mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-01-18 13:26:47 +01:00
Implement topup invoices (#2730)
This commit is contained in:
parent
63d4ccc058
commit
4c818d0359
@ -9,6 +9,8 @@ namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public class CreateInvoiceRequest : InvoiceDataBase
|
||||
{
|
||||
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||
public decimal? Amount { get; set; }
|
||||
public string[] AdditionalSearchTerms { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -7,10 +7,15 @@ using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public enum InvoiceType
|
||||
{
|
||||
Standard,
|
||||
TopUp
|
||||
}
|
||||
public class InvoiceDataBase
|
||||
{
|
||||
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||
public decimal Amount { get; set; }
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public InvoiceType Type { get; set; }
|
||||
public string Currency { get; set; }
|
||||
public JObject Metadata { get; set; }
|
||||
public CheckoutOptions Checkout { get; set; } = new CheckoutOptions();
|
||||
@ -41,6 +46,8 @@ namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string StoreId { get; set; }
|
||||
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||
public decimal Amount { get; set; }
|
||||
public string CheckoutLink { get; set; }
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public InvoiceStatus Status { get; set; }
|
||||
|
@ -28,7 +28,6 @@ namespace BTCPayServer.Services.Rates
|
||||
}
|
||||
|
||||
static readonly Dictionary<string, IFormatProvider> _CurrencyProviders = new Dictionary<string, IFormatProvider>();
|
||||
|
||||
public string FormatCurrency(string price, string currency)
|
||||
{
|
||||
return FormatCurrency(decimal.Parse(price, CultureInfo.InvariantCulture), currency);
|
||||
@ -110,9 +109,8 @@ namespace BTCPayServer.Services.Rates
|
||||
/// </summary>
|
||||
/// <param name="value">The value</param>
|
||||
/// <param name="currency">Currency code</param>
|
||||
/// <param name="threeLetterSuffix">Add three letter suffix (like USD)</param>
|
||||
/// <returns></returns>
|
||||
public string DisplayFormatCurrency(decimal value, string currency, bool threeLetterSuffix = true)
|
||||
public string DisplayFormatCurrency(decimal value, string currency)
|
||||
{
|
||||
var provider = GetNumberFormatInfo(currency, true);
|
||||
var currencyData = GetCurrencyData(currency, true);
|
||||
|
@ -226,6 +226,57 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Selenium", "Selenium")]
|
||||
public async Task CanUsePayjoinForTopUp()
|
||||
{
|
||||
using (var s = SeleniumTester.Create())
|
||||
{
|
||||
await s.StartAsync();
|
||||
s.RegisterNewUser(true);
|
||||
var receiver = s.CreateNewStore();
|
||||
var receiverSeed = s.GenerateWallet("BTC", "", true, true, ScriptPubKeyType.Segwit);
|
||||
var receiverWalletId = new WalletId(receiver.storeId, "BTC");
|
||||
|
||||
var sender = s.CreateNewStore();
|
||||
var senderSeed = s.GenerateWallet("BTC", "", true, true, ScriptPubKeyType.Segwit);
|
||||
var senderWalletId = new WalletId(sender.storeId, "BTC");
|
||||
|
||||
await s.Server.ExplorerNode.GenerateAsync(1);
|
||||
await s.FundStoreWallet(senderWalletId);
|
||||
await s.FundStoreWallet(receiverWalletId);
|
||||
|
||||
var invoiceId = s.CreateInvoice(receiver.storeName, null, "BTC");
|
||||
s.GoToInvoiceCheckout(invoiceId);
|
||||
var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
|
||||
.GetAttribute("href");
|
||||
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}=", bip21);
|
||||
s.GoToWallet(senderWalletId, WalletsNavPages.Send);
|
||||
s.Driver.FindElement(By.Id("bip21parse")).Click();
|
||||
s.Driver.SwitchTo().Alert().SendKeys(bip21);
|
||||
s.Driver.SwitchTo().Alert().Accept();
|
||||
s.Driver.FindElement(By.Id("Outputs_0__Amount")).Clear();
|
||||
s.Driver.FindElement(By.Id("Outputs_0__Amount")).SendKeys("0.023");
|
||||
|
||||
s.Driver.FindElement(By.Id("SignTransaction")).Click();
|
||||
|
||||
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
|
||||
{
|
||||
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).Click();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success);
|
||||
var invoiceRepository = s.Server.PayTester.GetService<InvoiceRepository>();
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var invoice = await invoiceRepository.GetInvoice(invoiceId);
|
||||
Assert.Equal(InvoiceStatusLegacy.Paid, invoice.Status);
|
||||
Assert.Equal(0.023m, invoice.Price);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Selenium", "Selenium")]
|
||||
public async Task CanUsePayjoinViaUI()
|
||||
|
@ -331,11 +331,12 @@ namespace BTCPayServer.Tests
|
||||
Driver.Navigate().GoToUrl(new Uri(Server.PayTester.ServerUri, "/login"));
|
||||
}
|
||||
|
||||
public string CreateInvoice(string storeName, decimal amount = 100, string currency = "USD", string refundEmail = "")
|
||||
public string CreateInvoice(string storeName, decimal? amount = 100, string currency = "USD", string refundEmail = "")
|
||||
{
|
||||
GoToInvoices();
|
||||
Driver.FindElement(By.Id("CreateNewInvoice")).Click();
|
||||
Driver.FindElement(By.Id("Amount")).SendKeys(amount.ToString(CultureInfo.InvariantCulture));
|
||||
if (amount is decimal v)
|
||||
Driver.FindElement(By.Id("Amount")).SendKeys(v.ToString(CultureInfo.InvariantCulture));
|
||||
var currencyEl = Driver.FindElement(By.Id("Currency"));
|
||||
currencyEl.Clear();
|
||||
currencyEl.SendKeys(currency);
|
||||
|
@ -935,7 +935,8 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
(0.0005m, "$0.0005 (USD)", "USD"), (0.001m, "$0.001 (USD)", "USD"), (0.01m, "$0.01 (USD)", "USD"),
|
||||
(0.1m, "$0.10 (USD)", "USD"), (0.1m, "0,10 € (EUR)", "EUR"), (1000m, "¥1,000 (JPY)", "JPY"),
|
||||
(1000.0001m, "₹ 1,000.00 (INR)", "INR")
|
||||
(1000.0001m, "₹ 1,000.00 (INR)", "INR"),
|
||||
(0.0m, "$0.00 (USD)", "USD")
|
||||
})
|
||||
{
|
||||
var actual = CurrencyNameTable.Instance.DisplayFormatCurrency(test.Item1, test.Item3);
|
||||
@ -2099,6 +2100,90 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Fact(Timeout = LongRunningTestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanCreateTopupInvoices()
|
||||
{
|
||||
using (var tester = ServerTester.Create())
|
||||
{
|
||||
await tester.StartAsync();
|
||||
var user = tester.NewAccount();
|
||||
user.GrantAccess();
|
||||
user.RegisterDerivationScheme("BTC");
|
||||
|
||||
var rng = new Random();
|
||||
var seed = rng.Next();
|
||||
rng = new Random(seed);
|
||||
Logs.Tester.LogInformation("Seed: " + seed);
|
||||
foreach (var networkFeeMode in Enum.GetValues(typeof(NetworkFeeMode)).Cast<NetworkFeeMode>())
|
||||
{
|
||||
await user.SetNetworkFeeMode(networkFeeMode);
|
||||
await AssertTopUpBtcPrice(tester, user, Money.Coins(1.0m), 5000.0m, networkFeeMode);
|
||||
await AssertTopUpBtcPrice(tester, user, Money.Coins(1.23456789m), 5000.0m * 1.23456789m, networkFeeMode);
|
||||
// Check if there is no strange roundup issues
|
||||
var v = (decimal)(rng.NextDouble() + 1.0);
|
||||
v = Money.Coins(v).ToDecimal(MoneyUnit.BTC);
|
||||
await AssertTopUpBtcPrice(tester, user, Money.Coins(v), 5000.0m * v, networkFeeMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AssertTopUpBtcPrice(ServerTester tester, TestAccount user, Money btcSent, decimal expectedPriceWithoutNetworkFee, NetworkFeeMode networkFeeMode)
|
||||
{
|
||||
var cashCow = tester.ExplorerNode;
|
||||
// First we try payment with a merchant having only BTC
|
||||
var client = await user.CreateClient();
|
||||
var invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest()
|
||||
{
|
||||
Amount = null,
|
||||
Currency = "USD"
|
||||
});
|
||||
Assert.Equal(0m, invoice.Amount);
|
||||
Assert.Equal(InvoiceType.TopUp, invoice.Type);
|
||||
var btcmethod = (await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id))[0];
|
||||
var paid = btcSent;
|
||||
var invoiceAddress = BitcoinAddress.Create(btcmethod.Destination, cashCow.Network);
|
||||
|
||||
|
||||
var btc = new PaymentMethodId("BTC", PaymentTypes.BTCLike);
|
||||
var networkFee = (await tester.PayTester.InvoiceRepository.GetInvoice(invoice.Id))
|
||||
.GetPaymentMethods()[btc]
|
||||
.GetPaymentMethodDetails()
|
||||
.AssertType<BitcoinLikeOnChainPaymentMethod>()
|
||||
.GetNextNetworkFee();
|
||||
if (networkFeeMode != NetworkFeeMode.Always)
|
||||
{
|
||||
networkFee = 0.0m;
|
||||
}
|
||||
|
||||
cashCow.SendToAddress(invoiceAddress, paid);
|
||||
|
||||
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var bitpayinvoice = await user.BitPay.GetInvoiceAsync(invoice.Id);
|
||||
Assert.NotEqual(0.0m, bitpayinvoice.Price);
|
||||
var due = Money.Parse(bitpayinvoice.CryptoInfo[0].CryptoPaid);
|
||||
Assert.Equal(paid, due);
|
||||
Assert.Equal(expectedPriceWithoutNetworkFee - networkFee * bitpayinvoice.Rate, bitpayinvoice.Price);
|
||||
Assert.Equal(Money.Zero, bitpayinvoice.BtcDue);
|
||||
Assert.Equal("paid", bitpayinvoice.Status);
|
||||
Assert.Equal("False", bitpayinvoice.ExceptionStatus.ToString());
|
||||
|
||||
// Check if we index by price correctly once we know it
|
||||
var invoices = await client.GetInvoices(user.StoreId, textSearch: $"{bitpayinvoice.Price.ToString(CultureInfo.InvariantCulture)}");
|
||||
Assert.Contains(invoices, inv => inv.Id == bitpayinvoice.Id);
|
||||
}
|
||||
catch (JsonSerializationException)
|
||||
{
|
||||
Assert.False(true, "The bitpay's amount is not set");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(Timeout = LongRunningTestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanModifyRates()
|
||||
|
@ -393,6 +393,7 @@ namespace BTCPayServer.Controllers.GreenField
|
||||
MonitoringExpiration = entity.MonitoringExpiration,
|
||||
CreatedTime = entity.InvoiceTime,
|
||||
Amount = entity.Price,
|
||||
Type = entity.Type,
|
||||
Id = entity.Id,
|
||||
CheckoutLink = _linkGenerator.CheckoutLink(entity.Id, Request.Scheme, Request.Host, Request.PathBase),
|
||||
Status = entity.Status.ToModernStatus(),
|
||||
|
@ -247,8 +247,7 @@ namespace BTCPayServer.Controllers
|
||||
cdCurrency.Divisibility);
|
||||
model.CryptoAmountThen = Math.Round(paidCurrency / paymentMethod.Rate, paymentMethodDivisibility);
|
||||
model.RateThenText =
|
||||
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountThen, paymentMethodId.CryptoCode,
|
||||
true);
|
||||
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountThen, paymentMethodId.CryptoCode);
|
||||
rules = store.GetStoreBlob().GetRateRules(_NetworkProvider);
|
||||
rateResult = await _RateProvider.FetchRate(
|
||||
new Rating.CurrencyPair(paymentMethodId.CryptoCode, invoice.Currency), rules,
|
||||
@ -263,10 +262,9 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
model.CryptoAmountNow = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
|
||||
model.CurrentRateText =
|
||||
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode,
|
||||
true);
|
||||
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode);
|
||||
model.FiatAmount = paidCurrency;
|
||||
model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.Currency, true);
|
||||
model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.Currency);
|
||||
return View(model);
|
||||
case RefundSteps.SelectRate:
|
||||
createPullPayment = new HostedServices.CreatePullPayment();
|
||||
@ -545,7 +543,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
}
|
||||
lang ??= storeBlob.DefaultLang;
|
||||
|
||||
|
||||
var model = new PaymentModel()
|
||||
{
|
||||
Activated = paymentMethodDetails.Activated,
|
||||
@ -562,6 +560,7 @@ namespace BTCPayServer.Controllers
|
||||
BtcDue = accounting.Due.ShowMoney(divisibility),
|
||||
InvoiceCurrency = invoice.Currency,
|
||||
OrderAmount = (accounting.TotalDue - accounting.NetworkFee).ShowMoney(divisibility),
|
||||
IsUnsetTopUp = invoice.IsUnsetTopUp(),
|
||||
OrderAmountFiat = OrderAmountFromInvoice(network.CryptoCode, invoice),
|
||||
CustomerEmail = invoice.RefundMail,
|
||||
RequiresRefundEmail = storeBlob.RequiresRefundEmail,
|
||||
@ -846,17 +845,12 @@ namespace BTCPayServer.Controllers
|
||||
ModelState.AddModelError(nameof(model.StoreId), "You need to configure the derivation scheme in order to create an invoice");
|
||||
return View(model);
|
||||
}
|
||||
if (model.Amount is null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Amount), "Thhe invoice amount can't be empty");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest()
|
||||
{
|
||||
Price = model.Amount.Value,
|
||||
Price = model.Amount,
|
||||
Currency = model.Currency,
|
||||
PosData = model.PosData,
|
||||
OrderId = model.OrderId,
|
||||
|
@ -119,7 +119,16 @@ namespace BTCPayServer.Controllers
|
||||
entity.Metadata.Physical = invoice.Physical;
|
||||
entity.Metadata.TaxIncluded = invoice.TaxIncluded;
|
||||
entity.Currency = invoice.Currency;
|
||||
entity.Price = price;
|
||||
if (price is decimal vv)
|
||||
{
|
||||
entity.Price = vv;
|
||||
entity.Type = InvoiceType.Standard;
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Price = 0m;
|
||||
entity.Type = InvoiceType.TopUp;
|
||||
}
|
||||
|
||||
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
|
||||
entity.RedirectAutomatically =
|
||||
@ -161,7 +170,16 @@ namespace BTCPayServer.Controllers
|
||||
invoice.Checkout ??= new CreateInvoiceRequest.CheckoutOptions();
|
||||
invoice.Currency = invoice.Currency?.Trim().ToUpperInvariant() ?? "USD";
|
||||
entity.Currency = invoice.Currency;
|
||||
entity.Price = invoice.Amount;
|
||||
if (invoice.Amount is decimal v)
|
||||
{
|
||||
entity.Price = v;
|
||||
entity.Type = InvoiceType.Standard;
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Price = 0.0m;
|
||||
entity.Type = InvoiceType.TopUp;
|
||||
}
|
||||
entity.SpeedPolicy = invoice.Checkout.SpeedPolicy ?? store.SpeedPolicy;
|
||||
entity.DefaultLanguage = invoice.Checkout.DefaultLanguage;
|
||||
entity.RedirectAutomatically = invoice.Checkout.RedirectAutomatically ?? storeBlob.RedirectAutomatically;
|
||||
|
@ -48,7 +48,7 @@ namespace BTCPayServer.Controllers
|
||||
ModelState.AddModelError("Store", "Store has not enabled Pay Button");
|
||||
}
|
||||
|
||||
if (model == null || model.Price <= 0)
|
||||
if (model == null || (model.Price is decimal v ? v <= 0 : false))
|
||||
ModelState.AddModelError("Price", "Price must be greater than 0");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
|
@ -999,7 +999,7 @@ namespace BTCPayServer.Controllers
|
||||
var appUrl = HttpContext.Request.GetAbsoluteRoot().WithTrailingSlash();
|
||||
var model = new PayButtonViewModel
|
||||
{
|
||||
Price = 10,
|
||||
Price = null,
|
||||
Currency = DEFAULT_CURRENCY,
|
||||
ButtonSize = 2,
|
||||
UrlRoot = appUrl,
|
||||
|
@ -43,6 +43,13 @@ namespace BTCPayServer.HostedServices
|
||||
|
||||
public bool Dirty => _Dirty;
|
||||
public bool Unaffect => _Unaffect;
|
||||
|
||||
bool _IsBlobUpdated;
|
||||
public bool IsBlobUpdated => _IsBlobUpdated;
|
||||
public void BlobUpdated()
|
||||
{
|
||||
_IsBlobUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
readonly InvoiceRepository _InvoiceRepository;
|
||||
@ -83,13 +90,26 @@ namespace BTCPayServer.HostedServices
|
||||
return;
|
||||
if (invoice.Status == InvoiceStatusLegacy.New || invoice.Status == InvoiceStatusLegacy.Expired)
|
||||
{
|
||||
if (accounting.Paid >= accounting.MinimumTotalDue)
|
||||
var isPaid = invoice.IsUnsetTopUp() ?
|
||||
accounting.Paid > Money.Zero :
|
||||
accounting.Paid >= accounting.MinimumTotalDue;
|
||||
if (isPaid)
|
||||
{
|
||||
if (invoice.Status == InvoiceStatusLegacy.New)
|
||||
{
|
||||
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.PaidInFull));
|
||||
invoice.Status = InvoiceStatusLegacy.Paid;
|
||||
invoice.ExceptionStatus = accounting.Paid > accounting.TotalDue ? InvoiceExceptionStatus.PaidOver : InvoiceExceptionStatus.None;
|
||||
if (invoice.IsUnsetTopUp())
|
||||
{
|
||||
invoice.ExceptionStatus = InvoiceExceptionStatus.None;
|
||||
invoice.Price = (accounting.Paid - accounting.NetworkFeeAlreadyPaid).ToDecimal(MoneyUnit.BTC) * paymentMethod.Rate;
|
||||
accounting = paymentMethod.Calculate();
|
||||
context.BlobUpdated();
|
||||
}
|
||||
else
|
||||
{
|
||||
invoice.ExceptionStatus = accounting.Paid > accounting.TotalDue ? InvoiceExceptionStatus.PaidOver : InvoiceExceptionStatus.None;
|
||||
}
|
||||
context.UnaffectAddresses();
|
||||
context.MarkDirty();
|
||||
}
|
||||
@ -293,6 +313,10 @@ namespace BTCPayServer.HostedServices
|
||||
await _InvoiceRepository.UpdateInvoiceStatus(invoice.Id, invoice.GetInvoiceState());
|
||||
updateContext.Events.Insert(0, new InvoiceDataChangedEvent(invoice));
|
||||
}
|
||||
if (updateContext.IsBlobUpdated)
|
||||
{
|
||||
await _InvoiceRepository.UpdateInvoicePrice(invoice.Id, invoice);
|
||||
}
|
||||
|
||||
foreach (var evt in updateContext.Events)
|
||||
{
|
||||
|
@ -51,7 +51,7 @@ namespace BTCPayServer.ModelBinders
|
||||
// Parse() method trims the value (with common NumberStyles) then throws if the result is empty.
|
||||
model = null;
|
||||
}
|
||||
else if (type == typeof(decimal))
|
||||
else if (type == typeof(decimal) || type == typeof(decimal?))
|
||||
{
|
||||
model = decimal.Parse(value, _supportedStyles, culture);
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ namespace BTCPayServer.Models
|
||||
[JsonProperty(PropertyName = "currency", DefaultValueHandling = DefaultValueHandling.Ignore)]
|
||||
public string Currency { get; set; }
|
||||
[JsonProperty(PropertyName = "price", DefaultValueHandling = DefaultValueHandling.Ignore)]
|
||||
public decimal Price { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
[JsonProperty(PropertyName = "notificationEmail", DefaultValueHandling = DefaultValueHandling.Ignore)]
|
||||
public string NotificationEmail { get; set; }
|
||||
[JsonConverter(typeof(DateTimeJsonConverter))]
|
||||
|
@ -14,7 +14,6 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
Currency = "USD";
|
||||
}
|
||||
|
||||
[Required]
|
||||
public decimal? Amount
|
||||
{
|
||||
get; set;
|
||||
|
@ -27,6 +27,7 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
public string DefaultLang { get; set; }
|
||||
public List<AvailableCrypto> AvailableCryptos { get; set; } = new List<AvailableCrypto>();
|
||||
public bool IsModal { get; set; }
|
||||
public bool IsUnsetTopUp { get; set; }
|
||||
public string CryptoCode { get; set; }
|
||||
public string InvoiceId { get; set; }
|
||||
public string BtcAddress { get; set; }
|
||||
|
@ -9,7 +9,7 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
public class PayButtonViewModel
|
||||
{
|
||||
[ModelBinder(BinderType = typeof(InvariantDecimalModelBinder))]
|
||||
public decimal Price { get; set; }
|
||||
public decimal? Price { get; set; }
|
||||
public string InvoiceId { get; set; }
|
||||
[Required]
|
||||
public string Currency { get; set; }
|
||||
|
@ -413,7 +413,7 @@ namespace BTCPayServer.Payments.PayJoin
|
||||
if (additionalFee > Money.Zero)
|
||||
{
|
||||
// If the user overpaid, taking fee on our output (useful if sender dump a full UTXO for privacy)
|
||||
for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero && due < Money.Zero; i++)
|
||||
for (int i = 0; i < newTx.Outputs.Count && additionalFee > Money.Zero && due < Money.Zero && !invoice.IsUnsetTopUp(); i++)
|
||||
{
|
||||
if (disableoutputsubstitution)
|
||||
break;
|
||||
|
@ -13,6 +13,7 @@ using NBitcoin.DataEncoders;
|
||||
using NBitpayClient;
|
||||
using NBXplorer;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
@ -396,6 +397,10 @@ namespace BTCPayServer.Services.Invoices
|
||||
public double PaymentTolerance { get; set; }
|
||||
public bool Archived { get; set; }
|
||||
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
|
||||
public InvoiceType Type { get; set; }
|
||||
|
||||
public bool IsExpired()
|
||||
{
|
||||
return DateTimeOffset.UtcNow > ExpirationTime;
|
||||
@ -681,6 +686,11 @@ namespace BTCPayServer.Services.Invoices
|
||||
throw new InvalidOperationException("Not a legacy invoice");
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsUnsetTopUp()
|
||||
{
|
||||
return Type == InvoiceType.TopUp && Price == 0.0m;
|
||||
}
|
||||
}
|
||||
|
||||
public enum InvoiceStatusLegacy
|
||||
@ -865,6 +875,10 @@ namespace BTCPayServer.Services.Invoices
|
||||
/// </summary>
|
||||
public Money NetworkFee { get; set; }
|
||||
/// <summary>
|
||||
/// Total amount of network fee to pay to the invoice
|
||||
/// </summary>
|
||||
public Money NetworkFeeAlreadyPaid { get; set; }
|
||||
/// <summary>
|
||||
/// Minimum required to be paid in order to accept invoice as paid
|
||||
/// </summary>
|
||||
public Money MinimumTotalDue { get; set; }
|
||||
@ -991,13 +1005,14 @@ namespace BTCPayServer.Services.Invoices
|
||||
var totalDueNoNetworkCost = Money.Coins(Extensions.RoundUp(totalDue, precision));
|
||||
bool paidEnough = paid >= Extensions.RoundUp(totalDue, precision);
|
||||
int txRequired = 0;
|
||||
|
||||
decimal networkFeeAlreadyPaid = 0.0m;
|
||||
_ = ParentEntity.GetPayments(true)
|
||||
.Where(p => paymentPredicate(p))
|
||||
.OrderBy(p => p.ReceivedTime)
|
||||
.Select(_ =>
|
||||
{
|
||||
var txFee = _.GetValue(paymentMethods, GetId(), _.NetworkFee, precision);
|
||||
networkFeeAlreadyPaid += txFee;
|
||||
paid += _.GetValue(paymentMethods, GetId(), null, precision);
|
||||
if (!paidEnough)
|
||||
{
|
||||
@ -1029,6 +1044,7 @@ namespace BTCPayServer.Services.Invoices
|
||||
accounting.Due = Money.Max(accounting.TotalDue - accounting.Paid, Money.Zero);
|
||||
accounting.DueUncapped = accounting.TotalDue - accounting.Paid;
|
||||
accounting.NetworkFee = accounting.TotalDue - totalDueNoNetworkCost;
|
||||
accounting.NetworkFeeAlreadyPaid = Money.Coins(Extensions.RoundUp(networkFeeAlreadyPaid, precision));
|
||||
// If the total due is 0, there is no payment tolerance to calculate
|
||||
var minimumTotalDueSatoshi = accounting.TotalDue.Satoshi == 0
|
||||
? 0
|
||||
|
@ -206,7 +206,8 @@ namespace BTCPayServer.Services.Invoices
|
||||
|
||||
textSearch.Add(invoice.Id);
|
||||
textSearch.Add(invoice.InvoiceTime.ToString(CultureInfo.InvariantCulture));
|
||||
textSearch.Add(invoice.Price.ToString(CultureInfo.InvariantCulture));
|
||||
if (!invoice.IsUnsetTopUp())
|
||||
textSearch.Add(invoice.Price.ToString(CultureInfo.InvariantCulture));
|
||||
textSearch.Add(invoice.Metadata.OrderId);
|
||||
textSearch.Add(invoice.StoreId);
|
||||
textSearch.Add(invoice.Metadata.BuyerEmail);
|
||||
@ -425,6 +426,22 @@ namespace BTCPayServer.Services.Invoices
|
||||
await context.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
internal async Task UpdateInvoicePrice(string invoiceId, InvoiceEntity invoice)
|
||||
{
|
||||
if (invoice.Type != InvoiceType.TopUp)
|
||||
throw new ArgumentException("The invoice type should be TopUp to be able to update invoice price", nameof(invoice));
|
||||
using (var context = _ContextFactory.CreateContext())
|
||||
{
|
||||
var invoiceData = await context.FindAsync<Data.InvoiceData>(invoiceId).ConfigureAwait(false);
|
||||
if (invoiceData == null)
|
||||
return;
|
||||
var blob = invoiceData.GetBlob(_Networks);
|
||||
blob.Price = invoice.Price;
|
||||
AddToTextSearch(context, invoiceData, new[] { invoice.Price.ToString(CultureInfo.InvariantCulture) });
|
||||
invoiceData.Blob = ToBytes(blob, null);
|
||||
await context.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MassArchive(string[] invoiceIds)
|
||||
{
|
||||
|
@ -95,10 +95,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="single-item-order__right">
|
||||
<div class="single-item-order__right__btc-price" v-if="srvModel.status === 'paid'">
|
||||
<div class="single-item-order__right__btc-price" v-if="srvModel.status === 'paid' && !srvModel.isUnsetTopUp">
|
||||
<span>{{ srvModel.btcPaid }} {{ srvModel.cryptoCode }}</span>
|
||||
</div>
|
||||
<div class="single-item-order__right__btc-price" v-else>
|
||||
<div class="single-item-order__right__btc-price" v-if="srvModel.status !== 'paid' && !srvModel.isUnsetTopUp">
|
||||
<span>{{ srvModel.btcDue }} {{ srvModel.cryptoCode }}</span>
|
||||
</div>
|
||||
<div class="single-item-order__right__ex-rate" v-if="srvModel.orderAmountFiat && srvModel.cryptoCode">
|
||||
@ -106,10 +106,10 @@
|
||||
<span v-else>1 {{ srvModel.cryptoCodeSrv }} = {{ srvModel.rate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fa fa-angle-double-down"></span>
|
||||
<span class="fa fa-angle-double-up"></span>
|
||||
<span class="fa fa-angle-double-down" v-if="!srvModel.isUnsetTopUp"></span>
|
||||
<span class="fa fa-angle-double-up" v-if="!srvModel.isUnsetTopUp"></span>
|
||||
</div>
|
||||
<line-items>
|
||||
<line-items v-if="!srvModel.isUnsetTopUp">
|
||||
<div class="extraPayment" v-if="srvModel.status === 'new' && srvModel.txCount > 1">
|
||||
{{$t("NotPaid_ExtraTransaction")}}
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
@model BTCPayServer.Models.InvoicingModels.CreateInvoiceModel
|
||||
@model BTCPayServer.Models.InvoicingModels.CreateInvoiceModel
|
||||
@{
|
||||
ViewData.SetActivePageAndTitle(InvoiceNavPages.Create, "Create an invoice");
|
||||
}
|
||||
@ -35,8 +35,8 @@
|
||||
<form asp-action="CreateInvoice" method="post" id="create-invoice-form">
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Amount" class="form-label" data-required></label>
|
||||
<input asp-for="Amount" class="form-control" required />
|
||||
<label asp-for="Amount" class="form-label"></label>
|
||||
<input asp-for="Amount" class="form-control" />
|
||||
<span asp-validation-for="Amount" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
@ -19,14 +19,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-view payment manual-flow" id="copy" v-bind:class="{ 'active': currentTab == 'copy'}">
|
||||
<div class="manual__step-two__instructions">
|
||||
<div class="manual__step-two__instructions" v-if="!srvModel.isUnsetTopUp">
|
||||
<span v-html="$t('CompletePay_Body', srvModel)"></span>
|
||||
</div>
|
||||
<div class="copyLabelPopup">
|
||||
<span>{{$t("Copied")}}</span>
|
||||
</div>
|
||||
<nav class="copyBox">
|
||||
<div class="copySectionBox bottomBorder">
|
||||
<div class="copySectionBox bottomBorder" v-if="!srvModel.isUnsetTopUp">
|
||||
<label>{{$t("Amount")}}</label>
|
||||
<div class="copyAmountText copy-cursor _copySpan">
|
||||
<span>{{srvModel.btcDue}}</span> {{ srvModel.cryptoCode }}
|
||||
|
@ -58,9 +58,9 @@
|
||||
<div class="row">
|
||||
<div class="form-group col-md-8">
|
||||
<label class="form-label">Price</label>
|
||||
<input name="price" type="text" class="form-control"
|
||||
<input name="price" type="text" class="form-control" placeholder="(optional)"
|
||||
v-model="srvModel.price" v-on:change="inputChanges"
|
||||
v-validate="'required|decimal|min_value:0'" :class="{'is-invalid': errors.has('price') }">
|
||||
v-validate="'decimal|min_value:0'" :class="{'is-invalid': errors.has('price') }">
|
||||
<small class="text-danger">{{ errors.first('price') }}</small>
|
||||
</div>
|
||||
<div class="form-group col-md-4" v-if="!srvModel.appIdEndpoint">
|
||||
|
@ -148,7 +148,8 @@ function inputChanges(event, buttonSize) {
|
||||
|
||||
// Fixed amount: Add price and currency as hidden inputs
|
||||
if (isFixedAmount) {
|
||||
html += addInput(priceInputName, srvModel.price);
|
||||
if (srvModel.price !== '')
|
||||
html += addInput(priceInputName, srvModel.price);
|
||||
if(allowCurrencySelection){
|
||||
html += addInput("currency", srvModel.currency);
|
||||
}
|
||||
|
@ -747,11 +747,6 @@
|
||||
},
|
||||
"InvoiceDataBase": {
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "string",
|
||||
"format": "decimal",
|
||||
"description": "The amount of the invoice"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
@ -788,6 +783,15 @@
|
||||
"type": "string",
|
||||
"description": "The store identifier that the invoice belongs to"
|
||||
},
|
||||
"amount": {
|
||||
"type": "string",
|
||||
"format": "decimal",
|
||||
"description": "The amount of the invoice"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/components/schemas/InvoiceType",
|
||||
"description": "The type of invoice"
|
||||
},
|
||||
"checkoutLink": {
|
||||
"type": "string",
|
||||
"description": "The link to the checkout page, where you can redirect the customer"
|
||||
@ -972,6 +976,12 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "string",
|
||||
"format": "decimal",
|
||||
"nullable": true,
|
||||
"description": "The amount of the invoice. If null or unspecified, the invoice will be a top-up invoice. (ie. The invoice will consider any payment as a full payment)"
|
||||
},
|
||||
"additionalSearchTerms": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -1172,6 +1182,18 @@
|
||||
"Processing",
|
||||
"Settled"
|
||||
]
|
||||
},
|
||||
"InvoiceType": {
|
||||
"type": "string",
|
||||
"description": "",
|
||||
"x-enumNames": [
|
||||
"Standard",
|
||||
"TopUp"
|
||||
],
|
||||
"enum": [
|
||||
"Standard",
|
||||
"TopUp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user