Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions static/components/payment-method-selector.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ window.app.component('tpos-payment-method-selector', {
</div>
</q-btn>
</div>
<div class="col-6" v-if="allowCashSettlement && currency != 'sats'">
<q-btn
class="full-width q-px-lg q-py-sm"
:size="drawer ? 'lg' : 'xl'"
color="secondary"
rounded
:disable="disabled"
:aria-label="'Custom ' + currency"
@click="$emit('select', 'custom')"
>
<div class="row items-center no-wrap q-gutter-x-xs">
<span class="text-h5 text-weight-bold" v-text="currencySymbol"></span>
<q-icon name="more_horiz" size="30px"></q-icon>
</div>
</q-btn>
</div>
<div class="col-6" v-if="tabsEnabled && !isSettlingTab">
<q-btn
class="full-width q-px-lg q-py-sm"
Expand Down
17 changes: 16 additions & 1 deletion static/js/tpos.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,15 @@ window.app = Vue.createApp({
}
},
computed: {
// 'cash' or 'custom' while an internally settled fiat invoice is open
internalFiatMethod() {
const request =
this.invoiceDialog.data && this.invoiceDialog.data.payment_request
return request === 'cash' || request === 'custom' ? request : null
},
internalFiatMethodLabel() {
return (this.internalFiatMethod || 'cash').toUpperCase()
},
activePaymentAmount() {
return this.paymentAmount !== null ? this.paymentAmount : this.amount
},
Expand Down Expand Up @@ -410,6 +419,7 @@ window.app = Vue.createApp({
lightning_payment_request:
paymentRequest &&
paymentRequest !== 'cash' &&
paymentRequest !== 'custom' &&
paymentRequest !== 'tap_to_pay'
? paymentRequest
: null,
Expand All @@ -425,6 +435,7 @@ window.app = Vue.createApp({
? null
: paymentData.payment_request &&
paymentData.payment_request !== 'cash' &&
paymentData.payment_request !== 'custom' &&
paymentData.payment_request !== 'tap_to_pay'
? paymentData.payment_request
: paymentData.bolt11
Expand Down Expand Up @@ -1018,6 +1029,9 @@ window.app = Vue.createApp({
case 'cash':
this.fiatMethod = 'cash'
return 'fiat'
case 'custom':
this.fiatMethod = 'custom'
return 'fiat'
case 'btc':
case 'btc_onchain':
case 'tab':
Expand Down Expand Up @@ -1368,11 +1382,12 @@ window.app = Vue.createApp({
async validateCashInvoice() {
const paymentHash = this.invoiceDialog.data.payment_hash
if (!paymentHash || this.cashValidating) return
const method = this.internalFiatMethod || 'cash'
this.cashValidating = true
try {
await LNbits.api.request(
'POST',
`/tpos/api/v1/tposs/${this.tposId}/invoices/${paymentHash}/cash/validate`
`/tpos/api/v1/tposs/${this.tposId}/invoices/${paymentHash}/${method}/validate`
)
} catch (error) {
LNbits.utils.notifyApiError(error)
Expand Down
5 changes: 3 additions & 2 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
get_tpos_payment_by_hash,
update_tpos_payment,
)
from .helpers import INTERNAL_FIAT_METHODS
from .services import ensure_tpos_tabs_access
from .services_inventory import deduct_inventory_stock
from .services_onchain import fetch_onchain_balance
Expand Down Expand Up @@ -243,8 +244,8 @@ def _tabs_settlement_method(payment_method: str, payment: Payment) -> 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"
Expand Down
4 changes: 2 additions & 2 deletions templates/tpos/dialogs.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ <h3>Waiting for card…</h3>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
<div
v-else-if="invoiceDialog.data.payment_request == 'cash'"
v-else-if="invoiceDialog.data.payment_request == 'cash' || invoiceDialog.data.payment_request == 'custom'"
class="text-center q-mb-xl"
>
<h3>CASH ${ currency }</h3>
<h3>${ internalFiatMethodLabel } ${ currency }</h3>
<h3 class="q-my-md">${ activePaymentAmountWithTipFormatted }</h3>
<h5 class="q-mt-none q-mb-sm">
${ activePaymentAmountFormatted }
Expand Down
93 changes: 93 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
77 changes: 58 additions & 19 deletions views_payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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_"):
Expand Down