diff --git a/services.py b/services.py index 310fb85..c27400e 100644 --- a/services.py +++ b/services.py @@ -36,6 +36,16 @@ async def ensure_tpos_tabs_access(tpos: Tpos) -> str: return user_id +async def tpos_fiat_as_satoshis(amount: float, currency: str) -> int: + try: + return await fiat_amount_as_satoshis(amount, currency) + except ValueError as exc: + raise HTTPException( + HTTPStatus.SERVICE_UNAVAILABLE, + "Exchange rate not available. Please try again later.", + ) from exc + + async def validate_tpos_sale_amount( tpos: Tpos, data: CreateTposInvoice, amount: float, *, minimum: bool = False ) -> None: @@ -56,7 +66,7 @@ async def validate_tpos_sale_amount( ) if minimum: amount = max(supplied, amount) - data.amount = await fiat_amount_as_satoshis(amount, tpos.currency) + data.amount = await tpos_fiat_as_satoshis(amount, tpos.currency) if data.amount <= 0: raise HTTPException(HTTPStatus.BAD_REQUEST, "Sale amount is below one satoshi.") data.amount_fiat = amount diff --git a/static/js/tpos.js b/static/js/tpos.js index 99f772e..8b3cede 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -29,7 +29,7 @@ window.app = Vue.createApp({ withdrawMaximum: 0, tip_options: null, exchangeRateLoaded: false, - exchangeRate: 1, + exchangeRate: 0, stack: [], tipAmount: 0.0, tipRounding: null, @@ -1687,6 +1687,7 @@ window.app = Vue.createApp({ } } catch (e) { console.error(e) + rate = 0 } finally { Quasar.Loading.hide() this.exchangeRate = rate @@ -1701,15 +1702,18 @@ window.app = Vue.createApp({ let last = [...res.data] this.lastPaymentsDialog.data = last.map(obj => { obj.dateFrom = moment(obj.time).fromNow() - if (obj.currency != g.settings.denomination) { + const paidRate = obj.exchange_rate || this.exchangeRate + if (obj.currency != g.settings.denomination && paidRate) { obj.amountFiat = this.formatAmount( - obj.amount / 1000 / (obj.exchange_rate || this.exchangeRate), - this.currency - ) - obj.amountFiatNow = this.formatAmount( - Math.abs(obj.amount) / 1000 / this.exchangeRate, + obj.amount / 1000 / paidRate, this.currency ) + obj.amountFiatNow = this.exchangeRate + ? this.formatAmount( + Math.abs(obj.amount) / 1000 / this.exchangeRate, + this.currency + ) + : null } return obj }) diff --git a/templates/tpos/dialogs.html b/templates/tpos/dialogs.html index 4434caa..83fa785 100644 --- a/templates/tpos/dialogs.html +++ b/templates/tpos/dialogs.html @@ -180,6 +180,7 @@
diff --git a/tests/test_api.py b/tests/test_api.py index 8ad846c..995608c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -25,6 +25,7 @@ get_tab_settlements, ) +import tpos.services as services # type: ignore[import] import tpos.tasks as tpos_tasks # type: ignore[import] import tpos.views_api as views_api # type: ignore[import] import tpos.views_atm as views_atm # type: ignore[import] @@ -478,6 +479,9 @@ async def fake_websocket_updater(channel, message): sent_messages.append((channel, json.loads(message))) monkeypatch.setattr(views_payments, "websocket_updater", fake_websocket_updater) + monkeypatch.setattr( + services, "fiat_amount_as_satoshis", AsyncMock(return_value=36800) + ) invoice_response = await client.post( f"/tpos/api/v1/tposs/{tpos['id']}/invoices", json={ diff --git a/tests/test_security.py b/tests/test_security.py index aceb360..d4c8eb6 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -492,6 +492,68 @@ async def convert(value, _currency): assert (await client.get(f"{base}/tabs")).json()["data"] == [] +@pytest.mark.asyncio +async def test_fiat_keypad_sale_uses_server_rate(client, monkeypatch): + _, _, pos = await terminal(client, currency="BRL") + url = f"/tpos/api/v1/tposs/{pos['id']}/invoices" + + async def convert(value, _currency): + return int(Decimal(str(value)) * 229) + + monkeypatch.setattr(services, "fiat_amount_as_satoshis", convert) + # A client whose rate fetch failed and fell back to 1 sat per BRL (#264). + data = { + "amount": 15, + "amount_fiat": 14.45, + "exchange_rate": 1, + } + created = await client.post(url, json=data) + assert created.status_code == 201, created.text + extra = created.json()["extra"] + assert extra["amount"] == 3309 + assert extra["exchangeRate"] == pytest.approx(3309 / 14.45) + assert extra["details"]["exchangeRate"] == pytest.approx(3309 / 14.45) + + async def no_rate(_value, currency): + raise ValueError(f"Could not get exchange rate for {currency}.") + + monkeypatch.setattr(services, "fiat_amount_as_satoshis", no_rate) + unavailable = await client.post(url, json=data) + assert unavailable.status_code == 503 + assert "Exchange rate not available" in unavailable.text + + +@pytest.mark.asyncio +async def test_fiat_keypad_sale_tax_uses_repriced_amount(client, monkeypatch): + _, _, pos = await terminal(client, currency="BRL", tax_default=10) + + async def convert(value, _currency): + return int(Decimal(str(value)) * 229) + + monkeypatch.setattr(services, "fiat_amount_as_satoshis", convert) + created = await client.post( + f"/tpos/api/v1/tposs/{pos['id']}/invoices", + json={"amount": 15, "amount_fiat": 14.45, "exchange_rate": 1}, + ) + assert created.status_code == 201, created.text + details = created.json()["extra"]["details"] + assert details["taxValue"] == pytest.approx(14.45 * 0.1 / 1.1) + + +@pytest.mark.asyncio +async def test_sats_keypad_sale_amount_is_unchanged(client, monkeypatch): + _, _, pos = await terminal(client) + convert = AsyncMock(side_effect=AssertionError("no conversion for sats")) + monkeypatch.setattr(services, "fiat_amount_as_satoshis", convert) + created = await client.post( + f"/tpos/api/v1/tposs/{pos['id']}/invoices", + json={"amount": 21, "tip_amount": 3}, + ) + assert created.status_code == 201, created.text + assert created.json()["extra"]["amount"] == 21 + assert created.json()["extra"]["tip_amount"] == 3 + + @pytest.mark.asyncio async def test_fractional_sats_tab_balance_rounds_up_without_changing_balance(client): _, _, pos = await terminal(client) diff --git a/views_payments.py b/views_payments.py index 6c12081..1db049d 100644 --- a/views_payments.py +++ b/views_payments.py @@ -87,6 +87,16 @@ async def api_tpos_create_invoice( detail="Provided tags are not allowed for this TPoS.", ) + if ( + tpos.currency.lower() != "sats" + and data.amount_fiat is not None + and not (data.tab_settlement or inventory_payload or data.pay_in_fiat) + ): + # Keypad sales: price the sats on the server, so a client that fell back + # to a wrong exchange rate can't undercharge. Runs before the + # default details, so their tax uses the repriced amount. + await validate_tpos_sale_amount(tpos, data, data.amount_fiat) + if not data.details: tax_value = 0.0 if tpos.tax_default and data.exchange_rate: