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: