diff --git a/helpers.py b/helpers.py index 2d8c3af..7c6fb5a 100644 --- a/helpers.py +++ b/helpers.py @@ -3,6 +3,14 @@ from lnbits.helpers import create_access_token from loguru import logger +# Fiat methods that are settled inside LNbits instead of by a fiat provider. +# The invoice is created as an internal payment and a cashier confirms it +# manually. The chosen value is stored in payment.extra["fiat_method"]. +INTERNAL_FIAT_METHODS = ("cash", "custom") + +# Colour of the account label LNbits attaches to these payments. +INTERNAL_FIAT_LABEL_COLORS = {"cash": "#FFC107", "custom": "#7E57C2"} + def from_csv(value: str | None, separator: str = ",") -> list[str]: if not value: diff --git a/static/components/payment-method-selector.js b/static/components/payment-method-selector.js index fb6e347..c403af5 100644 --- a/static/components/payment-method-selector.js +++ b/static/components/payment-method-selector.js @@ -96,6 +96,22 @@ window.app.component('tpos-payment-method-selector', { +
+ +
+ + +
+
+
str: def _payment_method(payment: Payment) -> str: if payment.extra.get("payment_method"): return str(payment.extra["payment_method"]) - if payment.extra.get("fiat_method") == "cash": - return "cash" + if payment.extra.get("fiat_method") in INTERNAL_FIAT_METHODS: + return str(payment.extra["fiat_method"]) if payment.extra.get("fiat_payment_request", "").startswith("pi_"): return "fiat" return "lightning" diff --git a/templates/tpos/dialogs.html b/templates/tpos/dialogs.html index 821b5ac..fd38986 100644 --- a/templates/tpos/dialogs.html +++ b/templates/tpos/dialogs.html @@ -21,10 +21,10 @@

Waiting for card…

Close
-

CASH ${ currency }

+

${ internalFiatMethodLabel } ${ currency }

${ activePaymentAmountWithTipFormatted }

