New feature: Apps limited inventory (#961)

This commit is contained in:
Andrew Camilleri 2019-09-02 15:37:52 +02:00 committed by Nicolas Dorier
parent fefc45854e
commit d99beb9811
20 changed files with 544 additions and 123 deletions

View file

@ -44,9 +44,9 @@ namespace BTCPayServer.Tests
formFile.ContentDisposition = $"form-data; name=\"file\"; filename=\"{fileInfo.Name}\""; formFile.ContentDisposition = $"form-data; name=\"file\"; filename=\"{fileInfo.Name}\"";
return formFile; return formFile;
} }
public static void Eventually(Action act) public static void Eventually(Action act, int ms = 200000)
{ {
CancellationTokenSource cts = new CancellationTokenSource(20000); CancellationTokenSource cts = new CancellationTokenSource(ms);
while (true) while (true)
{ {
try try

View file

@ -1861,7 +1861,7 @@ namespace BTCPayServer.Tests
[Fact] [Fact]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
public void CanUsePoSApp() public async Task CanUsePoSApp()
{ {
using (var tester = ServerTester.Create()) using (var tester = ServerTester.Create())
{ {
@ -1972,6 +1972,49 @@ donation:
Assert.Equal(test.ExpectedDivisibility, vmview.CurrencyInfo.Divisibility); Assert.Equal(test.ExpectedDivisibility, vmview.CurrencyInfo.Divisibility);
Assert.Equal(test.ExpectedSymbolSpace, vmview.CurrencyInfo.SymbolSpace); Assert.Equal(test.ExpectedSymbolSpace, vmview.CurrencyInfo.SymbolSpace);
} }
//test inventory related features
vmpos = Assert.IsType<UpdatePointOfSaleViewModel>(Assert.IsType<ViewResult>(apps.UpdatePointOfSale(appId).Result).Model);
vmpos.Title = "hello";
vmpos.Currency = "BTC";
vmpos.Template = @"
inventoryitem:
price: 1.0
title: good apple
inventory: 1
noninventoryitem:
price: 10.0";
Assert.IsType<RedirectToActionResult>(apps.UpdatePointOfSale(appId, vmpos).Result);
//inventoryitem has 1 item available
Assert.IsType<RedirectToActionResult>(publicApps.ViewPointOfSale(appId, 1, null, null, null, null, "inventoryitem").Result);
//we already bought all available stock so this should fail
await Task.Delay(100);
Assert.IsType<RedirectToActionResult>(publicApps.ViewPointOfSale(appId, 1, null, null, null, null, "inventoryitem").Result);
//inventoryitem has unlimited items available
Assert.IsType<RedirectToActionResult>(publicApps.ViewPointOfSale(appId, 1, null, null, null, null, "noninventoryitem").Result);
Assert.IsType<RedirectToActionResult>(publicApps.ViewPointOfSale(appId, 1, null, null, null, null, "noninventoryitem").Result);
//verify invoices where created
invoices = user.BitPay.GetInvoices();
Assert.Equal(2, invoices.Count(invoice => invoice.ItemCode.Equals("noninventoryitem")));
var inventoryItemInvoice = invoices.SingleOrDefault(invoice => invoice.ItemCode.Equals("inventoryitem"));
Assert.NotNull(inventoryItemInvoice);
//let's mark the inventoryitem invoice as invalid, thsi should return the item to back in stock
var controller = tester.PayTester.GetController<InvoiceController>(user.UserId, user.StoreId);
var appService = tester.PayTester.GetService<AppService>();
var eventAggregator = tester.PayTester.GetService<EventAggregator>();
Assert.IsType<JsonResult>( await controller.ChangeInvoiceState(inventoryItemInvoice.Id, "invalid"));
//check that item is back in stock
TestUtils.Eventually(() =>
{
vmpos = Assert.IsType<UpdatePointOfSaleViewModel>(Assert.IsType<ViewResult>(apps.UpdatePointOfSale(appId).Result).Model);
Assert.Equal(1, appService.Parse(vmpos.Template, "BTC").Single(item => item.Id == "inventoryitem").Inventory);
}, 10000);
} }
} }

View file

@ -156,7 +156,7 @@ namespace BTCPayServer.Controllers
app.TagAllInvoices = vm.UseAllStoreInvoices; app.TagAllInvoices = vm.UseAllStoreInvoices;
app.SetSettings(newSettings); app.SetSettings(newSettings);
await UpdateAppSettings(app); await _AppService.UpdateOrCreateApp(app);
_EventAggregator.Publish(new AppUpdated() _EventAggregator.Publish(new AppUpdated()
{ {

View file

@ -53,6 +53,7 @@ namespace BTCPayServer.Controllers
" title: Fruit Tea\n" + " title: Fruit Tea\n" +
" description: The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!\n" + " description: The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!\n" +
" image: https://cdn.pixabay.com/photo/2016/09/16/11/24/darts-1673812__480.jpg\n" + " image: https://cdn.pixabay.com/photo/2016/09/16/11/24/darts-1673812__480.jpg\n" +
" inventory: 5\n" +
" custom: true"; " custom: true";
EnableShoppingCart = false; EnableShoppingCart = false;
ShowCustomAmount = true; ShowCustomAmount = true;
@ -198,22 +199,11 @@ namespace BTCPayServer.Controllers
RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically)? (bool?) null: bool.Parse(vm.RedirectAutomatically) RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically)? (bool?) null: bool.Parse(vm.RedirectAutomatically)
}); });
await UpdateAppSettings(app); await _AppService.UpdateOrCreateApp(app);
StatusMessage = "App updated"; StatusMessage = "App updated";
return RedirectToAction(nameof(UpdatePointOfSale), new { appId }); return RedirectToAction(nameof(UpdatePointOfSale), new { appId });
} }
private async Task UpdateAppSettings(AppData app)
{
using (var ctx = _ContextFactory.CreateContext())
{
ctx.Apps.Add(app);
ctx.Entry<AppData>(app).State = EntityState.Modified;
ctx.Entry<AppData>(app).Property(a => a.Settings).IsModified = true;
ctx.Entry<AppData>(app).Property(a => a.TagAllInvoices).IsModified = true;
await ctx.SaveChangesAsync();
}
}
private int[] ListSplit(string list, string separator = ",") private int[] ListSplit(string list, string separator = ",")
{ {

View file

@ -127,29 +127,25 @@ namespace BTCPayServer.Controllers
StatusMessage = "Error: You are not owner of this store"; StatusMessage = "Error: You are not owner of this store";
return RedirectToAction(nameof(ListApps)); return RedirectToAction(nameof(ListApps));
} }
var id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)); var appData = new AppData
using (var ctx = _ContextFactory.CreateContext())
{ {
var appData = new AppData() { Id = id }; StoreDataId = selectedStore,
appData.StoreDataId = selectedStore; Name = vm.Name,
appData.Name = vm.Name; AppType = appType.ToString()
appData.AppType = appType.ToString(); };
ctx.Apps.Add(appData); await _AppService.UpdateOrCreateApp(appData);
await ctx.SaveChangesAsync();
}
StatusMessage = "App successfully created"; StatusMessage = "App successfully created";
CreatedAppId = id; CreatedAppId = appData.Id;
switch (appType) switch (appType)
{ {
case AppType.PointOfSale: case AppType.PointOfSale:
return RedirectToAction(nameof(UpdatePointOfSale), new { appId = id }); return RedirectToAction(nameof(UpdatePointOfSale), new { appId = appData.Id });
case AppType.Crowdfund: case AppType.Crowdfund:
return RedirectToAction(nameof(UpdateCrowdfund), new { appId = id }); return RedirectToAction(nameof(UpdateCrowdfund), new { appId = appData.Id });
default: default:
return RedirectToAction(nameof(ListApps)); return RedirectToAction(nameof(ListApps));
} }
} }
[HttpGet] [HttpGet]

View file

@ -1,52 +1,41 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Security.Claims; using System.Security.Claims;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Filters; using BTCPayServer.Filters;
using BTCPayServer.ModelBinders; using BTCPayServer.ModelBinders;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.AppViewModels; using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Ganss.XSS;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NBitpayClient;
using YamlDotNet.RepresentationModel;
using static BTCPayServer.Controllers.AppsController; using static BTCPayServer.Controllers.AppsController;
namespace BTCPayServer.Controllers namespace BTCPayServer.Controllers
{ {
public class AppsPublicController : Controller public class AppsPublicController : Controller
{ {
public AppsPublicController(AppService AppService, public AppsPublicController(AppService appService,
BTCPayServerOptions btcPayServerOptions, BTCPayServerOptions btcPayServerOptions,
InvoiceController invoiceController, InvoiceController invoiceController,
UserManager<ApplicationUser> userManager) UserManager<ApplicationUser> userManager)
{ {
_AppService = AppService; _AppService = appService;
_BtcPayServerOptions = btcPayServerOptions; _BtcPayServerOptions = btcPayServerOptions;
_InvoiceController = invoiceController; _InvoiceController = invoiceController;
_UserManager = userManager; _UserManager = userManager;
} }
private AppService _AppService; private readonly AppService _AppService;
private readonly BTCPayServerOptions _BtcPayServerOptions; private readonly BTCPayServerOptions _BtcPayServerOptions;
private InvoiceController _InvoiceController; private readonly InvoiceController _InvoiceController;
private readonly UserManager<ApplicationUser> _UserManager; private readonly UserManager<ApplicationUser> _UserManager;
[HttpGet] [HttpGet]
@ -132,6 +121,14 @@ namespace BTCPayServer.Controllers
price = choice.Price.Value; price = choice.Price.Value;
if (amount > price) if (amount > price)
price = amount; price = amount;
if (choice.Inventory.HasValue)
{
if (choice.Inventory <= 0)
{
return RedirectToAction(nameof(ViewPointOfSale), new { appId = appId });
}
}
} }
else else
{ {
@ -139,6 +136,33 @@ namespace BTCPayServer.Controllers
return NotFound(); return NotFound();
price = amount; price = amount;
title = settings.Title; title = settings.Title;
//if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items
if (!string.IsNullOrEmpty(posData) &&
settings.EnableShoppingCart &&
AppService.TryParsePosCartItems(posData, out var cartItems))
{
var choices = _AppService.Parse(settings.Template, settings.Currency);
var updateNeeded = false;
foreach (var cartItem in cartItems)
{
var itemChoice = choices.FirstOrDefault(c => c.Id == cartItem.Key);
if (itemChoice == null)
return NotFound();
if (itemChoice.Inventory.HasValue)
{
switch (itemChoice.Inventory)
{
case int i when i <= 0:
return RedirectToAction(nameof(ViewPointOfSale), new {appId});
case int inventory when inventory < cartItem.Value:
return RedirectToAction(nameof(ViewPointOfSale), new {appId});
}
}
}
}
} }
var store = await _AppService.GetStore(app); var store = await _AppService.GetStore(app);
store.AdditionalClaims.Add(new Claim(Policies.CanCreateInvoice.Key, store.Id)); store.AdditionalClaims.Add(new Claim(Policies.CanCreateInvoice.Key, store.Id));
@ -164,7 +188,6 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(InvoiceController.Checkout), "Invoice", new { invoiceId = invoice.Data.Id }); return RedirectToAction(nameof(InvoiceController.Checkout), "Invoice", new { invoiceId = invoice.Data.Id });
} }
[HttpGet] [HttpGet]
[Route("/apps/{appId}/crowdfund")] [Route("/apps/{appId}/crowdfund")]
[XFrameOptionsAttribute(XFrameOptionsAttribute.XFrameOptions.AllowAll)] [XFrameOptionsAttribute(XFrameOptionsAttribute.XFrameOptions.AllowAll)]
@ -243,6 +266,15 @@ namespace BTCPayServer.Controllers
price = choice.Price.Value; price = choice.Price.Value;
if (request.Amount > price) if (request.Amount > price)
price = request.Amount; price = request.Amount;
if (choice.Inventory.HasValue)
{
if (choice.Inventory <= 0)
{
return NotFound("Option was out of stock");
}
}
} }
if (!isAdmin && (settings.EnforceTargetAmount && info.TargetAmount.HasValue && price > if (!isAdmin && (settings.EnforceTargetAmount && info.TargetAmount.HasValue && price >

View file

@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Events;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.HostedServices
{
public class AppInventoryUpdaterHostedService : EventHostedServiceBase
{
private readonly AppService _AppService;
protected override void SubscibeToEvents()
{
Subscribe<InvoiceEvent>();
}
public AppInventoryUpdaterHostedService(EventAggregator eventAggregator, AppService appService) : base(
eventAggregator)
{
_AppService = appService;
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is InvoiceEvent invoiceEvent)
{
Dictionary<string, int> cartItems = null;
bool deduct;
switch (invoiceEvent.Name)
{
case InvoiceEvent.Expired:
case InvoiceEvent.MarkedInvalid:
deduct = false;
break;
case InvoiceEvent.Created:
deduct = true;
break;
default:
return;
}
if ((!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode) ||
AppService.TryParsePosCartItems(invoiceEvent.Invoice.PosData, out cartItems)))
{
var appIds = AppService.GetAppInternalTags(invoiceEvent.Invoice);
if (!appIds.Any())
{
return;
}
//get all apps that were tagged that have manageable inventory that has an item that matches the item code in the invoice
var apps = (await _AppService.GetApps(appIds)).Select(data =>
{
switch (Enum.Parse<AppType>(data.AppType))
{
case AppType.PointOfSale:
var possettings = data.GetSettings<AppsController.PointOfSaleSettings>();
return (Data: data, Settings: (object)possettings,
Items: _AppService.Parse(possettings.Template, possettings.Currency));
case AppType.Crowdfund:
var cfsettings = data.GetSettings<CrowdfundSettings>();
return (Data: data, Settings: (object)cfsettings,
Items: _AppService.Parse(cfsettings.PerksTemplate, cfsettings.TargetCurrency));
default:
return (null, null, null);
}
}).Where(tuple => tuple.Data != null && tuple.Items.Any(item =>
item.Inventory.HasValue &&
((!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode) &&
item.Id == invoiceEvent.Invoice.ProductInformation.ItemCode) ||
(cartItems != null && cartItems.ContainsKey(item.Id)))));
foreach (var valueTuple in apps)
{
foreach (var item1 in valueTuple.Items.Where(item =>
((!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode) &&
item.Id == invoiceEvent.Invoice.ProductInformation.ItemCode) ||
(cartItems != null && cartItems.ContainsKey(item.Id)))))
{
if (cartItems != null && cartItems.ContainsKey(item1.Id))
{
if (deduct)
{
item1.Inventory -= cartItems[item1.Id];
}
else
{
item1.Inventory += cartItems[item1.Id];
}
}
else if (!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode) &&
item1.Id == invoiceEvent.Invoice.ProductInformation.ItemCode)
{
if (deduct)
{
item1.Inventory--;
}
else
{
item1.Inventory++;
}
}
}
switch (Enum.Parse<AppType>(valueTuple.Data.AppType))
{
case AppType.PointOfSale:
((AppsController.PointOfSaleSettings)valueTuple.Settings).Template =
_AppService.SerializeTemplate(valueTuple.Items);
break;
case AppType.Crowdfund:
((CrowdfundSettings)valueTuple.Settings).PerksTemplate =
_AppService.SerializeTemplate(valueTuple.Items);
break;
default:
throw new InvalidOperationException();
}
valueTuple.Data.SetSettings(valueTuple.Settings);
await _AppService.UpdateOrCreateApp(valueTuple.Data);
}
}
}
}
}
}

