btcpayserver/BTCPayServer/Services/Invoices/InvoiceEntity.cs
Nicolas Dorier b4946f4db1
Fix divisibility in invoice details of lightning amounts (#6202)
* Fix divisibility in invoice details of lightning amounts

This PR will show 11 decimal in the invoice details for BTC amount
of lightning payment methods.

It also hacks around the fact that some
lightning clients don't create the requested amount of sats, which
resulted in over or under payments. (Blink not supporting msats, and
strike)

Now, In that case, a payment method fee (which can be negative) called tweak fee
will be added to the prompt.

We are also hiding this tweak fee from the user in the checkout page in
order to not disturb the UI with inconsequential fee of 0.000000001 sats.

* Only show 8 digits in checkout, even if amount is 11 digits
2024-09-12 12:43:08 +09:00

1019 lines
39 KiB
C#

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Globalization;
using System.Linq;
using System.Text;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.JsonConverters;
using BTCPayServer.Models;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient;
using NBXplorer;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using static BTCPayServer.Controllers.BitpayRateController;
namespace BTCPayServer.Services.Invoices
{
public class InvoiceCryptoInfo : NBitpayClient.InvoiceCryptoInfo
{
[JsonProperty("paymentUrls")]
public new InvoicePaymentUrls PaymentUrls { get; set; }
public class InvoicePaymentUrls : NBitpayClient.InvoicePaymentUrls
{
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
}
}
public class InvoiceMetadata : IHasAdditionalData
{
public static readonly JsonSerializer MetadataSerializer;
static InvoiceMetadata()
{
var seria = new JsonSerializer();
seria.DefaultValueHandling = DefaultValueHandling.Ignore;
seria.FloatParseHandling = FloatParseHandling.Decimal;
seria.ContractResolver = new CamelCasePropertyNamesContractResolver();
MetadataSerializer = seria;
}
[JsonIgnore]
public string OrderId
{
get => this.GetAdditionalData<string>("orderId");
set => this.SetAdditionalData("orderId", value);
}
[JsonIgnore]
public string OrderUrl
{
get => this.GetAdditionalData<string>("orderUrl");
set => this.SetAdditionalData("orderUrl", value);
}
[JsonIgnore]
public string PaymentRequestId
{
get => this.GetAdditionalData<string>("paymentRequestId");
set => this.SetAdditionalData("paymentRequestId", value);
}
[JsonIgnore]
public string BuyerName
{
get => this.GetAdditionalData<string>("buyerName");
set => this.SetAdditionalData("buyerName", value);
}
[JsonIgnore]
public string BuyerEmail
{
get => this.GetAdditionalData<string>("buyerEmail");
set => this.SetAdditionalData("buyerEmail", value);
}
[JsonIgnore]
public string BuyerCountry
{
get => this.GetAdditionalData<string>("buyerCountry");
set => this.SetAdditionalData("buyerCountry", value);
}
[JsonIgnore]
public string BuyerZip
{
get => this.GetAdditionalData<string>("buyerZip");
set => this.SetAdditionalData("buyerZip", value);
}
[JsonIgnore]
public string BuyerState
{
get => this.GetAdditionalData<string>("buyerState");
set => this.SetAdditionalData("buyerState", value);
}
[JsonIgnore]
public string BuyerCity
{
get => this.GetAdditionalData<string>("buyerCity");
set => this.SetAdditionalData("buyerCity", value);
}
[JsonIgnore]
public string BuyerAddress2
{
get => this.GetAdditionalData<string>("buyerAddress2");
set => this.SetAdditionalData("buyerAddress2", value);
}
[JsonIgnore]
public string BuyerAddress1
{
get => this.GetAdditionalData<string>("buyerAddress1");
set => this.SetAdditionalData("buyerAddress1", value);
}
[JsonIgnore]
public string BuyerPhone
{
get => this.GetAdditionalData<string>("buyerPhone");
set => this.SetAdditionalData("buyerPhone", value);
}
[JsonIgnore]
public string ItemDesc
{
get => this.GetAdditionalData<string>("itemDesc");
set => this.SetAdditionalData("itemDesc", value);
}
[JsonIgnore]
public string ItemCode
{
get => this.GetAdditionalData<string>("itemCode");
set => this.SetAdditionalData("itemCode", value);
}
[JsonIgnore]
public bool? Physical
{
get => this.GetAdditionalData<bool?>("physical");
set => this.SetAdditionalData("physical", value);
}
[JsonIgnore]
public decimal? TaxIncluded
{
get => this.GetAdditionalData<decimal?>("taxIncluded");
set => this.SetAdditionalData("taxIncluded", value);
}
/// <summary>
/// posData is a field that may be treated differently for presentation and in some legacy API
/// Before, it was a string field which could contain some JSON data inside.
/// For making it easier to query on the DB, and for logic using PosData in the code, we decided to
/// parse it as a JObject.
///
/// This property will return the posData as a JObject, even if it's a Json string inside.
/// </summary>
[JsonIgnore]
public JObject PosData
{
get
{
if (AdditionalData == null || !(AdditionalData.TryGetValue("posData", out var jt) is true))
return default;
if (jt.Type == JTokenType.Null)
return default;
if (jt.Type == JTokenType.String)
try
{
return JObject.Parse(jt.Value<string>());
}
catch
{
return null;
}
if (jt.Type == JTokenType.Object)
return (JObject)jt;
return null;
}
set
{
this.SetAdditionalData<JObject>("posData", value);
}
}
/// <summary>
/// See comments on <see cref="PosData"/>
/// </summary>
[JsonIgnore]
public string PosDataLegacy
{
get
{
return this.GetAdditionalData<string>("posData");
}
set
{
if (value != null)
{
try
{
PosData = JObject.Parse(value);
return;
}
catch
{
}
}
this.SetAdditionalData<string>("posData", value);
}
}
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; }
public static InvoiceMetadata FromJObject(JObject jObject)
{
return jObject.ToObject<InvoiceMetadata>(MetadataSerializer);
}
public JObject ToJObject()
{
return JObject.FromObject(this, MetadataSerializer);
}
}
public class InvoiceEntity : IHasAdditionalData
{
class BuyerInformation
{
[JsonProperty(PropertyName = "buyerName")]
public string BuyerName { get; set; }
[JsonProperty(PropertyName = "buyerEmail")]
public string BuyerEmail { get; set; }
[JsonProperty(PropertyName = "buyerCountry")]
public string BuyerCountry { get; set; }
[JsonProperty(PropertyName = "buyerZip")]
public string BuyerZip { get; set; }
[JsonProperty(PropertyName = "buyerState")]
public string BuyerState { get; set; }
[JsonProperty(PropertyName = "buyerCity")]
public string BuyerCity { get; set; }
[JsonProperty(PropertyName = "buyerAddress2")]
public string BuyerAddress2 { get; set; }
[JsonProperty(PropertyName = "buyerAddress1")]
public string BuyerAddress1 { get; set; }
[JsonProperty(PropertyName = "buyerPhone")]
public string BuyerPhone { get; set; }
}
class ProductInformation
{
[JsonProperty(PropertyName = "itemDesc")]
public string ItemDesc { get; set; }
[JsonProperty(PropertyName = "itemCode")]
public string ItemCode { get; set; }
[JsonProperty(PropertyName = "physical")]
public bool Physical { get; set; }
[JsonProperty(PropertyName = "price")]
public decimal Price { get; set; }
[JsonProperty(PropertyName = "taxIncluded", DefaultValueHandling = DefaultValueHandling.Ignore)]
public decimal TaxIncluded { get; set; }
[JsonProperty(PropertyName = "currency")]
public string Currency { get; set; }
}
public const int InternalTagSupport_Version = 1;
public const int GreenfieldInvoices_Version = 2;
public const int LeanInvoices_Version = 3;
public const int Lastest_Version = 3;
public int Version { get; set; }
[JsonIgnore]
public string Id { get; set; }
[JsonIgnore]
public string StoreId { get; set; }
public SpeedPolicy SpeedPolicy { get; set; }
[JsonProperty]
public string DefaultLanguage { get; set; }
[JsonIgnore]
public DateTimeOffset InvoiceTime { get; set; }
public DateTimeOffset ExpirationTime { get; set; }
public InvoiceMetadata Metadata { get; set; }
[JsonIgnore]
public decimal Price { get; set; }
[JsonIgnore]
public string Currency { get; set; }
[JsonConverter(typeof(PaymentMethodIdJsonConverter))]
public PaymentMethodId DefaultPaymentMethod { get; set; }
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; }
[JsonProperty]
public HashSet<string> InternalTags { get; set; } = new HashSet<string>();
public string[] GetInternalTags(string prefix)
{
return InternalTags == null ? Array.Empty<string>() : InternalTags
.Where(t => t.StartsWith(prefix, StringComparison.InvariantCulture))
.Select(t => t.Substring(prefix.Length)).ToArray();
}
public decimal GetInvoiceRate(string currency)
{
ArgumentNullException.ThrowIfNull(currency);
if (Currency is null)
throw new InvalidOperationException("The Currency of the invoice isn't set");
return GetRate(new CurrencyPair(currency, Currency));
}
public RateRules GetRateRules()
{
StringBuilder builder = new StringBuilder();
#pragma warning disable CS0618 // Type or member is obsolete
foreach (var r in Rates)
{
if (r.Key.Contains('_', StringComparison.Ordinal))
builder.AppendLine($"{r.Key} = {r.Value.ToString(CultureInfo.InvariantCulture)};");
else
builder.AppendLine($"{r.Key}_{Currency} = {r.Value.ToString(CultureInfo.InvariantCulture)};");
}
#pragma warning restore CS0618 // Type or member is obsolete
if (RateRules.TryParse(builder.ToString(), out var rules))
return rules;
throw new FormatException("Invalid rate rules");
}
public bool TryGetRate(string currency, out decimal rate)
{
return TryGetRate(new CurrencyPair(Currency, currency), out rate);
}
public bool TryGetRate(CurrencyPair pair, out decimal rate)
{
#pragma warning disable CS0618 // Type or member is obsolete
if (pair.Right == Currency && Rates.TryGetValue(pair.Left, out rate)) // Fast lane
return true;
#pragma warning restore CS0618 // Type or member is obsolete
var rule = GetRateRules().GetRuleFor(pair);
rule.Reevaluate();
if (rule.BidAsk is null)
{
rate = 0.0m;
return false;
}
rate = rule.BidAsk.Bid;
return true;
}
public decimal GetRate(CurrencyPair pair)
{
ArgumentNullException.ThrowIfNull(pair);
#pragma warning disable CS0618 // Type or member is obsolete
if (pair.Right == Currency && Rates.TryGetValue(pair.Left, out var rate)) // Fast lane
return rate;
#pragma warning restore CS0618 // Type or member is obsolete
var rule = GetRateRules().GetRuleFor(pair);
rule.Reevaluate();
if (rule.BidAsk is null)
throw new InvalidOperationException($"Rate rule is not evaluated ({rule.Errors.First()})");
return rule.BidAsk.Bid;
}
public void AddRate(CurrencyPair pair, decimal rate)
{
#pragma warning disable CS0618 // Type or member is obsolete
var v = pair.Right == Currency ? pair.Left : pair.ToString();
Rates.Add(v, rate);
#pragma warning restore CS0618 // Type or member is obsolete
}
[Obsolete("Use GetRate instead")]
[JsonProperty(ItemConverterType = typeof(NumericStringJsonConverter))]
public Dictionary<string, decimal> Rates
{
get;
set;
} = new Dictionary<string, decimal>();
public void UpdateTotals()
{
PaidAmount = new Amounts()
{
Currency = Currency
};
foreach (var payment in GetPayments(false))
{
payment.Rate = GetInvoiceRate(payment.Currency);
payment.InvoiceEntity = this;
payment.UpdateAmounts();
if (payment.Accounted)
{
PaidAmount.Gross += payment.InvoicePaidAmount.Gross;
PaidAmount.Net += payment.InvoicePaidAmount.Net;
}
}
NetDue = Price - PaidAmount.Net;
MinimumNetDue = Price * (1.0m - ((decimal)PaymentTolerance / 100.0m)) - PaidAmount.Net;
PaidFee = PaidAmount.Gross - PaidAmount.Net;
if (NetDue < 0.0m)
{
// If any payment method exactly pay the invoice, the overpayment is caused by
// rounding limitation of the underlying payment method.
// Document this overpayment as dust, and set the net due to 0
if (GetPaymentPrompts().Any(p => p.Calculate().DueUncapped == 0.0m))
{
Dust = -NetDue;
NetDue = 0.0m;
}
}
}
/// <summary>
/// Overpaid amount caused by payment method
/// Example: If you need to pay 124.4 sats, the on-chain payment need to be technically rounded to 125 sats, the extra 0.6 sats shouldn't be considered an over payment.
/// </summary>
[JsonIgnore]
public decimal Dust { get; set; }
/// <summary>
/// The due to consider the invoice paid (can be negative if over payment)
/// </summary>
[JsonIgnore]
public decimal NetDue
{
get;
set;
}
/// <summary>
/// Minimum due to consider the invoice paid (can be negative if overpaid)
/// </summary>
[JsonIgnore]
public decimal MinimumNetDue { get; set; }
[JsonIgnore]
public bool IsUnderPaid => MinimumNetDue > 0;
[JsonIgnore]
public bool IsOverPaid => NetDue < 0;
/// <summary>
/// Total of network fee paid by accounted payments
/// </summary>
[JsonIgnore]
public decimal PaidFee { get; set; }
[JsonIgnore]
public InvoiceStatus Status { get; set; }
[JsonIgnore]
public InvoiceExceptionStatus ExceptionStatus { get; set; }
[Obsolete("Use GetPayments instead")]
[JsonIgnore]
public List<PaymentEntity> Payments { get; set; }
#pragma warning disable CS0618
public List<PaymentEntity> GetPayments(bool accountedOnly)
{
return Payments?.Where(entity => (!accountedOnly || entity.Accounted)).ToList() ?? new List<PaymentEntity>();
}
public List<PaymentEntity> GetPayments(string currency, bool accountedOnly)
{
return GetPayments(accountedOnly).Where(p => p.Currency == currency).ToList();
}
#pragma warning restore CS0618
[JsonProperty]
public string StoreSupportUrl { get; set; }
[JsonProperty("redirectURL")]
public string RedirectURLTemplate { get; set; }
[JsonIgnore]
public Uri RedirectURL => FillPlaceholdersUri(RedirectURLTemplate);
private Uri FillPlaceholdersUri(string v)
{
var uriStr = (v ?? string.Empty).Replace("{OrderId}", System.Web.HttpUtility.UrlEncode(Metadata.OrderId) ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{InvoiceId}", System.Web.HttpUtility.UrlEncode(Id) ?? "", StringComparison.OrdinalIgnoreCase);
if (Uri.TryCreate(uriStr, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https"))
return uri;
return null;
}
[JsonProperty]
public bool RedirectAutomatically { get; set; }
public bool FullNotifications { get; set; }
[JsonProperty]
public string NotificationEmail { get; set; }
[JsonProperty("notificationURL")]
public string NotificationURLTemplate { get; set; }
[JsonIgnore]
public Uri NotificationURL => FillPlaceholdersUri(NotificationURLTemplate);
public string ServerUrl { get; set; }
[Obsolete("Use Set/GetPaymentPrompts() instead")]
[JsonProperty(PropertyName = "prompts")]
public JObject PaymentPrompts { get; set; }
[JsonProperty]
public DateTimeOffset MonitoringExpiration { get; set; }
[JsonIgnore]
public HashSet<string> AvailableAddressHashes { get; set; }
[JsonProperty]
public bool ExtendedNotifications { get; set; }
[JsonProperty]
public double PaymentTolerance { get; set; }
[JsonIgnore]
public bool Archived { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
[JsonProperty]
public InvoiceType Type { get; set; }
[JsonIgnore]
public List<RefundData> Refunds { get; set; }
[JsonProperty]
public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; }
[JsonProperty]
public bool LazyPaymentMethods { get; set; }
public bool IsExpired()
{
return DateTimeOffset.UtcNow > ExpirationTime;
}
public InvoiceResponse EntityToDTO(IDictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions, CurrencyNameTable currencyNameTable)
{
return EntityToDTO(bitpayExtensions, null, currencyNameTable);
}
public InvoiceResponse EntityToDTO(IDictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions, IUrlHelper urlHelper, CurrencyNameTable currencyNameTable)
{
ServerUrl = ServerUrl ?? "";
InvoiceResponse dto = new InvoiceResponse
{
Id = Id,
StoreId = StoreId,
OrderId = Metadata.OrderId,
PosData = Metadata.PosDataLegacy,
CurrentTime = DateTimeOffset.UtcNow,
InvoiceTime = InvoiceTime,
ExpirationTime = ExpirationTime,
Status = Status.ToLegacyStatusString(),
ExceptionStatus = ExceptionStatus == InvoiceExceptionStatus.None ? new JValue(false) : new JValue(ExceptionStatus.ToLegacyExceptionStatusString()),
Currency = Currency,
PaymentSubtotals = new Dictionary<string, decimal>(),
PaymentTotals = new Dictionary<string, decimal>(),
SupportedTransactionCurrencies = new Dictionary<string, NBitpayClient.InvoiceSupportedTransactionCurrency>(),
Addresses = new Dictionary<string, string>(),
PaymentCodes = new Dictionary<string, InvoiceCryptoInfo.InvoicePaymentUrls>(),
ExchangeRates = new Dictionary<string, Dictionary<string, decimal>>()
};
dto.Url = ServerUrl.WithTrailingSlash() + $"invoice?id=" + Id;
dto.CryptoInfo = new List<InvoiceCryptoInfo>();
dto.MinerFees = new Dictionary<string, MinerFeeInfo>();
foreach (var info in this.GetPaymentPrompts())
{
var accounting = info.Calculate();
var cryptoInfo = new InvoiceCryptoInfo();
var subtotalPrice = accounting.TotalDue - accounting.PaymentMethodFee;
var cryptoCode = info.Currency;
var address = info.Destination;
var exrates = new Dictionary<string, decimal>
{
{ Currency, cryptoInfo.Rate }
};
cryptoInfo.CryptoCode = cryptoCode;
cryptoInfo.PaymentType = info.PaymentMethodId.ToString();
cryptoInfo.Rate = info.Rate;
cryptoInfo.Price = subtotalPrice.ToString(CultureInfo.InvariantCulture);
cryptoInfo.Due = accounting.Due.ToString(CultureInfo.InvariantCulture);
cryptoInfo.Paid = accounting.Paid.ToString(CultureInfo.InvariantCulture);
cryptoInfo.TotalDue = accounting.TotalDue.ToString(CultureInfo.InvariantCulture);
cryptoInfo.NetworkFee = accounting.PaymentMethodFee.ToString(CultureInfo.InvariantCulture);
cryptoInfo.TxCount = accounting.TxCount;
cryptoInfo.CryptoPaid = accounting.PaymentMethodPaid.ToString(CultureInfo.InvariantCulture);
cryptoInfo.Address = address;
cryptoInfo.ExRates = exrates;
var paymentId = info.PaymentMethodId;
cryptoInfo.Url = ServerUrl.WithTrailingSlash() + $"i/{paymentId}/{Id}";
cryptoInfo.Payments = GetPayments(info.Currency, true).Select(entity =>
{
return new InvoicePaymentInfo()
{
Id = entity.Id,
Fee = entity.PaymentMethodFee,
Value = entity.Value,
Completed = entity.Status is PaymentStatus.Settled,
Confirmed = entity.Status is PaymentStatus.Settled,
Destination = entity.Destination,
PaymentType = entity.PaymentMethodId.ToString(),
ReceivedDate = entity.ReceivedTime.DateTime
};
}).ToList();
if (info.Activated)
{
if (bitpayExtensions.TryGetValue(paymentId, out var e))
e.PopulateCryptoInfo(cryptoInfo, dto, info, urlHelper);
}
dto.CryptoInfo.Add(cryptoInfo);
// Ideally, this should just be the payment id, but this
// is for legacy compatibility with the Bitpay API
var paymentCode = GetPaymentCode(info.Currency, paymentId);
dto.PaymentCodes.Add(paymentCode, cryptoInfo.PaymentUrls);
if (info.Currency is not null && currencyNameTable.GetCurrencyData(info.Currency, true)?.Divisibility is int divisibility)
{
dto.PaymentSubtotals.Add(paymentCode, BitcoinPaymentMethodBitpayAPIExtension.ToSmallestUnit(divisibility, subtotalPrice));
dto.PaymentTotals.Add(paymentCode, BitcoinPaymentMethodBitpayAPIExtension.ToSmallestUnit(divisibility, accounting.TotalDue));
}
dto.SupportedTransactionCurrencies.TryAdd(cryptoCode, new InvoiceSupportedTransactionCurrency()
{
Enabled = true
});
dto.Addresses.Add(paymentCode, address);
dto.ExchangeRates.TryAdd(cryptoCode, exrates);
}
//dto.AmountPaid dto.MinerFees & dto.TransactionCurrency are not supported by btcpayserver as we have multi currency payment support per invoice
dto.ItemCode = Metadata.ItemCode;
dto.ItemDesc = Metadata.ItemDesc;
dto.TaxIncluded = Metadata.TaxIncluded ?? 0m;
dto.Price = Price;
dto.Currency = Currency;
dto.Buyer = new JObject();
dto.Buyer.Add(new JProperty("name", Metadata.BuyerName));
dto.Buyer.Add(new JProperty("address1", Metadata.BuyerAddress1));
dto.Buyer.Add(new JProperty("address2", Metadata.BuyerAddress2));
dto.Buyer.Add(new JProperty("locality", Metadata.BuyerCity));
dto.Buyer.Add(new JProperty("region", Metadata.BuyerState));
dto.Buyer.Add(new JProperty("postalCode", Metadata.BuyerZip));
dto.Buyer.Add(new JProperty("country", Metadata.BuyerCountry));
dto.Buyer.Add(new JProperty("phone", Metadata.BuyerPhone));
dto.Buyer.Add(new JProperty("email", Metadata.BuyerEmail));
dto.Token = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); //No idea what it is useful for
dto.Guid = Guid.NewGuid().ToString();
return dto;
}
private static string GetPaymentCode(string currency, PaymentMethodId paymentId)
{
return PaymentTypes.CHAIN.GetPaymentMethodId(currency) == paymentId ? currency : paymentId.ToString();
}
#nullable enable
internal bool Support(PaymentMethodId paymentMethodId)
{
var rates = GetPaymentPrompts();
return rates.TryGet(paymentMethodId) != null;
}
public PaymentPrompt? GetPaymentPrompt(PaymentMethodId paymentMethodId)
{
#pragma warning disable CS0618 // Type or member is obsolete
if (PaymentPrompts is null)
return null;
var pm = PaymentPrompts[paymentMethodId.ToString()];
#pragma warning restore CS0618 // Type or member is obsolete
if (pm is null or JToken { Type: JTokenType.Null })
return null;
var r = pm.ToObject<PaymentPrompt>(InvoiceDataExtensions.DefaultSerializer)!;
r.ParentEntity = this;
r.PaymentMethodId = paymentMethodId;
return r;
}
public PaymentPromptDictionary GetPaymentPrompts()
{
PaymentPromptDictionary paymentMethods = new PaymentPromptDictionary();
#pragma warning disable CS0618
if (PaymentPrompts != null)
{
foreach (var prop in PaymentPrompts.Properties())
{
if (!PaymentMethodId.TryParse(prop.Name, out var paymentMethodId))
{
continue;
}
if (prop.Value?.Type is not JTokenType.Object)
{
continue;
}
var r = prop.Value.ToObject<PaymentPrompt>(InvoiceDataExtensions.DefaultSerializer)!;
r.ParentEntity = this;
r.PaymentMethodId = paymentMethodId;
paymentMethods.Add(r);
}
}
#pragma warning restore CS0618
return paymentMethods;
}
public void SetPaymentPrompt(PaymentMethodId paymentMethodId, PaymentPrompt paymentMethod)
{
var dict = GetPaymentPrompts();
paymentMethod.PaymentMethodId = paymentMethodId;
paymentMethod.ParentEntity = this;
dict.AddOrReplace(paymentMethod);
SetPaymentPrompts(dict);
}
public void SetPaymentPrompts(PaymentPromptDictionary paymentMethods)
{
var obj = new JObject();
#pragma warning disable CS0618
foreach (var v in paymentMethods)
{
obj.Add(new JProperty(v.PaymentMethodId.ToString(), JToken.FromObject(v, InvoiceDataExtensions.DefaultSerializer)));
}
PaymentPrompts = obj;
foreach (var cryptoData in paymentMethods)
{
cryptoData.ParentEntity = this;
}
#pragma warning restore CS0618
UpdateTotals();
}
#nullable restore
public InvoiceState GetInvoiceState()
{
return new InvoiceState(Status, ExceptionStatus);
}
public bool IsUnsetTopUp()
{
return Type == InvoiceType.TopUp && Price == 0.0m;
}
[JsonIgnore]
public Amounts PaidAmount { get; set; }
}
public enum InvoiceStatusLegacy
{
}
public static class InvoiceStatusLegacyExtensions
{
public static string ToLegacyStatusString(this InvoiceStatus status) =>
status switch
{
InvoiceStatus.Settled => "complete",
InvoiceStatus.Expired => "expired",
InvoiceStatus.Invalid => "invalid",
InvoiceStatus.Processing => "paid",
InvoiceStatus.New => "new",
_ => throw new NotSupportedException(status.ToString())
};
public static string ToLegacyExceptionStatusString(this InvoiceExceptionStatus status) =>
status switch
{
InvoiceExceptionStatus.None => string.Empty,
InvoiceExceptionStatus.PaidLate => "paidLater",
InvoiceExceptionStatus.PaidPartial => "paidPartial",
InvoiceExceptionStatus.PaidOver => "paidOver",
InvoiceExceptionStatus.Marked => "marked",
_ => throw new NotSupportedException(status.ToString())
};
}
public record InvoiceState(InvoiceStatus Status, InvoiceExceptionStatus ExceptionStatus)
{
public InvoiceState(string status, string exceptionStatus):
this(Enum.Parse<InvoiceStatus>(status), exceptionStatus switch { "None" or "" or null => InvoiceExceptionStatus.None, _ => Enum.Parse<InvoiceExceptionStatus>(exceptionStatus) })
{
}
public bool CanMarkComplete()
{
return Status is InvoiceStatus.New or InvoiceStatus.Processing or InvoiceStatus.Expired or InvoiceStatus.Invalid ||
(Status != InvoiceStatus.Settled && ExceptionStatus == InvoiceExceptionStatus.Marked);
}
public bool CanMarkInvalid()
{
return Status is InvoiceStatus.New or InvoiceStatus.Processing or InvoiceStatus.Expired ||
(Status != InvoiceStatus.Invalid && ExceptionStatus == InvoiceExceptionStatus.Marked);
}
public bool CanRefund()
{
return
Status == InvoiceStatus.Settled ||
(Status == InvoiceStatus.Expired &&
(ExceptionStatus == InvoiceExceptionStatus.PaidLate ||
ExceptionStatus == InvoiceExceptionStatus.PaidOver ||
ExceptionStatus == InvoiceExceptionStatus.PaidPartial)) ||
Status == InvoiceStatus.Invalid;
}
public bool IsSettled()
{
return
Status == InvoiceStatus.Settled ||
(Status == InvoiceStatus.Expired &&
ExceptionStatus is InvoiceExceptionStatus.PaidLate or InvoiceExceptionStatus.PaidOver);
}
public override string ToString()
{
return Status + ExceptionStatus switch
{
InvoiceExceptionStatus.PaidOver => " (paid over)",
InvoiceExceptionStatus.PaidLate => " (paid late)",
InvoiceExceptionStatus.PaidPartial => " (paid partial)",
InvoiceExceptionStatus.Marked => " (marked)",
_ => ""
};
}
}
public class PaymentMethodAccounting
{
/// <summary>Total amount of this invoice</summary>
public decimal TotalDue { get; set; }
/// <summary>Amount of crypto remaining to pay this invoice</summary>
public decimal Due { get; set; }
/// <summary>Same as Due, can be negative</summary>
public decimal DueUncapped { get; set; }
/// <summary>If DueUncapped is negative, that means user overpaid invoice</summary>
public decimal OverpaidHelper
{
get { return DueUncapped > 0.0m ? 0.0m : -DueUncapped; }
}
/// <summary>
/// Total amount of the invoice paid after conversion to this crypto currency
/// </summary>
public decimal Paid { get; set; }
/// <summary>
/// Total amount of the invoice paid in this currency
/// </summary>
public decimal PaymentMethodPaid { get; set; }
/// <summary>
/// Number of transactions required to pay
/// </summary>
public int TxRequired { get; set; }
/// <summary>
/// Number of transactions using this payment method
/// </summary>
public int TxCount { get; set; }
/// <summary>
/// Amount of fee already paid + to be paid in the invoice's currency
/// </summary>
public decimal PaymentMethodFee { get; set; }
/// <summary>
/// Minimum required to be paid in order to accept invoice as paid
/// </summary>
public decimal MinimumTotalDue { get; set; }
}
public class PaymentPrompt
{
[JsonIgnore]
public bool Activated => !Inactive;
public bool Inactive { get; set; }
[JsonIgnore]
public InvoiceEntity ParentEntity { get; set; }
[JsonIgnore]
public PaymentMethodId PaymentMethodId { get; set; }
public string Currency { get; set; }
[JsonIgnore]
public decimal Rate => Currency is null ? throw new InvalidOperationException("Currency of the payment prompt isn't set") : ParentEntity.GetInvoiceRate(Currency);
public int Divisibility { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal PaymentMethodFee { get; set; }
/// <summary>
/// A fee, hidden from UI, meant to be used when a payment method has a service provider which
/// have a different way of converting the invoice's amount into the currency of the payment method.
/// This fee can avoid under/over payments when this case happens.
///
/// Please use <see cref="AddTweakFee(decimal)"/> so that the tweak fee is also added to the <see cref="PaymentMethodFee"/>.
/// </summary>
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal TweakFee { get; set; }
/// <summary>
/// A fee, hidden from UI, meant to be used when a payment method has a service provider which
/// have a different way of converting the invoice's amount into the currency of the payment method.
/// This fee can avoid under/over payments when this case happens.
/// </summary>
/// <param name="value"></param>
public void AddTweakFee(decimal value)
{
TweakFee += value;
PaymentMethodFee += value;
}
public string Destination { get; set; }
public JToken Details { get; set; }
public PaymentMethodAccounting Calculate()
{
var i = ParentEntity;
var accounting = new PaymentMethodAccounting();
var thisPaymentMethodPayments = i.GetPayments(true).Where(p => PaymentMethodId == p.PaymentMethodId).ToList();
accounting.TxCount = thisPaymentMethodPayments.Count;
accounting.TxRequired = accounting.TxCount;
var grossDue = i.Price + i.PaidFee;
var rate = Rate;
if (i.MinimumNetDue > 0.0m)
{
accounting.TxRequired++;
grossDue += rate * PaymentMethodFee;
}
accounting.TotalDue = Coins(grossDue / rate, Divisibility);
accounting.Paid = Coins(i.PaidAmount.Gross / rate, Divisibility);
accounting.PaymentMethodPaid = Coins(thisPaymentMethodPayments.Sum(p => p.PaidAmount.Gross), Divisibility);
// This one deal with the fact where it might looks like a slight over payment due to the dust of another payment method.
// So if we detect the NetDue is zero, just cap dueUncapped to 0
var dueUncapped = i.NetDue == 0.0m ? 0.0m : grossDue - i.PaidAmount.Gross;
accounting.DueUncapped = Coins(dueUncapped / rate, Divisibility);
accounting.Due = Max(accounting.DueUncapped, 0.0m);
accounting.PaymentMethodFee = Coins((grossDue - i.Price) / rate, Divisibility);
accounting.MinimumTotalDue = Max(Smallest(Divisibility), Coins((grossDue * (1.0m - ((decimal)i.PaymentTolerance / 100.0m))) / rate, Divisibility));
return accounting;
}
private decimal Smallest(int precision)
{
decimal a = 1.0m;
for (int i = 0; i < precision; i++)
{
a /= 10.0m;
}
return a;
}
decimal Max(decimal a, decimal b) => a > b ? a : b;
const decimal MaxCoinValue = decimal.MaxValue / 1_0000_0000m;
internal static decimal Coins(decimal v, int precision)
{
v = Extensions.RoundUp(v, precision);
// Clamp the value to not crash on degenerate invoices
if (v > MaxCoinValue)
v = MaxCoinValue;
return v;
}
}
public class PaymentEntity : PaymentBlob
{
[JsonIgnore]
public DateTimeOffset ReceivedTime
{
get;
set;
}
[JsonIgnore]
public PaymentStatus Status { get; set; }
[JsonIgnore]
public bool Accounted => Status is PaymentStatus.Settled or PaymentStatus.Processing;
[JsonIgnore]
public string Currency
{
get;
set;
}
[JsonIgnore]
public PaymentMethodId PaymentMethodId { get; set; }
[JsonIgnore]
public decimal Rate { get; set; }
[JsonIgnore]
/// <summary>
public string InvoiceCurrency => InvoiceEntity.Currency;
/// The amount paid by this payment in the <see cref="Currency"/>
/// </summary>
[JsonIgnore]
public Amounts PaidAmount { get; set; }
/// <summary>
/// The amount paid by this payment in the <see cref="InvoiceCurrency"/>
/// </summary>
[JsonIgnore]
public Amounts InvoicePaidAmount { get; set; }
[JsonIgnore]
public InvoiceEntity InvoiceEntity { get; set; }
[JsonIgnore]
public decimal Value { get; set; }
[JsonIgnore]
public string Id { get; set; }
public void UpdateAmounts()
{
var value = Value;
PaidAmount = new Amounts()
{
Currency = Currency,
Gross = Value,
Net = Value - PaymentMethodFee
};
InvoicePaidAmount = new Amounts()
{
Currency = InvoiceCurrency,
Gross = PaidAmount.Gross * Rate,
Net = PaidAmount.Net * Rate
};
}
}
/// <summary>
/// A record of a payment
/// </summary>
public interface CryptoPaymentData
{
string GetPaymentProof();
}
}