${ activePaymentAmountFormatted } diff --git a/tests/test_api.py b/tests/test_api.py index 6dce278..5dd4963 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1065,6 +1065,99 @@ async def fake_internal_invoice_queue_put(checking_id): assert latest[0]["payment_method"] == "cash" +@pytest.mark.asyncio +async def test_custom_validate_invoice_endpoint(client: AsyncClient, monkeypatch): + await _drain_internal_invoice_queue() + user, wallet = await _user_with_tabs("customuser") + settings.super_user = user.id + headers = {"X-API-KEY": wallet.adminkey} + create = await client.post( + "/tpos/api/v1/tposs", + json=_tpos_payload(currency="EUR", allow_cash_settlement=True), + headers=headers, + ) + assert create.status_code == 201 + tpos = create.json() + + payment = await create_payment_request( + wallet.id, + CreateInvoice( + unit="sat", + out=False, + amount=10, + memo="Custom sale", + internal=True, + extra={ + "tag": "tpos", + "tpos_id": tpos["id"], + "amount": 10, + "fiat_method": "custom", + "details": { + "currency": "EUR", + "exchangeRate": 1, + "taxValue": 0, + "taxIncluded": True, + "items": [], + }, + }, + ), + ) + await update_payment_checking_id( + payment.checking_id, f"internal_custom_{payment.payment_hash}" + ) + await create_tpos_payment( + TposPayment( + id=uuid4().hex, + tpos_id=tpos["id"], + payment_hash=payment.payment_hash, + amount=10, + payment_method="custom", + ) + ) + + queued_checking_ids = [] + + async def fake_internal_invoice_queue_put(checking_id): + queued_checking_ids.append(checking_id) + + monkeypatch.setattr( + views_payments, "internal_invoice_queue_put", fake_internal_invoice_queue_put + ) + + # the cash route must not accept a custom invoice + wrong_route = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}/invoices/{payment.payment_hash}/cash/validate" + ) + assert wrong_route.status_code == 400 + assert queued_checking_ids == [] + + validated = await client.post( + f"/tpos/api/v1/tposs/{tpos['id']}" + f"/invoices/{payment.payment_hash}/custom/validate" + ) + assert validated.status_code == 200 + assert validated.json() == {"success": True} + assert queued_checking_ids == [f"internal_custom_{payment.payment_hash}"] + + settled_payment = await get_standalone_payment(payment.payment_hash, incoming=True) + assert settled_payment is not None + assert settled_payment.success is True + + poll = await client.get( + f"/tpos/api/v1/tposs/{tpos['id']}" + f"/invoices/{payment.payment_hash}?extra=true" + ) + assert poll.status_code == 200 + assert poll.json()["extra"]["fiat_method"] == "custom" + + await on_invoice_paid(settled_payment) + + latest_response = await client.get(f"/tpos/api/v1/tposs/{tpos['id']}/invoices") + assert latest_response.status_code == 200 + latest = latest_response.json() + assert latest[0]["payment_method"] == "custom" + + @pytest.mark.asyncio async def test_atm_and_lnurl_withdraw_routes(client: AsyncClient, monkeypatch): user, wallet = await _user_with_tabs("atmuser") diff --git a/views_payments.py b/views_payments.py index ff2af05..823b34b 100644 --- a/views_payments.py +++ b/views_payments.py @@ -20,7 +20,11 @@ get_tpos, get_tpos_payment_by_hash, ) -from .helpers import inventory_tags_to_list +from .helpers import ( + INTERNAL_FIAT_LABEL_COLORS, + INTERNAL_FIAT_METHODS, + inventory_tags_to_list, +) from .models import ( CreateTposInvoice, InventorySale, @@ -93,7 +97,12 @@ async def api_tpos_create_invoice( "taxValue": tax_value, } - cash_method = data.pay_in_fiat and data.fiat_method == "cash" + internal_fiat_method = ( + data.fiat_method + if data.pay_in_fiat and data.fiat_method in INTERNAL_FIAT_METHODS + else None + ) + cash_method = internal_fiat_method is not None onchain_method = data.payment_method == "btc_onchain" if cash_method and not tpos.allow_cash_settlement: raise HTTPException( @@ -160,11 +169,17 @@ async def api_tpos_create_invoice( detail="This tpos cannot create cash or onchain invoices.", ) existing = {label.name for label in account.extra.labels or []} - label_name = "cash" if cash_method else "onchain" + label_name = internal_fiat_method if cash_method else "onchain" label_description = ( - "Cash payment" if cash_method else "Onchain payment" + f"{label_name.capitalize()} payment" + if cash_method + else "Onchain payment" + ) + label_color = ( + INTERNAL_FIAT_LABEL_COLORS.get(label_name, "#FFC107") + if cash_method + else "#ED8403" ) - label_color = "#FFC107" if cash_method else "#ED8403" if label_name not in existing: account.extra.labels.append( UserLabel( @@ -192,11 +207,15 @@ async def api_tpos_create_invoice( tpos.fiat_provider if data.pay_in_fiat and not cash_method else None ), internal=bool(cash_method or onchain_method), - labels=["cash"] if cash_method else (["onchain"] if onchain_method else []), + labels=( + [internal_fiat_method] + if internal_fiat_method + else (["onchain"] if onchain_method else []) + ), ) payment = await create_payment_request(tpos.wallet, invoice_data) if cash_method: - new_checking_id = f"internal_cash_{payment.payment_hash}" + new_checking_id = f"internal_{internal_fiat_method}_{payment.payment_hash}" await update_payment_checking_id(payment.checking_id, new_checking_id) payment.checking_id = new_checking_id elif onchain_method: @@ -440,11 +459,14 @@ async def api_tpos_print_invoice( return {"success": True} -@tpos_payments_router.post( - "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}/cash/validate", - status_code=HTTPStatus.OK, -) -async def api_tpos_validate_cash_invoice(tpos_id: str, payment_hash: str): +async def _validate_internal_fiat_invoice( + tpos_id: str, payment_hash: str, fiat_method: str +): + """Mark an internally settled fiat invoice as received. + + Shared by the cash and custom validation routes. The cashier confirms + manually that the amount was received through that channel. + """ tpos = await get_tpos(tpos_id) if not tpos: raise HTTPException( @@ -464,14 +486,15 @@ async def api_tpos_validate_cash_invoice(tpos_id: str, payment_hash: str): raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." ) - if payment.extra.get("fiat_method") != "cash": + if payment.extra.get("fiat_method") != fiat_method: raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Payment is not cash." + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Payment is not {fiat_method}.", ) if not payment.is_internal: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail="Payment is not an internal cash invoice.", + detail=f"Payment is not an internal {fiat_method} invoice.", ) if not payment.success: payment.status = PaymentState.SUCCESS @@ -480,11 +503,27 @@ async def api_tpos_validate_cash_invoice(tpos_id: str, payment_hash: str): return {"success": True} +@tpos_payments_router.post( + "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}/cash/validate", + status_code=HTTPStatus.OK, +) +async def api_tpos_validate_cash_invoice(tpos_id: str, payment_hash: str): + return await _validate_internal_fiat_invoice(tpos_id, payment_hash, "cash") + + +@tpos_payments_router.post( + "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}/custom/validate", + status_code=HTTPStatus.OK, +) +async def api_tpos_validate_custom_invoice(tpos_id: str, payment_hash: str): + return await _validate_internal_fiat_invoice(tpos_id, payment_hash, "custom") + + def _payment_method_from_payment(payment: Payment) -> str: if payment.extra.get("payment_method"): return str(payment.extra["payment_method"]) - if payment.extra.get("fiat_method") == "cash": - return "cash" + if payment.extra.get("fiat_method") in INTERNAL_FIAT_METHODS: + return str(payment.extra["fiat_method"]) if payment.extra.get("fiat_payment_request", "").startswith("pi_"): return "fiat" return "lightning" @@ -495,8 +534,8 @@ def _serialize_tpos_invoice_response( ) -> TposInvoiceResponse: payment_method = _payment_method_from_payment(payment) payment_request = "lightning:" + payment.bolt11.upper() - if payment_method == "cash": - payment_request = "cash" + if payment_method in INTERNAL_FIAT_METHODS: + payment_request = payment_method elif payment.extra.get("fiat_payment_request") and not payment.extra.get( "fiat_payment_request", "" ).startswith("pi_"):