View file

@ -208,6 +208,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, RatesHostedService>(); services.AddSingleton<IHostedService, RatesHostedService>();
services.AddSingleton<IHostedService, BackgroundJobSchedulerHostedService>(); services.AddSingleton<IHostedService, BackgroundJobSchedulerHostedService>();
services.AddSingleton<IHostedService, AppHubStreamer>(); services.AddSingleton<IHostedService, AppHubStreamer>();
services.AddSingleton<IHostedService, AppInventoryUpdaterHostedService>();
services.AddSingleton<IHostedService, DynamicDnsHostedService>(); services.AddSingleton<IHostedService, DynamicDnsHostedService>();
services.AddSingleton<IHostedService, TorServicesHostedService>(); services.AddSingleton<IHostedService, TorServicesHostedService>();
services.AddSingleton<IHostedService, PaymentRequestStreamer>(); services.AddSingleton<IHostedService, PaymentRequestStreamer>();

View file

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -20,6 +20,7 @@ namespace BTCPayServer.Models.AppViewModels
public ItemPrice Price { get; set; } public ItemPrice Price { get; set; }
public string Title { get; set; } public string Title { get; set; }
public bool Custom { get; set; } public bool Custom { get; set; }
public int? Inventory { get; set; } = null;
} }
public class CurrencyInfoData public class CurrencyInfoData

