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 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 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("orderId"); set => this.SetAdditionalData("orderId", value); } [JsonIgnore] public string OrderUrl { get => this.GetAdditionalData("orderUrl"); set => this.SetAdditionalData("orderUrl", value); } [JsonIgnore] public string PaymentRequestId { get => this.GetAdditionalData("paymentRequestId"); set => this.SetAdditionalData("paymentRequestId", value); } [JsonIgnore] public string BuyerName { get => this.GetAdditionalData("buyerName"); set => this.SetAdditionalData("buyerName", value); } [JsonIgnore] public string BuyerEmail { get => this.GetAdditionalData("buyerEmail"); set => this.SetAdditionalData("buyerEmail", value); } [JsonIgnore] public string BuyerCountry { get => this.GetAdditionalData("buyerCountry"); set => this.SetAdditionalData("buyerCountry", value); } [JsonIgnore] public string BuyerZip { get => this.GetAdditionalData("buyerZip"); set => this.SetAdditionalData("buyerZip", value); } [JsonIgnore] public string BuyerState { get => this.GetAdditionalData("buyerState"); set => this.SetAdditionalData("buyerState", value); } [JsonIgnore] public string BuyerCity { get => this.GetAdditionalData("buyerCity"); set => this.SetAdditionalData("buyerCity", value); } [JsonIgnore] public string BuyerAddress2 { get => this.GetAdditionalData("buyerAddress2"); set => this.SetAdditionalData("buyerAddress2", value); } [JsonIgnore] public string BuyerAddress1 { get => this.GetAdditionalData("buyerAddress1"); set => this.SetAdditionalData("buyerAddress1", value); } [JsonIgnore] public string BuyerPhone { get => this.GetAdditionalData("buyerPhone"); set => this.SetAdditionalData("buyerPhone", value); } [JsonIgnore] public string ItemDesc { get => this.GetAdditionalData("itemDesc"); set => this.SetAdditionalData("itemDesc", value); } [JsonIgnore] public string ItemCode { get => this.GetAdditionalData("itemCode"); set => this.SetAdditionalData("itemCode", value); } [JsonIgnore] public bool? Physical { get => this.GetAdditionalData("physical"); set => this.SetAdditionalData("physical", value); } [JsonIgnore] public decimal? TaxIncluded { get => this.GetAdditionalData("taxIncluded"); set => this.SetAdditionalData("taxIncluded", value); } /// /// 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. /// [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()); } catch { return null; } if (jt.Type == JTokenType.Object) return (JObject)jt; return null; } set { this.SetAdditionalData("posData", value); } } /// /// See comments on /// [JsonIgnore] public string PosDataLegacy { get { return this.GetAdditionalData("posData"); } set { if (value != null) { try { PosData = JObject.Parse(value); return; } catch { } } this.SetAdditionalData("posData", value); } } [JsonExtensionData] public IDictionary AdditionalData { get; set; } public static InvoiceMetadata FromJObject(JObject jObject) { return jObject.ToObject(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 AdditionalData { get; set; } [JsonProperty] public HashSet InternalTags { get; set; } = new HashSet(); public string[] GetInternalTags(string prefix) { return InternalTags == null ? Array.Empty() : 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 Rates { get; set; } = new Dictionary(); 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; } } } /// /// 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. /// [JsonIgnore] public decimal Dust { get; set; } /// /// The due to consider the invoice paid (can be negative if over payment) /// [JsonIgnore] public decimal NetDue { get; set; } /// /// Minimum due to consider the invoice paid (can be negative if overpaid) /// [JsonIgnore] public decimal MinimumNetDue { get; set; } [JsonIgnore] public bool IsUnderPaid => MinimumNetDue > 0; [JsonIgnore] public bool IsOverPaid => NetDue < 0; /// /// Total of network fee paid by accounted payments /// [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 Payments { get; set; } #pragma warning disable CS0618 public List GetPayments(bool accountedOnly) { return Payments?.Where(entity => (!accountedOnly || entity.Accounted)).ToList() ?? new List(); } public List 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 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 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 bitpayExtensions) { return EntityToDTO(bitpayExtensions, null); } public InvoiceResponse EntityToDTO(IDictionary bitpayExtensions, IUrlHelper urlHelper) { 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(), PaymentTotals = new Dictionary(), SupportedTransactionCurrencies = new Dictionary(), Addresses = new Dictionary(), PaymentCodes = new Dictionary(), ExchangeRates = new Dictionary>() }; dto.Url = ServerUrl.WithTrailingSlash() + $"invoice?id=" + Id; dto.CryptoInfo = new List(); dto.MinerFees = new Dictionary(); 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 { { 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); dto.PaymentSubtotals.Add(paymentCode, accounting.ToSmallestUnit(subtotalPrice)); dto.PaymentTotals.Add(paymentCode, accounting.ToSmallestUnit(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(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(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(status), exceptionStatus switch { "None" or "" or null => InvoiceExceptionStatus.None, _ => Enum.Parse(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 { public int Divisibility { get; set; } /// Total amount of this invoice public decimal TotalDue { get; set; } /// Amount of crypto remaining to pay this invoice public decimal Due { get; set; } /// Same as Due, can be negative public decimal DueUncapped { get; set; } /// If DueUncapped is negative, that means user overpaid invoice public decimal OverpaidHelper { get { return DueUncapped > 0.0m ? 0.0m : -DueUncapped; } } /// /// Total amount of the invoice paid after conversion to this crypto currency /// public decimal Paid { get; set; } /// /// Total amount of the invoice paid in this currency /// public decimal PaymentMethodPaid { get; set; } /// /// Number of transactions required to pay /// public int TxRequired { get; set; } /// /// Number of transactions using this payment method /// public int TxCount { get; set; } /// /// Amount of fee already paid + to be paid in the invoice's currency /// public decimal PaymentMethodFee { get; set; } /// /// Amount of fee already paid in the invoice's currency /// public decimal PaymentMethodFeeAlreadyPaid { get; set; } /// /// Minimum required to be paid in order to accept invoice as paid /// public decimal MinimumTotalDue { get; set; } public decimal ToSmallestUnit(decimal v) { for (int i = 0; i < Divisibility; i++) { v *= 10.0m; } return v; } public string ShowMoney(decimal v) => MoneyExtensions.ShowMoney(v, Divisibility); } 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; } 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.Divisibility = Divisibility; 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.PaymentMethodFeeAlreadyPaid = Coins(i.PaidFee / 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] /// public string InvoiceCurrency => InvoiceEntity.Currency; /// The amount paid by this payment in the /// [JsonIgnore] public Amounts PaidAmount { get; set; } /// /// The amount paid by this payment in the /// [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 }; } } /// /// A record of a payment /// public interface CryptoPaymentData { string GetPaymentProof(); } }