Implement topup invoices (#2730)

This commit is contained in:
Nicolas Dorier 2021-08-03 17:03:00 +09:00 committed by GitHub
parent 63d4ccc058
commit 4c818d0359
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 288 additions and 51 deletions

View File

@ -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; }
}
}

View File

@ -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; }

View File

@ -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);

View File

@ -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()

View File

@ -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);

View File

@ -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()

View File

@ -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(),

View File

@ -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,

View File

@ -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;

View File

@ -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)

View File

@ -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,

View File

@ -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)
{

View File

@ -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);
}

View File

@ -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))]

View File

@ -14,7 +14,6 @@ namespace BTCPayServer.Models.InvoicingModels
Currency = "USD";
}
[Required]
public decimal? Amount
{
get; set;

View File

@ -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; }

View File

@ -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; }

View File

@ -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;

View File

@ -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

View File

@ -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)
{

View File

@ -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>

View File

@ -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">

View File

@ -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 }}

View File

@ -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">

View File

@ -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);
}

View File

@ -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"
]
}
}
},