diff --git a/.gitignore b/.gitignore index 6cdca95..eda6e4d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ node_modules .venv .codex +.claude +AGENTS.md +CLAUDE.md # test artifacts data/ diff --git a/Makefile b/Makefile index 5df1b8c..2a2a605 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,7 @@ test: teststatic teststatic: node tests/check_static_load.js + node tests/test_tax.js test-e2e: "$(abspath ../../../node_modules/.bin/playwright)" test --config "$(CURDIR)/tests/e2e/playwright.config.ts" diff --git a/static/js/tpos.js b/static/js/tpos.js index 1a8a77b..de6783f 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -631,7 +631,11 @@ window.app = Vue.createApp({ for (let item of this.cart.values()) { let tax = item.tax || this.taxDefault if (tax > 0) { - total += item.price * item.quantity * (tax * 0.01) + const gross = item.price * item.quantity + const taxRate = tax * 0.01 + total += this.taxInclusive + ? (gross * taxRate) / (1 + taxRate) + : gross * taxRate } } this.cartTax = roundTposCurrencyAmount(total, this.currency) diff --git a/tests/test_api.py b/tests/test_api.py index 3df8632..6dce278 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -154,6 +154,35 @@ async def test_tpos_crud_settings_and_wrapper_token(client: AsyncClient): assert await get_tpos(tpos["id"]) is None +@pytest.mark.asyncio +async def test_manual_invoice_tax_value_extracts_inclusive_tax( + client: AsyncClient, +): + _user, wallet = await _user_with_tabs("manualtaxtest") + headers = {"X-API-KEY": wallet.adminkey} + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(currency="EUR", tax_default=21), + headers=headers, + ) + assert create.status_code == 201 + tpos = create.json() + + response = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices", + json={"amount": 350, "exchange_rate": 100}, + ) + assert response.status_code == 201 + + payment = await get_standalone_payment( + response.json()["payment_hash"], incoming=True + ) + assert payment is not None + details = payment.extra["details"] + assert details["taxIncluded"] is True + assert details["taxValue"] == pytest.approx(3.5 * 0.21 / 1.21) + + @pytest.mark.asyncio async def test_tabs_endpoints_use_real_tabs_api(client: AsyncClient): _user, wallet = await _user_with_tabs("tabsuser") diff --git a/tests/test_tax.js b/tests/test_tax.js new file mode 100644 index 0000000..95f318c --- /dev/null +++ b/tests/test_tax.js @@ -0,0 +1,100 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') + +const root = path.resolve(__dirname, '..') +const sandbox = { + Intl, + Math, + Number, + Object, + Array, + Date, + JSON, + Map, + Set, + Promise, + console, + windowMixin: {}, + tpos: {}, + i18n: {global: {locale: 'en-US'}}, + Vue: { + createApp(config) { + return config + } + } +} +sandbox.window = sandbox +sandbox.globalThis = sandbox + +const context = vm.createContext(sandbox) +for (const file of ['static/js/tpos-utils.js', 'static/js/tpos.js']) { + vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, { + filename: file + }) +} + +const cartTaxTotal = sandbox.app.methods.cartTaxTotal + +function taxTotal({items, taxDefault = 0, taxInclusive}) { + const state = { + cart: new Map(items.map((item, index) => [item.id || index, item])), + taxDefault, + taxInclusive, + currency: 'EUR', + cartTax: 0 + } + cartTaxTotal.call(state) + return state.cartTax +} + +assert.equal( + taxTotal({ + items: [{price: 3.5, quantity: 1, tax: 21}], + taxInclusive: true + }), + 0.61 +) +assert.equal( + taxTotal({ + items: [{price: 3.5, quantity: 1, tax: 21}], + taxInclusive: false + }), + 0.74 +) +assert.equal( + taxTotal({ + items: [{price: 3.5, quantity: 2, tax: 21}], + taxInclusive: true + }), + 1.21 +) +assert.equal( + taxTotal({ + items: [ + {price: 10, quantity: 1, tax: 10}, + {price: 5, quantity: 2, tax: 20} + ], + taxInclusive: true + }), + 2.58 +) +assert.equal( + taxTotal({ + items: [{price: 3.5, quantity: 1, tax: 0}], + taxDefault: 0, + taxInclusive: true + }), + 0 +) +assert.equal( + taxTotal({ + items: [{price: 3.5, quantity: 1}], + taxDefault: 21, + taxInclusive: true + }), + 0.61 +) + +console.log('Tax calculation tests passed') diff --git a/views_payments.py b/views_payments.py index fb2abe4..ff2af05 100644 --- a/views_payments.py +++ b/views_payments.py @@ -81,12 +81,10 @@ async def api_tpos_create_invoice( if not data.details: tax_value = 0.0 - if tpos.tax_default: - tax_value = ( - (data.amount / data.exchange_rate) * (tpos.tax_default * 0.01) - if data.exchange_rate - else 0.0 - ) + if tpos.tax_default and data.exchange_rate: + gross_amount = data.amount / data.exchange_rate + tax_rate = tpos.tax_default * 0.01 + tax_value = (gross_amount * tax_rate) / (1 + tax_rate) data.details = { "currency": tpos.currency, "exchangeRate": data.exchange_rate,