View file

@ -17,14 +17,20 @@ using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using ExchangeSharp;
using Ganss.XSS; using Ganss.XSS;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient; using NBitpayClient;
using Newtonsoft.Json.Linq;
using YamlDotNet.RepresentationModel; using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using static BTCPayServer.Controllers.AppsController; using static BTCPayServer.Controllers.AppsController;
using static BTCPayServer.Models.AppViewModels.ViewCrowdfundViewModel; using static BTCPayServer.Models.AppViewModels.ViewCrowdfundViewModel;
@ -268,7 +274,33 @@ namespace BTCPayServer.Services.Apps
return _storeRepository.FindStore(app.StoreDataId); return _storeRepository.FindStore(app.StoreDataId);
} }
public string SerializeTemplate(ViewPointOfSaleViewModel.Item[] items)
{
var mappingNode = new YamlMappingNode();
foreach (var item in items)
{
var itemNode = new YamlMappingNode();
itemNode.Add("title", new YamlScalarNode(item.Title));
itemNode.Add("price", new YamlScalarNode(item.Price.Value.ToStringInvariant()));
if (!string.IsNullOrEmpty(item.Description))
{
itemNode.Add("description", new YamlScalarNode(item.Description));
}
if (!string.IsNullOrEmpty(item.Image))
{
itemNode.Add("image", new YamlScalarNode(item.Image));
}
itemNode.Add("custom", new YamlScalarNode(item.Custom.ToStringLowerInvariant()));
if (item.Inventory.HasValue)
{
itemNode.Add("inventory", new YamlScalarNode(item.Inventory.ToString()));
}
mappingNode.Add(item.Id, itemNode);
}
var serializer = new SerializerBuilder().Build();
return serializer.Serialize(mappingNode);
}
public ViewPointOfSaleViewModel.Item[] Parse(string template, string currency) public ViewPointOfSaleViewModel.Item[] Parse(string template, string currency)
{ {
if (string.IsNullOrWhiteSpace(template)) if (string.IsNullOrWhiteSpace(template))
@ -293,7 +325,8 @@ namespace BTCPayServer.Services.Apps
Value = decimal.Parse(cc.Value.Value, CultureInfo.InvariantCulture), Value = decimal.Parse(cc.Value.Value, CultureInfo.InvariantCulture),
Formatted = Currencies.FormatCurrency(cc.Value.Value, currency) Formatted = Currencies.FormatCurrency(cc.Value.Value, currency)
}).Single(), }).Single(),
Custom = c.GetDetailString("custom") == "true" Custom = c.GetDetailString("custom") == "true",
Inventory = string.IsNullOrEmpty(c.GetDetailString("inventory")) ?(int?) null: int.Parse(c.GetDetailString("inventory"), CultureInfo.InvariantCulture)
}) })
.ToArray(); .ToArray();
} }
@ -369,7 +402,6 @@ namespace BTCPayServer.Services.Apps
public string GetDetailString(string field) public string GetDetailString(string field)
{ {
return GetDetail(field).FirstOrDefault()?.Value?.Value; return GetDetail(field).FirstOrDefault()?.Value?.Value;
} }
} }
@ -396,5 +428,51 @@ namespace BTCPayServer.Services.Apps
return app; return app;
} }
} }
public async Task UpdateOrCreateApp(AppData app)
{
using (var ctx = _ContextFactory.CreateContext())
{
if (string.IsNullOrEmpty(app.Id))
{
app.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20));
app.Created = DateTimeOffset.Now;
await ctx.Apps.AddAsync(app);
}
else
{
ctx.Apps.Update(app);
ctx.Entry(app).Property(data => data.Created).IsModified = false;
ctx.Entry(app).Property(data => data.Id).IsModified = false;
ctx.Entry(app).Property(data => data.AppType).IsModified = false;
}
await ctx.SaveChangesAsync();
}
}
private static bool TryParseJson(string json, out JObject result)
{
result = null;
try
{
result = JObject.Parse(json);
return true;
}
catch
{
return false;
}
}
public static bool TryParsePosCartItems(string posData, out Dictionary<string, int> cartItems)
{
cartItems = null;
if (!TryParseJson(posData, out var posDataObj) ||
!posDataObj.TryGetValue("cart", out var cartObject)) return false;
cartItems = cartObject.Select(token => (JObject)token)
.ToDictionary(o => o.GetValue("id", StringComparison.InvariantCulture).ToString(),
o => int.Parse(o.GetValue("count", StringComparison.InvariantCulture).ToString(), CultureInfo.InvariantCulture ));
return true;
}
} }
} }

View file

@ -273,6 +273,12 @@
<textarea rows="3" cols="40" class="js-product-description form-control mb-2">{description}</textarea> <textarea rows="3" cols="40" class="js-product-description form-control mb-2">{description}</textarea>
</div> </div>
</div> </div>
<div class="form-row">
<div class="col">
<label>Inventory (leave to not use inventory feature)</label>
<input type="number" step="1" class="js-product-inventory form-control mb-2" value="{inventory}"/>
</div>
</div>
</div> </div>
</script> </script>
} }

View file

@ -277,6 +277,12 @@
<textarea rows="3" cols="40" class="js-product-description form-control mb-2">{description}</textarea> <textarea rows="3" cols="40" class="js-product-description form-control mb-2">{description}</textarea>
</div> </div>
</div> </div>
<div class="form-row">
<div class="col">
<label>Inventory (leave blank to not use inventory feature)</label>
<input type="number" step="1" class="js-product-inventory form-control mb-2" value="{inventory}"/>
</div>
</div>
</div> </div>
</script> </script>

View file

@ -1,4 +1,5 @@
@using Microsoft.EntityFrameworkCore.Internal @using Microsoft.EntityFrameworkCore.Internal
@using Microsoft.EntityFrameworkCore.Storage
@model BTCPayServer.Models.AppViewModels.ContributeToCrowdfund @model BTCPayServer.Models.AppViewModels.ContributeToCrowdfund
<form method="post"> <form method="post">
@ -43,11 +44,29 @@
<p class="card-text overflow-hidden">@Safe.Raw(item.Description)</p> <p class="card-text overflow-hidden">@Safe.Raw(item.Description)</p>
</div> </div>
@if (Model.ViewCrowdfundViewModel.PerkCount.ContainsKey(item.Id)) @if (Model.ViewCrowdfundViewModel.PerkCount.ContainsKey(item.Id) || item.Inventory.HasValue)
{ {
<div class="card-footer d-flex justify-content-between"> <div class="card-footer d-flex justify-content-between">
<span></span> @switch (item.Inventory)
<span> @Model.ViewCrowdfundViewModel.PerkCount[item.Id] Contributors</span> {
case null:
<span></span>
break;
case int i when i <= 0:
<span>Sold out</span>
break;
default:
<span>@item.Inventory left</span>
break;
}
@if (Model.ViewCrowdfundViewModel.PerkCount.ContainsKey(item.Id))
{
<span> @Model.ViewCrowdfundViewModel.PerkCount[item.Id] Contributors</span>
}
else
{
<span></span>
}
</div> </div>
} }
</div> </div>

View file

@ -300,9 +300,11 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between" v-if="perk.sold"> <div class="card-footer d-flex justify-content-between" v-if="perk.sold || perk.inventory != null">
<span ></span>
<span x >{{perk.sold}} Contributor{{perk.sold > 1? "s": ""}}</span> <span v-if="perk.inventory != null && perk.inventory > 0" class="text-center text-muted">{{perk.inventory}} left</span>
<span v-if="perk.inventory != null && perk.inventory <= 0" class="text-center text-muted">Sold out</span>
<span v-if="perk.sold" >{{perk.sold}} Contributor{{perk.sold > 1? "s": ""}}</span>
</div> </div>
</form> </form>
</div> </div>

View file

