Support Topup Invoices in Apps

This commit is contained in:
Kukks 2021-10-08 13:11:00 +02:00 committed by Andrew Camilleri
parent 9df4429fc2
commit 7d2aa28e1f
12 changed files with 129 additions and 89 deletions

View file

@ -114,7 +114,7 @@ namespace BTCPayServer.Controllers
[DomainMappingConstraint(AppType.PointOfSale)]
public async Task<IActionResult> ViewPointOfSale(string appId,
PosViewType viewType,
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal amount,
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal? amount,
string email,
string orderId,
string notificationUrl,
@ -136,7 +136,7 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(ViewPointOfSale), new { appId = appId, viewType = viewType });
}
string title = null;
var price = 0.0m;
decimal? price = null;
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
ViewPointOfSaleViewModel.Item choice = null;
if (!string.IsNullOrEmpty(choiceKey))
@ -146,9 +146,17 @@ namespace BTCPayServer.Controllers
if (choice == null)
return NotFound();
title = choice.Title;
price = choice.Price.Value;
if (amount > price)
price = amount;
if (choice.Custom == "topup")
{
price = null;
}
else
{
price = choice.Price.Value;
if (amount > price)
price = amount;
}
if (choice.Inventory.HasValue)
{
@ -277,10 +285,7 @@ namespace BTCPayServer.Controllers
[DomainMappingConstraintAttribute(AppType.Crowdfund)]
public async Task<IActionResult> ContributeToCrowdfund(string appId, ContributeToCrowdfund request, CancellationToken cancellationToken)
{
if (request.Amount <= 0)
{
return NotFound("Please provide an amount greater than 0");
}
var app = await _AppService.GetApp(appId, AppType.Crowdfund, true);
if (app == null)
@ -307,7 +312,7 @@ namespace BTCPayServer.Controllers
var store = await _AppService.GetStore(app);
var title = settings.Title;
var price = request.Amount;
decimal? price = request.Amount;
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
ViewPointOfSaleViewModel.Item choice = null;
if (!string.IsNullOrEmpty(request.ChoiceKey))
@ -317,11 +322,17 @@ namespace BTCPayServer.Controllers
if (choice == null)
return NotFound("Incorrect option provided");
title = choice.Title;
price = choice.Price.Value;
if (request.Amount > price)
price = request.Amount;
if (choice.Custom == "topup")
{
price = null;
}
else
{
price = choice.Price.Value;
if (request.Amount > price)
price = request.Amount;
}
if (choice.Inventory.HasValue)
{
if (choice.Inventory <= 0)
@ -329,14 +340,21 @@ namespace BTCPayServer.Controllers
return NotFound("Option was out of stock");
}
}
if (choice?.PaymentMethods?.Any() is true)
{
paymentMethods = choice?.PaymentMethods.ToDictionary(s => s,
s => new InvoiceSupportedTransactionCurrency() { Enabled = true });
}
}
else
{
if (request.Amount < 0)
{
return NotFound("Please provide an amount greater than 0");
}
price = null;
}
if (!isAdmin && (settings.EnforceTargetAmount && info.TargetAmount.HasValue && price >
(info.TargetAmount - (info.Info.CurrentAmount + info.Info.CurrentPendingAmount))))

View file

@ -83,7 +83,7 @@ namespace BTCPayServer.Models.AppViewModels
public class ContributeToCrowdfund
{
public ViewCrowdfundViewModel ViewCrowdfundViewModel { get; set; }
[Required] public decimal Amount { get; set; }
[Required] public decimal? Amount { get; set; }
public string Email { get; set; }
public string ChoiceKey { get; set; }
public bool RedirectToCheckout { get; set; }

View file

@ -17,7 +17,7 @@ namespace BTCPayServer.Models.AppViewModels
public string Image { get; set; }
public ItemPrice Price { get; set; }
public string Title { get; set; }
public bool Custom { get; set; }
public string Custom { get; set; }
public string BuyButtonText { get; set; }
public int? Inventory { get; set; } = null;
public string[] PaymentMethods { get; set; }

View file

@ -289,7 +289,8 @@ namespace BTCPayServer.Services.Apps
{
var itemNode = new YamlMappingNode();
itemNode.Add("title", new YamlScalarNode(item.Title));
itemNode.Add("price", new YamlScalarNode(item.Price.Value.ToStringInvariant()));
if(item.Custom!= "topup")
itemNode.Add("price", new YamlScalarNode(item.Price.Value.ToStringInvariant()));
if (!string.IsNullOrEmpty(item.Description))
{
itemNode.Add("description", new YamlScalarNode(item.Description)
@ -341,13 +342,16 @@ namespace BTCPayServer.Services.Apps
Id = c.Key,
Image = c.GetDetailString("image"),
Title = c.GetDetailString("title") ?? c.Key,
Price = c.GetDetail("price")
.Select(cc => new ViewPointOfSaleViewModel.Item.ItemPrice()
{
Value = decimal.Parse(cc.Value.Value, CultureInfo.InvariantCulture),
Formatted = Currencies.FormatCurrency(cc.Value.Value, currency)
}).Single(),
Custom = c.GetDetailString("custom") == "true",
Custom = c.GetDetailString("custom"),
Price =
c.GetDetailString("custom") == "topup"
? null
: c.GetDetail("price")
.Select(cc => new ViewPointOfSaleViewModel.Item.ItemPrice()
{
Value = decimal.Parse(cc.Value.Value, CultureInfo.InvariantCulture),
Formatted = Currencies.FormatCurrency(cc.Value.Value, currency)
}).Single(),
BuyButtonText = c.GetDetailString("buyButtonText"),
Inventory = string.IsNullOrEmpty(c.GetDetailString("inventory")) ? (int?)null : int.Parse(c.GetDetailString("inventory"), CultureInfo.InvariantCulture),
PaymentMethods = c.GetDetailStringList("payment_methods"),

View file

@ -7,7 +7,7 @@
{
foreach (var error in errors.Errors)
{
<br />
<br/>
<span class="text-danger">@error.ErrorMessage</span>
}
}
@ -25,7 +25,7 @@
</div>
</div>
<div v-if="!items || items.length === 0" class="col-12 text-center">
No items.<br />
No items.<br/>
<button type="button" class="btn btn-link" v-on:click="editItem(-1)" id="btn-add-first">
Add your first item
</button>
@ -46,7 +46,7 @@
<div class="modal-header">
<h5 class="modal-title" v-if="editingItem">{{editingItem.index>=0? "Edit" : "Create"}} item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" ref="close">
<vc:icon symbol="close" />
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body" v-if="editingItem">
@ -55,9 +55,15 @@
<div class="form-group row">
<div class="col-sm-6">
<label class="form-label" data-required>Title</label>
<input type="text" required pattern="[^\*#]+" class="form-control mb-2" v-model="editingItem.title" autofocus ref="txtTitle" />
<input type="text" required pattern="[^\*#]+" class="form-control mb-2" v-model="editingItem.title" autofocus ref="txtTitle"/>
</div>
<div class="col-sm-3">
<label class="form-label">Price</label>
<select class="form-select" v-model="editingItem.custom">
<option v-for="option in customPriceOptions" :value="option.value">{{option.text}}</option>
</select>
</div>
<div class="col-sm-3" v-show="editingItem.custom !== 'topup'">
<label class="form-label" data-required>Price</label>
<input class="form-control mb-2"
inputmode="numeric"
@ -66,18 +72,13 @@
min="0"
type="number"
required
v-model="editingItem.price" ref="txtPrice" />
</div>
<div class="col-sm-3">
<label class="form-label">Custom price</label>
<select class="form-select" v-model="editingItem.custom">
<option v-for="option in customPriceOptions" :value="option.value">{{option.text}}</option>
</select>
v-model="editingItem.price" ref="txtPrice"/>
</div>
</div>
<div class="form-group">
<label class="form-label">Image</label>
<input type="text" class="form-control mb-2" pattern="[^\*#]+" v-model="editingItem.image" ref="txtImage" />
<input type="text" class="form-control mb-2" pattern="[^\*#]+" v-model="editingItem.image" ref="txtImage"/>
</div>
<div class="form-group">
<label class="form-label">Description</label>
@ -85,18 +86,18 @@
</div>
<div class="form-group">
<label class="form-label">Inventory (leave blank to not use inventory feature)</label>
<input type="number" min="0" step="1" class="form-control mb-2" v-model="editingItem.inventory" ref="txtInventory" />
<input type="number" min="0" step="1" class="form-control mb-2" v-model="editingItem.inventory" ref="txtInventory"/>
</div>
<div class="form-group">
<label class="form-label">Id (leave blank to generate from title)</label>
<input type="text" required pattern="[^\*#]+" class="form-control mb-2" v-model="editingItem.id" ref="txtId" />
<input type="text" required pattern="[^\*#]+" class="form-control mb-2" v-model="editingItem.id" ref="txtId"/>
</div>
<div class="form-group">
<label class="form-label">Buy Button Text</label>
<input type="text" id="BuyButtonText" class="form-control mb-2" v-model="editingItem.buyButtonText" ref="txtBuyButtonText" />
<input type="text" id="BuyButtonText" class="form-control mb-2" v-model="editingItem.buyButtonText" ref="txtBuyButtonText"/>
</div>
<div class="form-group d-flex align-items-center">
<input type="checkbox" id="Disabled" class="btcpay-toggle me-2" v-model="editingItem.disabled" />
<input type="checkbox" id="Disabled" class="btcpay-toggle me-2" v-model="editingItem.disabled"/>
<label class="form-label mb-0">Disabled</label>
</div>
</div>
@ -120,8 +121,9 @@ document.addEventListener("DOMContentLoaded", function () {
items: [],
editingItem: null,
customPriceOptions: [
{ text: 'No', value: false },
{ text: 'Yes', value: true },
{ text: 'Fixed', value: false },
{ text: 'Minimum', value: true },
{ text: 'Topup', value: 'topup' },
],
elementId: "@Model.templateId"
},
@ -222,7 +224,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
}
if (price != null || title != null) {
if (title != null) {
// Add product to the list
result.push({
id: id,
@ -230,7 +232,7 @@ document.addEventListener("DOMContentLoaded", function () {
price: price,
image: image || null,
description: description || '',
custom: custom === "true",
custom: custom === "topup"? "topup": custom === "true",
buyButtonText: buyButtonText,
inventory: isNaN(inventory)? null: inventory,
paymentMethods: paymentMethods,
@ -241,13 +243,13 @@ document.addEventListener("DOMContentLoaded", function () {
this.items = result;
},
toYml: function(){
var template = '';
let template = '';
// Construct template from the product list
for (var key in this.items) {
var product = this.items[key],
for (const key in this.items) {
const product = this.items[key],
id = product.id,
title = product.title,
price = product.price? product.price : 0,
price = product.custom === 'topup'? null : product.price??0,
image = product.image,
description = product.description,
custom = product.custom,
@ -255,36 +257,36 @@ document.addEventListener("DOMContentLoaded", function () {
inventory = product.inventory,
paymentMethods = product.paymentMethods,
disabled = product.disabled;
template += id + ':\n' +
' price: ' + parseFloat(price).noExponents() + '\n' +
' title: ' + title + '\n';
let itemTemplate = id+":\n";
itemTemplate += ( product.custom === 'topup'? '' : (' price: ' + parseFloat(price).noExponents() + '\n'));
itemTemplate+= ' title: ' + title + '\n';
if (description) {
template += ' description: "' + description.replaceAll("\n", "<br/>").replaceAll('"', '\\"') + '"\n';
itemTemplate += ' description: "' + description.replaceAll("\n", "<br/>").replaceAll('"', '\\"') + '"\n';
}
if (image) {
template += ' image: ' + image + '\n';
itemTemplate += ' image: ' + image + '\n';
}
if (inventory) {
template += ' inventory: ' + inventory + '\n';
itemTemplate += ' inventory: ' + inventory + '\n';
}
if (custom != null) {
template += ' custom: ' + custom + '\n';
itemTemplate += ' custom: ' + (custom === "topup"? '"topup"': custom) + '\n';
}
if (buyButtonText != null && buyButtonText.length > 0) {
template += ' buyButtonText: ' + buyButtonText + '\n';
itemTemplate += ' buyButtonText: ' + buyButtonText + '\n';
}
if (disabled != null) {
template += ' disabled: ' + disabled.toString() + '\n';
itemTemplate += ' disabled: ' + disabled.toString() + '\n';
}
if(paymentMethods != null && paymentMethods.length > 0){
template+= ' payment_methods:\n';
itemTemplate+= ' payment_methods:\n';
for (var method of paymentMethods){
template+= ' - '+method+'\n';
itemTemplate+= ' - '+method+'\n';
}
}
template += '\n';
itemTemplate += '\n';
template+=itemTemplate;
}
this.getInputElement().val(template);
},
@ -337,7 +339,7 @@ document.addEventListener("DOMContentLoaded", function () {
this.errors.push("Image cannot start with \"- \"");
}
if (!this.$refs.txtPrice.checkValidity()) {
if (this.editingItem.custom !== "topup" && !this.$refs.txtPrice.checkValidity()) {
this.errors.push("Price must be a valid number");
}
if (!this.$refs.txtTitle.checkValidity()) {

View file

@ -19,26 +19,29 @@
<div class="card-body">
<div class="card-title d-flex align-items-center justify-content-between mb-1">
<label class="h5 d-flex align-items-center">
@if (vm.Started && !vm.Ended && (item.Price.Value > 0 || item.Custom))
@if (vm.Started && !vm.Ended )
{
<input type="radio" asp-for="ChoiceKey" value="@item.Id" class="form-check-input mt-0 me-2"/>
}
@(string.IsNullOrEmpty(item.Title) ? item.Id : item.Title)
</label>
<span class="text-muted">
@if (item.Price.Value > 0)
@if (item.Price?.Value > 0)
{
<span>@item.Price.Value</span>
<span>@vm.TargetCurrency</span>
if (item.Custom)
if (item.Custom == "true")
{
@Safe.Raw("or more")
}
}
else if (item.Custom)
else if (item.Custom == "topup" || item.Custom == "true" )
{
@Safe.Raw("Any amount")
}else if (item.Custom == "false")
{
@Safe.Raw("Free")
}
</span>
</div>

View file

@ -230,7 +230,17 @@
</div>
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
<span class="text-muted small">@((item.BuyButtonText ?? Model.ButtonText).Replace("{0}",item.Price.Formatted).Replace("{Price}",item.Price.Formatted))</span>
<span class="text-muted small">
@{
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.Custom != "false" ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
if (item.Custom != "topup")
{
buttonText = buttonText.Replace("{0}",item.Price.Formatted)
?.Replace("{Price}",item.Price.Formatted);
}
}
@Safe.Raw(buttonText)
</span>
@if (item.Inventory.HasValue)
{

View file

@ -19,13 +19,13 @@
@for (int x = 0; x < Model.Items.Length; x++)
{
var item = Model.Items[x];
var buttonText = (string.IsNullOrEmpty(item.BuyButtonText) ?
item.Custom?
Model.CustomButtonText :
Model.ButtonText
: item.BuyButtonText)
.Replace("{0}",item.Price.Formatted)
.Replace("{Price}",item.Price.Formatted);
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.Custom != "false" ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
if (item.Custom != "topup")
{
buttonText = buttonText.Replace("{0}",item.Price.Formatted)
?.Replace("{Price}",item.Price.Formatted);
}
<div class="card px-0" data-id="@x">
@if (!String.IsNullOrWhiteSpace(item.Image))
{
@ -35,7 +35,7 @@
<div class="card-footer bg-transparent border-0 pb-3">
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
{
@if (item.Custom)
@if (item.Custom == "true")
{
<form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
<input type="hidden" name="choicekey" value="@item.Id"/>

View file

@ -326,12 +326,12 @@
<div class="card-title d-flex justify-content-between">
<span class="h5">{{perk.title? perk.title : perk.id}}</span>
<span class="text-muted">
<template v-if="perk.price.value">
<template v-if="perk.price && perk.price.value">
{{perk.price.value.noExponents()}}
{{targetCurrency}}
<template v-if="perk.custom">or more</template>
<template v-if="perk.custom === 'true'">or more</template>
</template>
<template v-else-if="!perk.price.value && perk.custom">
<template v-else-if="perk.custom === 'topup' || (!perk.price.value && perk.custom === 'true')">
Any amount
</template>
</span>
@ -340,8 +340,9 @@
<div class="input-group" style="max-width:500px;" v-if="expanded" :id="'perk-form'+ perk.id">
<input
v-if="perk.custom !== 'topup'"
:disabled="!active"
:readonly="!perk.custom"
:readonly="perk.custom !== 'true'"
class="form-control hide-number-spin"
type="number"
v-model="amount"
@ -349,7 +350,7 @@
step="any"
placeholder="Contribution Amount"
required>
<span class="input-group-text">{{targetCurrency}}</span>
<span class="input-group-text" v-if="perk.custom !== 'topup'">{{targetCurrency}}</span>
<button
class="btn btn-primary d-flex align-items-center"
v-bind:class="{ 'btn-disabled': loading}"

View file

@ -1,6 +1,6 @@
@model PaymentModel
<div>
<p>To complete payment, please send <b>@Model.BtcDue @Model.CryptoCode</b> to <b style="word-break: break-word;">@Model.BtcAddress</b></p>
<p>To complete payment, please send <b>@Safe.Raw(Model.IsUnsetTopUp? "any amount of": Model.BtcDue) @Model.CryptoCode</b> to <b style="word-break: break-word;">@Model.BtcAddress</b></p>
<p>Time remaining: @Model.TimeLeft</p>
<p>
<a href="@Model.InvoiceBitcoinUrl" style="word-break: break-word;" rel="noreferrer noopener">@Model.InvoiceBitcoinUrl</a>

View file

@ -112,7 +112,7 @@ Cart.prototype.getTotalProducts = function() {
typeof this.content[key] != 'undefined' &&
!this.content[key].disabled
) {
var price = this.toCents(this.content[key].price.value);
var price = this.toCents(this.content[key].price?.value??0);
amount += (this.content[key].count * price);
}
}
@ -439,7 +439,7 @@ Cart.prototype.listItems = function() {
'title': this.escape(item.title),
'count': this.escape(item.count),
'inventory': this.escape(item.inventory < 0? 99999: item.inventory),
'price': this.escape(item.price.formatted)
'price': this.escape(item.price?.formatted??0)
});
list.push($(tableTemplate));
}

View file

@ -38,7 +38,7 @@ addLoadEvent(function (ev) {
},
computed: {
canExpand: function(){
return !this.expanded && this.active && (this.perk.price.value || this.perk.custom) && (this.perk.inventory==null || this.perk.inventory > 0)
return !this.expanded && this.active && (this.perk.custom=="topup" || this.perk.price.value || this.perk.custom == "true") && (this.perk.inventory==null || this.perk.inventory > 0)
}
},
methods: {
@ -58,18 +58,20 @@ addLoadEvent(function (ev) {
}
},
setAmount: function (amount) {
this.amount = (amount || 0).noExponents();
this.amount = this.perk.custom == "topup"? null : (amount || 0).noExponents();
this.expanded = false;
}
},
mounted: function () {
this.setAmount(this.perk.price.value);
this.setAmount(this.perk.price?.value);
},
watch: {
perk: function (newValue, oldValue) {
if (newValue.price.value != oldValue.price.value) {
if(newValue.custom === "topup"){
this.setAmount();
}else if (newValue.price.value != oldValue.price.value) {
this.setAmount(newValue.price.value);
}
}