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_"):