@ -6,6 +6,7 @@
ViewData["Title"] = Model.Title; ViewData["Title"] = Model.Title;
Layout = null; Layout = null;
int[] CustomTipPercentages = Model.CustomTipPercentages; int[] CustomTipPercentages = Model.CustomTipPercentages;
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
} }
<!DOCTYPE html> <!DOCTYPE html>
@ -42,10 +43,19 @@
grid-gap: .5rem; grid-gap: .5rem;
} }
.card-deck .card:only-of-type { .card-deck .card:only-of-type {
max-width: 320px; max-width: 320px;
margin: auto; margin: auto;
} }
.js-cart-item-count::-webkit-inner-spin-button,
.js-cart-item-count::-webkit-outer-spin-button {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
margin: 0;
}
</style> </style>
@if (!string.IsNullOrEmpty(Model.EmbeddedCSS)) @if (!string.IsNullOrEmpty(Model.EmbeddedCSS))
{ {
@ -55,7 +65,7 @@
</head> </head>
<body class="h-100"> <body class="h-100">
<script id="template-cart-item" type="text/template"> <script id="template-cart-item" type="text/template">
<tr data-id="{id}"> <tr data-id="{id}">
{image} {image}
<td class="align-middle pr-0 pl-2"><b>{title}</b></td> <td class="align-middle pr-0 pl-2"><b>{title}</b></td>
@ -67,7 +77,7 @@
<div class="input-group-prepend"> <div class="input-group-prepend">
<a class="js-cart-item-minus btn btn-link px-2" href="#"><i class="fa fa-minus-circle fa-fw text-danger"></i></a> <a class="js-cart-item-minus btn btn-link px-2" href="#"><i class="fa fa-minus-circle fa-fw text-danger"></i></a>
</div> </div>
<input class="js-cart-item-count form-control form-control-sm pull-left" type="text" name="count" placeholder="Qty" value="{count}" data-prev="{count}"> <input class="js-cart-item-count form-control form-control-sm pull-left" type="number" step="1" name="count" placeholder="Qty" max="{inventory}" value="{count}" data-prev="{count}">
<div class="input-group-append"> <div class="input-group-append">
<a class="js-cart-item-plus btn btn-link px-2" href="#"> <a class="js-cart-item-plus btn btn-link px-2" href="#">
<i class="fa fa-plus-circle fa-fw text-success"></i> <i class="fa fa-plus-circle fa-fw text-success"></i>
@ -287,7 +297,7 @@
var image = item.Image; var image = item.Image;
var description = item.Description; var description = item.Description;
<div class="js-add-cart card my-2 card-wrapper" data-id="@index"> <div class="js-add-cart card my-2 card-wrapper" data-index="@index">
@if (!String.IsNullOrWhiteSpace(image)) @if (!String.IsNullOrWhiteSpace(image))
{ {
@:<img class="card-img-top" src="@image" alt="Card image cap"> @:<img class="card-img-top" src="@image" alt="Card image cap">
@ -302,6 +312,23 @@
<div class="card-footer pt-0 bg-transparent border-0"> <div class="card-footer pt-0 bg-transparent border-0">
<span class="text-muted small">@String.Format(Model.ButtonText, @item.Price.Formatted)</span> <span class="text-muted small">@String.Format(Model.ButtonText, @item.Price.Formatted)</span>
@if (item.Inventory.HasValue)
{
<div class="w-100 pt-2 text-center text-muted">
@if (item.Inventory > 0)
{
<span>@item.Inventory left</span>
}
else
{
<span>Sold out</span>
}
</div>
}else if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp</div>
}
</div> </div>
</div> </div>
} }
@ -378,29 +405,51 @@
</div> </div>
<div class="card-footer bg-transparent border-0"> <div class="card-footer bg-transparent border-0">
@if (item.Custom) @if (!item.Inventory.HasValue || item.Inventory.Value > 0)
{ {
<form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy> <div class="w-100">
<input type="hidden" name="choicekey" value="@item.Id" /> @if (item.Custom)
<div class="input-group"> {
<div class="input-group-prepend"> <form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
<span class="input-group-text">@Model.CurrencySymbol</span> <input type="hidden" name="choicekey" value="@item.Id"/>
</div> <div class="input-group">
<input class="form-control" type="number" min="@item.Price.Value" step="@Model.Step" name="amount" <div class="input-group-prepend">
value="@item.Price.Value" placeholder="Amount"> <span class="input-group-text">@Model.CurrencySymbol</span>
<div class="input-group-append"> </div>
<button class="btn btn-primary" type="submit">@Model.CustomButtonText</button> <input class="form-control" type="number" min="@item.Price.Value" step="@Model.Step" name="amount"
</div> value="@item.Price.Value" placeholder="Amount">
</div> <div class="input-group-append">
</form> <button class="btn btn-primary" type="submit">@Model.CustomButtonText</button>
</div>
</div>
</form>
}
else
{
<form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
<button type="submit" name="choiceKey" class="js-add-cart btn btn-primary" value="@item.Id">
@String.Format(Model.ButtonText, @item.Price.Formatted)
</button>
</form>
}
</div>
} }
else @if (item.Inventory.HasValue)
{ {
<form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
<button type="submit" name="choiceKey" class="js-add-cart btn btn-primary" value="@item.Id"> <div class="w-100 pt-2 text-center text-muted">
@String.Format(Model.ButtonText, @item.Price.Formatted) @if (item.Inventory > 0)
</button> {
</form> <span>@item.Inventory left</span>
}
else
{
<span>Sold out</span>
}
</div>
}else if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp</div>
} }
</div> </div>
</div> </div>
@ -425,6 +474,10 @@
</div> </div>
</div> </div>
</form> </form>
@if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp</div>
}
</div> </div>
</div> </div>
} }

View file

@ -52,8 +52,8 @@ $(document).ready(function(){
var $btn = $(event.target), var $btn = $(event.target),
self = this; self = this;
id = $btn.closest('.card').data('id'), index = $btn.closest('.card').data('index'),
item = srvModel.items[id], item = srvModel.items[index],
items = cart.items; items = cart.items;
// Is event catching disabled? // Is event catching disabled?
@ -68,10 +68,11 @@ $(document).ready(function(){
}); });
cart.addItem({ cart.addItem({
id: id, id: item.id,
title: item.title, title: item.title,
price: item.price, price: item.price,
image: typeof item.image != 'underfined' ? item.image : null image: typeof item.image != 'undefined' ? item.image : null,
inventory: item.inventory
}); });
cart.listItems(); cart.listItems();
} }

View file

