Allows merchants configure tax inclusion or exclusion (#7290)
What changed, and why it matters
This commit adds a new merchant setting that lets a store choose whether entered prices already include sales tax or whether tax should be added on top. It updates the Point of Sale screens, order calculation logic, and tests. There is no indication in the commit that this fixes a security vulnerability; it appears to be a normal feature addition for tax handling.
No security action required; review as a normal feature change. If auditing, verify that inclusive/exclusive tax math is correct and consistent between server and client, and that invoice metadata accurately reflects the chosen mode.
Security signals we found
No security-relevant signals detected in the diff
Change is a functional tax-calculation feature, not a vulnerability patch
Evidence from the diff
The change introduces a TaxIncludedInPrice boolean in PointOfSaleSettings and propagates it through view models, the UpdatePointOfSale UI, server-side PoSOrder calculation, and client-side JavaScript order calculation. When enabled, tax is computed as an inclusive portion of the line price (tax = price * rate / (100 + rate)); otherwise tax is added on top as before. The diff also adds Playwright tests verifying inclusive tax display and metadata. No security-relevant fixes such as input validation, authorization, or injection prevention are present.
Changed components
BTCPayServer Point of Sale pluginPoS order calculation (server and client)Point of Sale settings and update viewInspect captured patch +93 / −31
diff --git a/BTCPayServer.Tests/POSTests.cs b/BTCPayServer.Tests/POSTests.cs
index 675e17a..c015c4c 100644
--- a/BTCPayServer.Tests/POSTests.cs
+++ b/BTCPayServer.Tests/POSTests.cs
@@ -19,15 +19,16 @@ using BTCPayServer.Tests.PMO;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
using LNURL;
+using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Playwright;
-using static Microsoft.Playwright.Assertions;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Abstractions;
using static BTCPayServer.Tests.UnitTest1;
+using static Microsoft.Playwright.Assertions;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Tests
@@ -441,6 +442,7 @@ goodies:
// Setup POS
await s.CreateApp("PointOfSale");
+ var editUrl = s.Page.Url;
await s.Page.ClickAsync("label[for='DefaultView_Cart']");
await s.Page.FillAsync("#Currency", "EUR");
Assert.False(await s.Page.IsCheckedAsync("#EnableTips"));
@@ -462,6 +464,7 @@ goodies:
// View
await ViewApp(s);
+ var keypadUrl = s.Page.Url;
await s.Page.WaitForSelectorAsync("#PosItems");
Assert.Empty(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
var posUrl = s.Page.Url;
@@ -618,6 +621,30 @@ goodies:
throw;
}
+ await s.GoToUrl(editUrl);
+ await s.Page.ClickAsync("#TaxIncludedInPrice");
+ await s.ClickPagePrimary();
+ await s.FindAlertMessage(partialText: "App updated");
+
+ await s.GoToUrl(posUrl);
+ await s.Page.WaitForSelectorAsync("#PosItems");
+ await s.Page.ClickAsync(".posItem:nth-child(1) .btn-primary");
+
+ await AssertCartSummary(s, new()
+ {
+ Subtotal = "0,91 €",
+ Taxes = "0,09 € (10%)",
+ Total = "1,00 €"
+ });
+
+ await s.Page.ClickAsync("#CartSubmit");
+ await s.Page.WaitForSelectorAsync("#Checkout");
+ await s.Page.ClickAsync("#DetailsToggle");
+ await s.Page.WaitForSelectorAsync("#PaymentDetails-TotalFiat");
+ Assert.Contains("0,09 €", await s.Page.TextContentAsync("#PaymentDetails-TaxIncluded"));
+ Assert.Contains("1,00 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
+ await s.PayInvoice(true);
+
// Guest user can access recent transactions
await s.GoToHome();
@@ -878,7 +905,7 @@ goodies:
await EnterKeypad(s, "123");
await Expect(s.Page.Locator("#Amount")).ToContainTextAsync("1,23");
- await AssertKeypadCalculation(s, "2 x Green Tea (1,00 €) = 2,00 € + 1 x Black Tea (1,00 €) = 1,00 € + 1,23 € + 0,42 € (10%)", "4,65 €");
+ await AssertKeypadCalculation(s, "2 x Green Tea (1,00 €) = 2,00 € + 1 x Black Tea (1,00 €) = 1,00 € + 1,23 € + 0,42 € (10% tax)", "4,65 €");
// Pay
await s.Page.ClickAsync("#pay-button");
diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
index bd6a60d..2bdedff 100644
--- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
+++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
@@ -138,6 +138,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
CustomTipText = settings.CustomTipText,
CustomTipPercentages = settings.CustomTipPercentages,
DefaultTaxRate = settings.DefaultTaxRate,
+ TaxIncludedInPrice = settings.TaxIncludedInPrice,
AppId = appId,
StoreId = store.Id,
HtmlLang = settings.HtmlLang,
@@ -263,11 +264,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
jposData.Amounts is null &&
amount is { } o)
{
- order.AddLine(new("", 1, o, settings.DefaultTaxRate));
+ order.AddLine(new("", 1, o, settings.DefaultTaxRate, settings.TaxIncludedInPrice));
}
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
{
- order.AddLine(new($"Custom Amount {i + 1}", 1, jposData.Amounts[i], settings.DefaultTaxRate));
+ order.AddLine(new($"Custom Amount {i + 1}", 1, jposData.Amounts[i], settings.DefaultTaxRate, settings.TaxIncludedInPrice));
}
foreach (var cartItem in jposData.Cart)
@@ -286,10 +287,10 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (cartItem.Price < expectedCartItemPrice)
cartItem.Price = expectedCartItemPrice;
}
- order.AddLine(new(cartItem.Id, cartItem.Count, cartItem.Price, itemChoice.TaxRate ?? settings.DefaultTaxRate));
+ order.AddLine(new(cartItem.Id, cartItem.Count, cartItem.Price, itemChoice.TaxRate ?? settings.DefaultTaxRate, settings.TaxIncludedInPrice));
}
if (customAmount is { } c && settings.ShowCustomAmount)
- order.AddLine(new("", 1, c, settings.DefaultTaxRate));
+ order.AddLine(new("", 1, c, settings.DefaultTaxRate, settings.TaxIncludedInPrice));
if (discount is { } d)
order.AddDiscountRate(d);
if (tip is { } t)
@@ -352,7 +353,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (invoiceRequest.Amount is not null && originalAmount != invoiceRequest.Amount.Value )
{
var diff = invoiceRequest.Amount.Value - originalAmount;
- order.AddLine(new("", 1, diff, settings.DefaultTaxRate));
+ order.AddLine(new("", 1, diff, settings.DefaultTaxRate, false));
}
break;
}
@@ -401,7 +402,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
entity.ExtendedNotifications = true;
if (formResponseJObject is not null)
{
- var meta = entity.Metadata.ToJObject();
+ var meta = entity.Metadata.ToJObject();
meta.Merge(formResponseJObject);
entity.Metadata = InvoiceMetadata.FromJObject(meta);
}
@@ -583,6 +584,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
AppName = app.Name,
Title = settings.Title,
DefaultTaxRate = settings.DefaultTaxRate,
+ TaxIncludedInPrice = settings.TaxIncludedInPrice,
DefaultView = settings.DefaultView,
ShowItems = settings.ShowItems,
ShowCustomAmount = settings.ShowCustomAmount,
@@ -680,6 +682,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
Title = vm.Title,
DefaultView = vm.DefaultView,
DefaultTaxRate = vm.DefaultTaxRate ?? 0,
+ TaxIncludedInPrice = vm.TaxIncludedInPrice,
ShowItems = vm.ShowItems,
ShowCustomAmount = vm.ShowCustomAmount,
ShowDiscount = vm.ShowDiscount,
diff --git a/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs b/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs
index 927b706..e17515e 100644
--- a/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs
+++ b/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs
@@ -45,6 +45,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
[Range(0.0, 100.0)]
[DisplayFormat(DataFormatString = "{0:0.00####}", ApplyFormatInEditMode = true)]
public decimal? DefaultTaxRate { get; set; }
+ [Display(Name = "Tax included in price")]
+ public bool TaxIncludedInPrice { get; set; }
public string Example1 { get; internal set; }
public string Example2 { get; internal set; }
public string ExampleCallback { get; internal set; }
diff --git a/BTCPayServer/Plugins/PointOfSale/Models/ViewPointOfSaleViewModel.cs b/BTCPayServer/Plugins/PointOfSale/Models/ViewPointOfSaleViewModel.cs
index 87b5873..163cbe7 100644
--- a/BTCPayServer/Plugins/PointOfSale/Models/ViewPointOfSaleViewModel.cs
+++ b/BTCPayServer/Plugins/PointOfSale/Models/ViewPointOfSaleViewModel.cs
@@ -84,6 +84,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public string Description { get; set; }
public SelectList AllCategories { get; set; }
public string StoreId { get; set; }
+ public bool TaxIncludedInPrice { get; set; }
public decimal DefaultTaxRate { get; set; }
public string NotAvailable { get; set; }
}
diff --git a/BTCPayServer/Plugins/PointOfSale/PoSOrder.cs b/BTCPayServer/Plugins/PointOfSale/PoSOrder.cs
index da0732a..36c2d4e 100644
--- a/BTCPayServer/Plugins/PointOfSale/PoSOrder.cs
+++ b/BTCPayServer/Plugins/PointOfSale/PoSOrder.cs
@@ -1,4 +1,4 @@
-#nullable enable
+#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
@@ -17,7 +17,7 @@ public class PoSOrder
_decimals = decimals;
}
- public record ItemLine(string ItemId, int Count, decimal UnitPrice, decimal TaxRate);
+ public record ItemLine(string ItemId, int Count, decimal UnitPrice, decimal TaxRate, bool TaxIncluded = false);
public void AddLine(ItemLine line)
{
ItemLines.Add(line);
@@ -44,10 +44,21 @@ public class PoSOrder
discount = Round(discount);
ctx.Discount += discount;
linePrice -= discount;
- var tax = linePrice * item.TaxRate / 100.0m;
- tax = Round(tax);
+
+ decimal tax;
+ decimal lineExcluded;
+ if (item.TaxIncluded && item.TaxRate > 0)
+ {
+ tax = Round(linePrice * item.TaxRate / (100.0m + item.TaxRate));
+ lineExcluded = linePrice - tax;
+ }
+ else
+ {
+ tax = Round(linePrice * item.TaxRate / 100.0m);
+ lineExcluded = linePrice;
+ }
ctx.Tax += tax;
- ctx.PriceTaxExcluded += linePrice;
+ ctx.PriceTaxExcluded += lineExcluded;
}
ctx.PriceTaxExcluded = Round(ctx.PriceTaxExcluded);
ctx.PriceTaxIncluded = ctx.PriceTaxExcluded + ctx.Tax;
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
index 816c77e..70f53f4 100644
--- a/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
+++ b/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
@@ -177,6 +177,13 @@
<div class="form-text" text-translate="true">This rate can also be overridden per item.</div>
<span asp-validation-for="DefaultTaxRate" class="text-danger"></span>
</div>
+ <div class="form-group d-flex align-items-center">
+ <input asp-for="TaxIncludedInPrice" type="checkbox" class="btcpay-toggle me-3" />
+ <div>
+ <label asp-for="TaxIncludedInPrice" class="form-check-label"></label>
+ <div class="text-muted" text-translate="true">When enabled, prices entered in the keypad are tax inclusive.</div>
+ </div>
+ </div>
</fieldset>
<fieldset id="discounts" class="mt-2">
diff --git a/BTCPayServer/Services/Apps/PointOfSaleSettings.cs b/BTCPayServer/Services/Apps/PointOfSaleSettings.cs
index ebe8264..e88aa6d 100644
--- a/BTCPayServer/Services/Apps/PointOfSaleSettings.cs
+++ b/BTCPayServer/Services/Apps/PointOfSaleSettings.cs
@@ -86,6 +86,7 @@ namespace BTCPayServer.Services.Apps
public bool EnableShoppingCart { get; set; }
[JsonConverter(typeof(JsonConverters.NumericStringJsonConverter))]
public decimal DefaultTaxRate { get; set; }
+ public bool TaxIncludedInPrice { get; set; }
public PosViewType DefaultView { get; set; }
public bool ShowItems { get; set; }
public bool ShowCustomAmount { get; set; }
diff --git a/BTCPayServer/wwwroot/pos/common.js b/BTCPayServer/wwwroot/pos/common.js
index 61608e0..c3113a5 100644
--- a/BTCPayServer/wwwroot/pos/common.js
+++ b/BTCPayServer/wwwroot/pos/common.js
@@ -10,11 +10,12 @@ class PoSOrder {
}
static ItemLine = class {
- constructor(itemId, count, unitPrice, taxRate = null) {
+ constructor(itemId, count, unitPrice, taxRate = null, taxIncluded = false) {
this.itemId = itemId;
this.count = count;
this.unitPrice = unitPrice;
this.taxRate = taxRate;
+ this.taxIncluded = taxIncluded;
}
}
@@ -35,16 +36,16 @@ class PoSOrder {
this._discount = discount;
}
- setCart(cart, amounts, defaultTaxRate) {
+ setCart(cart, amounts, defaultTaxRate, taxIncludedInPrice) {
this.itemLines = [];
for (const item of cart) {
- this.addLine(new PoSOrder.ItemLine(item.id, item.count, item.price, item.taxRate ?? defaultTaxRate));
+ this.addLine(new PoSOrder.ItemLine(item.id, item.count, item.price, item.taxRate ?? defaultTaxRate, taxIncludedInPrice));
}
if (amounts) {
var i = 1;
for (const item of amounts) {
if (!item) continue;
- this.addLine(new PoSOrder.ItemLine("Custom Amount " + i, 1, item, defaultTaxRate));
+ this.addLine(new PoSOrder.ItemLine("Custom Amount " + i, 1, item, defaultTaxRate, taxIncludedInPrice));
i++;
}
}
@@ -83,11 +84,17 @@ class PoSOrder {
ctx.discount += discount;
linePrice -= discount;
- let taxRate = item.taxRate ?? 0;
- let tax = linePrice * taxRate / 100;
- tax = this._round(tax);
+ let taxRate = +(item.taxRate ?? 0);
+ let tax, lineExcluded;
+ if (item.taxIncluded && taxRate > 0) {
+ tax = this._round(linePrice * taxRate / (100 + taxRate));
+ lineExcluded = linePrice - tax;
+ } else {
+ tax = this._round(linePrice * taxRate / 100);
+ lineExcluded = linePrice;
+ }
ctx.tax += tax;
- ctx.priceTaxExcluded += linePrice;
+ ctx.priceTaxExcluded += lineExcluded;
}
ctx.priceTaxExcluded = this._round(ctx.priceTaxExcluded);
@@ -241,12 +248,12 @@ const posCommon = {
if (this.persistState) {
saveState('cart', newCart)
}
- this.posOrder.setCart(newCart, this.amounts, this.defaultTaxRate)
+ this.posOrder.setCart(newCart, this.amounts, this.defaultTaxRate, this.taxIncludedInPrice)
},
deep: true
},
amounts (values) {
- this.posOrder.setCart(this.cart, values, this.defaultTaxRate)
+ this.posOrder.setCart(this.cart, values, this.defaultTaxRate, this.taxIncludedInPrice)
}
},
methods: {
@@ -321,13 +328,13 @@ const posCommon = {
// Animate
if (!$posItem.classList.contains(POS_ITEM_ADDED_CLASS)) $posItem.classList.add(POS_ITEM_ADDED_CLASS);
- this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
+ this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate, this.taxIncludedInPrice);
return itemInCart;
},
removeFromCart(id) {
const index = this.cart.findIndex(lineItem => lineItem.id === id);
this.cart.splice(index, 1);
- this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
+ this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate, this.taxIncludedInPrice);
},
getQuantity(id) {
const itemInCart = this.cart.find(lineItem => lineItem.id === id);
@@ -347,7 +354,7 @@ const posCommon = {
if (itemInCart && itemInCart.count <= 0 && addOrRemove) {
this.removeFromCart(itemInCart.id);
}
- this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
+ this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate, this.taxIncludedInPrice);
},
clear() {
this.cart = [];
@@ -408,7 +415,7 @@ const posCommon = {
if (this.persistState) {
this.cart = loadState('cart');
}
- this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
+ this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate, this.taxIncludedInPrice);
this.items.forEach(item => {
// Those images are generated by ASP.NET but not displayed.
diff --git a/BTCPayServer/wwwroot/pos/keypad.js b/BTCPayServer/wwwroot/pos/keypad.js
index 7aee0a1..56dc325 100644
--- a/BTCPayServer/wwwroot/pos/keypad.js
+++ b/BTCPayServer/wwwroot/pos/keypad.js
@@ -42,10 +42,13 @@ document.addEventListener("DOMContentLoaded",function () {
if (this.tipPercent) calc += ` (${this.tipPercent}%)`
if (this.summary.tax)
{
- calc += ` + ${this.formatCurrency(this.summary.tax, true)}`
- if (this.posOrder.getTaxRate())
- {
- calc += ` (${this.posOrder.getTaxRate()}%)`
+ if (this.taxIncludedInPrice) {
+ const rateLabel = this.posOrder.getTaxRate() ? ` ${this.posOrder.getTaxRate()}%` : ''
+ calc += ` (incl. ${this.formatCurrency(this.summary.tax, true)} tax${rateLabel})`
+ } else {
+ calc += ` + ${this.formatCurrency(this.summary.tax, true)}`
+ if (this.posOrder.getTaxRate())
+ calc += ` (${this.posOrder.getTaxRate()}% tax)`
}
}
return calc
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.