btcpayserver/BTCPayServer/Controllers/InvoiceController.cs

429 lines
23 KiB
C#
Raw Normal View History

2020-06-29 04:44:35 +02:00
using System;
2017-09-13 08:47:34 +02:00
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
2017-09-13 08:47:34 +02:00
using System.Text;
using System.Threading;
2017-09-13 08:47:34 +02:00
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
2017-09-13 08:47:34 +02:00
using BTCPayServer.Data;
2019-01-06 10:12:45 +01:00
using BTCPayServer.Events;
2020-06-24 10:51:00 +02:00
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.Security;
2019-02-19 05:04:58 +01:00
using BTCPayServer.Services.Apps;
2017-10-20 21:06:37 +02:00
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
2018-11-06 08:08:42 +01:00
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json;
using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest;
using StoreData = BTCPayServer.Data.StoreData;
2017-09-13 08:47:34 +02:00
namespace BTCPayServer.Controllers
{
[Filters.BitpayAPIConstraint(false)]
public partial class InvoiceController : Controller
{
readonly InvoiceRepository _InvoiceRepository;
readonly ContentSecurityPolicies _CSP;
readonly RateFetcher _RateProvider;
readonly StoreRepository _StoreRepository;
readonly UserManager<ApplicationUser> _UserManager;
private readonly CurrencyNameTable _CurrencyNameTable;
readonly EventAggregator _EventAggregator;
readonly BTCPayNetworkProvider _NetworkProvider;
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
2020-06-24 10:51:00 +02:00
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly PullPaymentHostedService _paymentHostedService;
readonly IServiceProvider _ServiceProvider;
2020-11-06 12:42:26 +01:00
public WebhookNotificationManager WebhookNotificationManager { get; }
public InvoiceController(
IServiceProvider serviceProvider,
InvoiceRepository invoiceRepository,
2017-10-27 11:58:43 +02:00
CurrencyNameTable currencyNameTable,
UserManager<ApplicationUser> userManager,
RateFetcher rateProvider,
StoreRepository storeRepository,
EventAggregator eventAggregator,
2018-07-12 10:38:21 +02:00
ContentSecurityPolicies csp,
BTCPayNetworkProvider networkProvider,
2020-06-24 10:51:00 +02:00
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
ApplicationDbContextFactory dbContextFactory,
2020-11-06 12:42:26 +01:00
PullPaymentHostedService paymentHostedService,
WebhookNotificationManager webhookNotificationManager)
{
_ServiceProvider = serviceProvider;
2017-10-27 11:58:43 +02:00
_CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
_StoreRepository = storeRepository ?? throw new ArgumentNullException(nameof(storeRepository));
_InvoiceRepository = invoiceRepository ?? throw new ArgumentNullException(nameof(invoiceRepository));
2018-05-02 20:32:42 +02:00
_RateProvider = rateProvider ?? throw new ArgumentNullException(nameof(rateProvider));
_UserManager = userManager;
_EventAggregator = eventAggregator;
_NetworkProvider = networkProvider;
_paymentMethodHandlerDictionary = paymentMethodHandlerDictionary;
2020-06-24 10:51:00 +02:00
_dbContextFactory = dbContextFactory;
_paymentHostedService = paymentHostedService;
2020-11-06 12:42:26 +01:00
WebhookNotificationManager = webhookNotificationManager;
2018-07-12 10:38:21 +02:00
_CSP = csp;
}
2017-09-13 08:47:34 +02:00
internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice,
2020-07-22 13:58:41 +02:00
StoreData store, string serverUrl, List<string> additionalTags = null,
CancellationToken cancellationToken = default)
{
var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken);
var resp = entity.EntityToDTO();
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
2020-07-22 13:58:41 +02:00
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string> additionalTags = null, CancellationToken cancellationToken = default)
{
var storeBlob = store.GetStoreBlob();
var entity = _InvoiceRepository.CreateNewInvoice();
entity.ExpirationTime = invoice.ExpirationTime is DateTimeOffset v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration;
entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration;
if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
{
throw new BitpayHttpException(400, "The expirationTime is set too soon");
}
invoice.Currency = invoice.Currency?.ToUpperInvariant() ?? "USD";
entity.Currency = invoice.Currency;
entity.Metadata.OrderId = invoice.OrderId;
entity.Metadata.PosData = invoice.PosData;
entity.ServerUrl = serverUrl;
2018-01-07 20:14:35 +01:00
entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
entity.ExtendedNotifications = invoice.ExtendedNotifications;
entity.NotificationURLTemplate = invoice.NotificationURL;
entity.NotificationEmail = invoice.NotificationEmail;
2019-02-19 03:14:21 +01:00
if (additionalTags != null)
entity.InternalTags.AddRange(additionalTags);
FillBuyerInfo(invoice, entity);
2019-01-15 14:12:29 +01:00
var taxIncluded = invoice.TaxIncluded.HasValue ? invoice.TaxIncluded.Value : 0m;
var currencyInfo = _CurrencyNameTable.GetNumberFormatInfo(invoice.Currency, false);
if (currencyInfo != null)
2019-01-15 14:12:29 +01:00
{
int divisibility = currencyInfo.CurrencyDecimalDigits;
invoice.Price = invoice.Price.RoundToSignificant(ref divisibility);
divisibility = currencyInfo.CurrencyDecimalDigits;
invoice.TaxIncluded = taxIncluded.RoundToSignificant(ref divisibility);
2019-01-15 14:12:29 +01:00
}
invoice.Price = Math.Max(0.0m, invoice.Price);
invoice.TaxIncluded = Math.Max(0.0m, taxIncluded);
invoice.TaxIncluded = Math.Min(taxIncluded, invoice.Price);
entity.Metadata.ItemCode = invoice.ItemCode;
entity.Metadata.ItemDesc = invoice.ItemDesc;
entity.Metadata.Physical = invoice.Physical;
entity.Metadata.TaxIncluded = invoice.TaxIncluded;
entity.Currency = invoice.Currency;
entity.Price = invoice.Price;
2019-01-15 14:12:29 +01:00
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
entity.RedirectAutomatically =
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
IPaymentFilter excludeFilter = null;
if (invoice.PaymentCurrencies?.Any() is true)
{
invoice.SupportedTransactionCurrencies ??=
new Dictionary<string, InvoiceSupportedTransactionCurrency>();
foreach (string paymentCurrency in invoice.PaymentCurrencies)
{
invoice.SupportedTransactionCurrencies.TryAdd(paymentCurrency,
2020-06-28 10:55:27 +02:00
new InvoiceSupportedTransactionCurrency() { Enabled = true });
}
}
if (invoice.SupportedTransactionCurrencies != null && invoice.SupportedTransactionCurrencies.Count != 0)
{
var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies
.Where(c => c.Value.Enabled)
.Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null)
.Where(c => c != null)
.ToHashSet();
excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
}
entity.PaymentTolerance = storeBlob.PaymentTolerance;
return await CreateInvoiceCoreRaw(entity, store, excludeFilter, cancellationToken);
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string> additionalTags = null, CancellationToken cancellationToken = default)
{
var storeBlob = store.GetStoreBlob();
var entity = _InvoiceRepository.CreateNewInvoice();
entity.ExpirationTime = entity.InvoiceTime + (invoice.Checkout.Expiration ?? storeBlob.InvoiceExpiration);
entity.MonitoringExpiration = entity.ExpirationTime + (invoice.Checkout.Monitoring ?? storeBlob.MonitoringExpiration);
if (invoice.Metadata != null)
entity.Metadata = InvoiceMetadata.FromJObject(invoice.Metadata);
invoice.Checkout ??= new CreateInvoiceRequest.CheckoutOptions();
entity.Currency = invoice.Currency;
entity.Price = invoice.Amount;
entity.SpeedPolicy = invoice.Checkout.SpeedPolicy ?? store.SpeedPolicy;
entity.DefaultLanguage = invoice.Checkout.DefaultLanguage;
IPaymentFilter excludeFilter = null;
if (invoice.Checkout.PaymentMethods != null)
{
var supportedTransactionCurrencies = invoice.Checkout.PaymentMethods
.Select(c => PaymentMethodId.TryParse(c, out var p) ? p : null)
.ToHashSet();
excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
}
entity.PaymentTolerance = invoice.Checkout.PaymentTolerance ?? storeBlob.PaymentTolerance;
entity.RedirectURLTemplate = invoice.Checkout.RedirectURL?.Trim();
if (additionalTags != null)
entity.InternalTags.AddRange(additionalTags);
return await CreateInvoiceCoreRaw(entity, store, excludeFilter, cancellationToken);
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(InvoiceEntity entity, StoreData store, IPaymentFilter invoicePaymentMethodFilter, CancellationToken cancellationToken = default)
{
InvoiceLogs logs = new InvoiceLogs();
logs.Write("Creation of invoice starting", InvoiceEventData.EventSeverity.Info);
var getAppsTaggingStore = _InvoiceRepository.GetAppsTaggingStore(store.Id);
var storeBlob = store.GetStoreBlob();
if (entity.Metadata.BuyerEmail != null)
{
if (!EmailValidator.IsEmail(entity.Metadata.BuyerEmail))
throw new BitpayHttpException(400, "Invalid email");
entity.RefundMail = entity.Metadata.BuyerEmail;
}
entity.Status = InvoiceStatusLegacy.New;
HashSet<CurrencyPair> currencyPairsToFetch = new HashSet<CurrencyPair>();
var rules = storeBlob.GetRateRules(_NetworkProvider);
var excludeFilter = storeBlob.GetExcludedPaymentMethods(); // Here we can compose filters from other origin with PaymentFilter.Any()
if (invoicePaymentMethodFilter != null)
{
excludeFilter = PaymentFilter.Or(excludeFilter,
invoicePaymentMethodFilter);
}
2018-05-02 20:32:42 +02:00
foreach (var network in store.GetSupportedPaymentMethods(_NetworkProvider)
2018-07-27 13:37:16 +02:00
.Where(s => !excludeFilter.Match(s.PaymentId))
.Select(c => _NetworkProvider.GetNetwork<BTCPayNetworkBase>(c.PaymentId.CryptoCode))
2018-05-02 20:32:42 +02:00
.Where(c => c != null))
{
currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, entity.Currency));
foreach (var paymentMethodCriteria in store.GetPaymentMethodCriteria(_NetworkProvider, storeBlob))
{
if (paymentMethodCriteria.Value != null)
{
currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, paymentMethodCriteria.Value.Currency));
2020-11-09 06:37:45 +01:00
}
}
2018-05-02 20:32:42 +02:00
}
var rateRules = storeBlob.GetRateRules(_NetworkProvider);
var fetchingByCurrencyPair = _RateProvider.FetchRates(currencyPairsToFetch, rateRules, cancellationToken);
var fetchingAll = WhenAllFetched(logs, fetchingByCurrencyPair);
List<ISupportedPaymentMethod> supported = new List<ISupportedPaymentMethod>();
var paymentMethods = new PaymentMethodDictionary();
// This loop ends with .ToList so we are querying all payment methods at once
// instead of sequentially to improve response time
foreach (var o in store.GetSupportedPaymentMethods(_NetworkProvider)
.Where(s => !excludeFilter.Match(s.PaymentId) && _paymentMethodHandlerDictionary.Support(s.PaymentId))
.Select(c =>
(Handler: _paymentMethodHandlerDictionary[c.PaymentId],
SupportedPaymentMethod: c,
Network: _NetworkProvider.GetNetwork<BTCPayNetworkBase>(c.PaymentId.CryptoCode)))
.Where(c => c.Network != null)
.Select(o =>
(SupportedPaymentMethod: o.SupportedPaymentMethod,
PaymentMethod: CreatePaymentMethodAsync(fetchingByCurrencyPair, o.Handler, o.SupportedPaymentMethod, o.Network, entity, store, logs)))
.ToList())
{
var paymentMethod = await o.PaymentMethod;
if (paymentMethod == null)
continue;
supported.Add(o.SupportedPaymentMethod);
paymentMethods.Add(paymentMethod);
}
if (supported.Count == 0)
{
StringBuilder errors = new StringBuilder();
if (!store.GetSupportedPaymentMethods(_NetworkProvider).Any())
2020-06-11 16:09:33 +02:00
errors.AppendLine("Warning: No wallet has been linked to your BTCPay Store. See the following link for more information on how to connect your store and wallet. (https://docs.btcpayserver.org/WalletSetup/)");
foreach (var error in logs.ToList())
{
errors.AppendLine(error.ToString());
}
throw new BitpayHttpException(400, errors.ToString());
}
entity.SetSupportedPaymentMethods(supported);
entity.SetPaymentMethods(paymentMethods);
2019-02-19 04:48:08 +01:00
foreach (var app in await getAppsTaggingStore)
{
2019-02-19 05:04:58 +01:00
entity.InternalTags.Add(AppService.GetAppInternalTag(app.Id));
2019-02-19 04:48:08 +01:00
}
using (logs.Measure("Saving invoice"))
{
entity = await _InvoiceRepository.CreateInvoiceAsync(store.Id, entity);
}
_ = Task.Run(async () =>
{
try
{
await fetchingAll;
}
catch (AggregateException ex)
{
ex.Handle(e => { logs.Write($"Error while fetching rates {ex}", InvoiceEventData.EventSeverity.Error); return true; });
}
await _InvoiceRepository.AddInvoiceLogs(entity.Id, logs);
});
2020-07-24 09:40:37 +02:00
_EventAggregator.Publish(new Events.InvoiceEvent(entity, InvoiceEvent.Created));
2020-07-22 13:58:41 +02:00
return entity;
}
2017-09-13 08:47:34 +02:00
private Task WhenAllFetched(InvoiceLogs logs, Dictionary<CurrencyPair, Task<RateResult>> fetchingByCurrencyPair)
{
return Task.WhenAll(fetchingByCurrencyPair.Select(async pair =>
{
var rateResult = await pair.Value;
logs.Write($"{pair.Key}: The rating rule is {rateResult.Rule}", InvoiceEventData.EventSeverity.Info);
logs.Write($"{pair.Key}: The evaluated rating rule is {rateResult.EvaluatedRule}", InvoiceEventData.EventSeverity.Info);
if (rateResult.Errors.Count != 0)
{
var allRateRuleErrors = string.Join(", ", rateResult.Errors.ToArray());
logs.Write($"{pair.Key}: Rate rule error ({allRateRuleErrors})", InvoiceEventData.EventSeverity.Error);
}
2018-08-22 17:24:33 +02:00
foreach (var ex in rateResult.ExchangeExceptions)
{
logs.Write($"{pair.Key}: Exception reaching exchange {ex.ExchangeName} ({ex.Exception.Message})", InvoiceEventData.EventSeverity.Error);
}
}).ToArray());
}
2018-04-03 10:39:28 +02:00
2020-11-09 06:37:45 +01:00
private async Task<PaymentMethod> CreatePaymentMethodAsync(Dictionary<CurrencyPair, Task<RateResult>> fetchingByCurrencyPair,
IPaymentMethodHandler handler, ISupportedPaymentMethod supportedPaymentMethod, BTCPayNetworkBase network, InvoiceEntity entity,
StoreData store, InvoiceLogs logs)
{
try
2018-04-03 10:39:28 +02:00
{
var logPrefix = $"{supportedPaymentMethod.PaymentId.ToPrettyString()}:";
var storeBlob = store.GetStoreBlob();
var preparePayment = handler.PreparePayment(supportedPaymentMethod, store, network);
var rate = await fetchingByCurrencyPair[new CurrencyPair(network.CryptoCode, entity.Currency)];
if (rate.BidAsk == null)
{
return null;
}
2020-11-09 06:37:45 +01:00
var paymentMethod = new PaymentMethod
{
ParentEntity = entity,
Network = network,
Rate = rate.BidAsk.Bid,
PreferOnion = Uri.TryCreate(entity.ServerUrl, UriKind.Absolute, out var u) && u.DnsSafeHost.EndsWith(".onion", StringComparison.OrdinalIgnoreCase)
};
paymentMethod.SetId(supportedPaymentMethod.PaymentId);
using (logs.Measure($"{logPrefix} Payment method details creation"))
{
2020-03-29 17:28:22 +02:00
var paymentDetails = await handler.CreatePaymentMethodDetails(logs, supportedPaymentMethod, paymentMethod, store, network, preparePayment);
paymentMethod.SetPaymentMethodDetails(paymentDetails);
}
var criteria = store.GetPaymentMethodCriteria(_NetworkProvider, storeBlob)?.Find(methodCriteria => methodCriteria.PaymentMethod == supportedPaymentMethod.PaymentId);
if (criteria?.Value != null)
{
var currentRateToCrypto =
await fetchingByCurrencyPair[new CurrencyPair(supportedPaymentMethod.PaymentId.CryptoCode, criteria.Value.Currency)];
if (currentRateToCrypto?.BidAsk != null)
{
var amount = paymentMethod.Calculate().Due.GetValue(network as BTCPayNetwork);
var limitValueCrypto = criteria.Value.Value / currentRateToCrypto.BidAsk.Bid;
2020-11-09 06:37:45 +01:00
if (amount < limitValueCrypto && criteria.Above)
{
logs.Write($"{logPrefix} invoice amount below accepted value for payment method", InvoiceEventData.EventSeverity.Error);
return null;
}
if (amount > limitValueCrypto && !criteria.Above)
{
logs.Write($"{logPrefix} invoice amount above accepted value for payment method", InvoiceEventData.EventSeverity.Error);
return null;
}
}
}
#pragma warning disable CS0618
if (paymentMethod.GetId().IsBTCOnChain)
{
2019-01-07 07:35:18 +01:00
entity.TxFee = paymentMethod.NextNetworkFee;
entity.Rate = paymentMethod.Rate;
entity.DepositAddress = paymentMethod.DepositAddress;
}
#pragma warning restore CS0618
return paymentMethod;
}
catch (PaymentMethodUnavailableException ex)
{
logs.Write($"{supportedPaymentMethod.PaymentId.CryptoCode}: Payment method unavailable ({ex.Message})", InvoiceEventData.EventSeverity.Error);
}
catch (Exception ex)
{
2020-11-09 06:37:45 +01:00
logs.Write($"{supportedPaymentMethod.PaymentId.CryptoCode}: Unexpected exception ({ex})", InvoiceEventData.EventSeverity.Error);
}
return null;
}
private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy)
{
if (transactionSpeed == null)
return defaultPolicy;
var mappings = new Dictionary<string, SpeedPolicy>();
mappings.Add("low", SpeedPolicy.LowSpeed);
2018-05-11 15:12:45 +02:00
mappings.Add("low-medium", SpeedPolicy.LowMediumSpeed);
mappings.Add("medium", SpeedPolicy.MediumSpeed);
mappings.Add("high", SpeedPolicy.HighSpeed);
if (!mappings.TryGetValue(transactionSpeed, out SpeedPolicy policy))
policy = defaultPolicy;
return policy;
}
private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity)
{
var buyerInformation = invoiceEntity.Metadata;
buyerInformation.BuyerAddress1 = req.BuyerAddress1;
buyerInformation.BuyerAddress2 = req.BuyerAddress2;
buyerInformation.BuyerCity = req.BuyerCity;
buyerInformation.BuyerCountry = req.BuyerCountry;
buyerInformation.BuyerEmail = req.BuyerEmail;
buyerInformation.BuyerName = req.BuyerName;
buyerInformation.BuyerPhone = req.BuyerPhone;
buyerInformation.BuyerState = req.BuyerState;
buyerInformation.BuyerZip = req.BuyerZip;
var buyer = req.Buyer;
if (buyer == null)
return;
buyerInformation.BuyerAddress1 ??= buyer.Address1;
buyerInformation.BuyerAddress2 ??= buyer.Address2;
buyerInformation.BuyerCity ??= buyer.City;
buyerInformation.BuyerCountry ??= buyer.country;
buyerInformation.BuyerEmail ??= buyer.email;
buyerInformation.BuyerName ??= buyer.Name;
buyerInformation.BuyerPhone ??= buyer.phone;
buyerInformation.BuyerState ??= buyer.State;
buyerInformation.BuyerZip ??= buyer.zip;
}
}
2017-09-13 08:47:34 +02:00
}