@ -150,7 +150,7 @@ Cart.prototype.addItem = function(item) {
// Add new item because it doesn't exist yet // Add new item because it doesn't exist yet
if (!result.length) { if (!result.length) {
this.content.push({id: id, title: item.title, price: item.price, count: 0, image: item.image}); this.content.push({id: id, title: item.title, price: item.price, count: 0, image: item.image, inventory: item.inventory});
this.emptyCartToggle(); this.emptyCartToggle();
} }
@ -159,21 +159,30 @@ Cart.prototype.addItem = function(item) {
} }
Cart.prototype.incrementItem = function(id) { Cart.prototype.incrementItem = function(id) {
var self = this; var oldItemsCount = this.items;
this.items = 0; // Calculate total # of items from scratch just to make sure this.items = 0; // Calculate total # of items from scratch just to make sure
var result = true;
this.content.filter(function(obj){ for (var i = 0; i < this.content.length; i++) {
// Increment the item count var obj = this.content[i];
if (obj.id === id){ if (obj.id === id){
if(obj.inventory != null && obj.inventory <= obj.count){
result = false;
continue;
}
obj.count++; obj.count++;
delete(obj.disabled); delete(obj.disabled);
} }
// Increment the total # of items // Increment the total # of items
self.items += obj.count; this.items += obj.count;
}); }
if(!result){
this.items = oldItemsCount;
}
this.updateAll(); this.updateAll();
return result;
} }
// Disable cart item so it doesn't count towards total amount // Disable cart item so it doesn't count towards total amount
@ -425,6 +434,7 @@ Cart.prototype.listItems = function() {
}) : '', }) : '',
'title': this.escape(item.title), 'title': this.escape(item.title),
'count': this.escape(item.count), '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)
}); });
list.push($(tableTemplate)); list.push($(tableTemplate));
@ -499,14 +509,14 @@ Cart.prototype.listItems = function() {
// Increment item // Increment item
$('.js-cart-item-plus').off().on('click', function(event){ $('.js-cart-item-plus').off().on('click', function(event){
event.preventDefault(); event.preventDefault();
if(self.incrementItem($(this).closest('tr').data('id'))){
var $val = $(this).parents('.input-group').find('.js-cart-item-count'), var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
val = parseInt($val.val() || $val.data('prev')) + 1; val = parseInt($val.val() || $val.data('prev')) + 1;
$val.val(val); $val.val(val);
$val.data('prev', val); $val.data('prev', val);
self.resetTip(); self.resetTip();
self.incrementItem($(this).closest('tr').data('id')); }
}); });
// Decrement item // Decrement item
@ -625,15 +635,39 @@ Cart.prototype.saveLocalStorage = function() {
Cart.prototype.loadLocalStorage = function() { Cart.prototype.loadLocalStorage = function() {
this.content = $.parseJSON(localStorage.getItem(this.getStorageKey('cart'))) || []; this.content = $.parseJSON(localStorage.getItem(this.getStorageKey('cart'))) || [];
var self = this;
// Get number of cart items // Get number of cart items
for (var key in this.content) { for (var i = this.content.length-1; i >= 0; i--) {
if (this.content.hasOwnProperty(key) && typeof this.content[key] != 'undefined' && this.content[key] != null) { if (!this.content[i]) {
this.items += this.content[key].count; this.content.splice(i,1);
continue;
// Delete the disabled flag if any
delete(this.content[key].disabled);
} }
//check if the pos items still has the cached cart items
var matchedItem = srvModel.items.find(function(item){
return item.id === self.content[i].id;
});
if(!matchedItem){
//remove if no longer available
this.content.splice(i,1);
continue;
}else{
if(matchedItem.inventory != null && matchedItem.inventory <= 0){
//item is out of stock
this.content.splice(i,1);
}else if(matchedItem.inventory != null && matchedItem.inventory < this.content[i].count){
//not enough stock for original cart amount, reduce to available stock
this.content[i].count = matchedItem.inventory;
}
//update its stock
this.content[i].inventory = matchedItem.inventory;
}
this.items += this.content[i].count;
// Delete the disabled flag if any
delete(this.content[i].disabled);
} }
this.discount = localStorage.getItem(this.getStorageKey('cartDiscount')); this.discount = localStorage.getItem(this.getStorageKey('cartDiscount'));

View file

@ -38,7 +38,7 @@ addLoadEvent(function (ev) {
}, },
computed: { computed: {
canExpand: function(){ canExpand: function(){
return !this.expanded && this.active && (this.perk.price.value || this.perk.custom) return !this.expanded && this.active && (this.perk.price.value || this.perk.custom) && (this.perk.inventory==null || this.perk.inventory > 0)
} }
}, },
methods: { methods: {

View file

@ -35,7 +35,8 @@ $(document).ready(function(){
var index = $('.js-product-index').val(), var index = $('.js-product-index').val(),
description = $('.js-product-description').val(), description = $('.js-product-description').val(),
image = $('.js-product-image').val(), image = $('.js-product-image').val(),
custom = $('.js-product-custom').val(); custom = $('.js-product-custom').val(),
inventory = parseInt($('.js-product-inventory').val(), 10);
obj = { obj = {
id: products.escape($('.js-product-id').val()), id: products.escape($('.js-product-id').val()),
price: products.escape($('.js-product-price').val()), price: products.escape($('.js-product-price').val()),
@ -58,7 +59,9 @@ $(document).ready(function(){
if (!Boolean(index)) { if (!Boolean(index)) {
obj.id = products.escape(obj.title.toLowerCase() + ':'); obj.id = products.escape(obj.title.toLowerCase() + ':');
} }
if(inventory != null && !isNaN(inventory ))
obj.inventory = inventory;
products.saveItem(obj, index); products.saveItem(obj, index);
} }
}); });
@ -66,4 +69,4 @@ $(document).ready(function(){
$('.js-product-add').click(function(){ $('.js-product-add').click(function(){
products.itemContent(); products.itemContent();
}); });
}); });

View file

@ -9,8 +9,21 @@ function Products() {
} }
Products.prototype.loadFromTemplate = function() { Products.prototype.loadFromTemplate = function() {
var template = $('.js-product-template').val().trim(), var template = $('.js-product-template').val().trim();
lines = template.split("\n\n");
var lines = [];
var items = template.split("\n");
for (var i = 0; i < items.length; i++) {
if(items[i] === ""){
continue;
}
if(items[i].startsWith(" ")){
lines[lines.length-1]+=items[i] + "\n";
}else{
lines.push(items[i] + "\n");
}
}
this.products = []; this.products = [];
@ -19,7 +32,7 @@ Products.prototype.loadFromTemplate = function() {
var line = lines[kl], var line = lines[kl],
product = line.split("\n"), product = line.split("\n"),
id, price, title, description, image = null, id, price, title, description, image = null,
custom; custom, inventory=null;
for (var kp in product) { for (var kp in product) {
var productProperty = product[kp].trim(); var productProperty = product[kp].trim();
@ -43,6 +56,9 @@ Products.prototype.loadFromTemplate = function() {
if (productProperty.indexOf('custom:') !== -1) { if (productProperty.indexOf('custom:') !== -1) {
custom = productProperty.replace('custom:', '').trim(); custom = productProperty.replace('custom:', '').trim();
} }
if (productProperty.indexOf('inventory:') !== -1) {
inventory = parseInt(productProperty.replace('inventory:', '').trim(),10);
}
} }
if (price != null || title != null) { if (price != null || title != null) {
@ -53,12 +69,13 @@ Products.prototype.loadFromTemplate = function() {
'price': price, 'price': price,
'image': image || null, 'image': image || null,
'description': description || null, 'description': description || null,
'custom': Boolean(custom) 'custom': Boolean(custom),
'inventory': isNaN(inventory)? null: inventory
}); });
} }
} }
} };
Products.prototype.saveTemplate = function() { Products.prototype.saveTemplate = function() {
var template = ''; var template = '';
@ -69,9 +86,10 @@ Products.prototype.saveTemplate = function() {
id = product.id, id = product.id,
title = product.title, title = product.title,
price = product.price? product.price : 0, price = product.price? product.price : 0,
image = product.image image = product.image,
description = product.description, description = product.description,
custom = product.custom; custom = product.custom,
inventory = product.inventory;
template += id + '\n' + template += id + '\n' +
' price: ' + parseFloat(price).noExponents() + '\n' + ' price: ' + parseFloat(price).noExponents() + '\n' +
@ -86,11 +104,14 @@ Products.prototype.saveTemplate = function() {
if (custom) { if (custom) {
template += ' custom: true\n'; template += ' custom: true\n';
} }
if(inventory != null){
template+= ' inventory: ' + inventory + '\n';
}
template += '\n'; template += '\n';
} }
$('.js-product-template').val(template); $('.js-product-template').val(template);
} };
Products.prototype.showAll = function() { Products.prototype.showAll = function() {
var list = []; var list = [];
@ -106,7 +127,7 @@ Products.prototype.showAll = function() {
} }
$('.js-products').html(list); $('.js-products').html(list);
} };
// Load the template // Load the template
Products.prototype.template = function($template, obj) { Products.prototype.template = function($template, obj) {
@ -118,7 +139,7 @@ Products.prototype.template = function($template, obj) {
} }
return template; return template;
} };
Products.prototype.saveItem = function(obj, index) { Products.prototype.saveItem = function(obj, index) {
// Edit product // Edit product
@ -143,7 +164,7 @@ Products.prototype.removeItem = function(index) {
} }
this.saveTemplate(); this.saveTemplate();
} };
Products.prototype.itemContent = function(index) { Products.prototype.itemContent = function(index) {
var product = null, var product = null,
@ -162,11 +183,12 @@ Products.prototype.itemContent = function(index) {
'title': product != null ? this.escape(product.title) : '', 'title': product != null ? this.escape(product.title) : '',
'description': product != null ? this.escape(product.description) : '', 'description': product != null ? this.escape(product.description) : '',
'image': product != null ? this.escape(product.image) : '', 'image': product != null ? this.escape(product.image) : '',
'inventory': product != null ? parseInt(this.escape(product.inventory),10) : '',
'custom': '<option value="true"' + (custom ? ' selected' : '') + '>Yes</option><option value="false"' + (!custom ? ' selected' : '') + '>No</option>' 'custom': '<option value="true"' + (custom ? ' selected' : '') + '>Yes</option><option value="false"' + (!custom ? ' selected' : '') + '>No</option>'
}); });
$('#product-modal').find('.modal-body').html(template); $('#product-modal').find('.modal-body').html(template);
} };
Products.prototype.modalEmpty = function() { Products.prototype.modalEmpty = function() {
var $modal = $('#product-modal'); var $modal = $('#product-modal');