diff options
| author | Adam Soltys <[email protected]> | 2026-09-22 11:23:19 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-22 11:27:15 -0700 |
| commit | 802eea628fb77fed305de555995cf323174b0394 (patch) | |
| tree | 2c9d1a74f5f94dda032b3c346a15f1dff2daffb4 /src | |
| parent | 6735d9ec73949046932716dba90ebd9cd63b513d (diff) | |
| download | coinos-ui-802eea628fb77fed305de555995cf323174b0394.tar.gz coinos-ui-802eea628fb77fed305de555995cf323174b0394.zip | |
invoice: a reusable bolt12 offer has no terminal paid state
A bolt12 offer is a standing payment code. CLN derives the same offer_id
from the same (amount, description), so re-creating one hands back the
original record, whose `received` has accumulated over the offer's whole
lifetime. The invoice layout read that as "this invoice was paid" and
redirected to the success screen, so reopening a long-lived offer never
showed the QR and left no way to copy the lno.
Treat a payment as celebratory only when it arrives while the page is
open: the socket handler now passes the amount of that payment as
`?paid=<amount>`, and the layout uses it in place of the lifetime total
for reusable offers. Single-use invoices keep the existing logic, so a
partly-paid invoice still stays on the QR rather than celebrating early.
The success screen showed lifetime `received` for the same reason, which
misreported a single payment to a long-lived offer; it now renders the
amount the socket reported, which is already net of tip.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/socket.ts | 7 | ||||
| -rw-r--r-- | src/routes/(app)/invoice/[id]/+layout.server.ts | 34 | ||||
| -rw-r--r-- | src/routes/(app)/invoice/[id]/paid/+page.svelte | 16 |
3 files changed, 50 insertions, 7 deletions
diff --git a/src/lib/socket.ts b/src/lib/socket.ts index b87cc165..b28b1060 100644 --- a/src/lib/socket.ts +++ b/src/lib/socket.ts @@ -57,7 +57,12 @@ export const messages = (data) => ({ pathname.includes("/receive") || pathname.includes("/invoice") ) { - if (!get(navigating)) goto(`/invoice/${iid}`); + // `paid=<amount>` marks this as a payment that arrived just now, and + // carries the amount of THIS payment. A reusable bolt12 offer needs + // both: its `received` is a lifetime total, so the invoice layout + // can't tell a live payment from an old one, and the success screen + // would otherwise show the accumulated figure. + if (!get(navigating)) goto(`/invoice/${iid}?paid=${amount}`); } else success(`${confirmed ? "Received" : "Detected"} ⚡️${s(amount)}!`); } }, diff --git a/src/routes/(app)/invoice/[id]/+layout.server.ts b/src/routes/(app)/invoice/[id]/+layout.server.ts index ab41dddc..faabb753 100644 --- a/src/routes/(app)/invoice/[id]/+layout.server.ts +++ b/src/routes/(app)/invoice/[id]/+layout.server.ts @@ -8,6 +8,10 @@ export async function load({ cookies, depends, params, url, parent }) { let { subject, user } = await parent(); const { id } = params; let invoice; + // Amount of a payment that arrived while the page was open, set by the + // socket handler. Only meaningful for reusable (bolt12) offers, whose + // `received` is a lifetime total rather than this payment. + let justPaid: string | null = null; if (id) { invoice = await get(`/invoice/${id}`); @@ -15,11 +19,31 @@ export async function load({ cookies, depends, params, url, parent }) { let { amount, pending, received } = invoice; amount = parseInt(amount); - const paid = - (!amount && (pending || received)) || - (amount > 0 && (pending >= amount || received >= amount)); + // A bolt12 offer is a standing, REUSABLE payment code: CLN derives the same + // offer_id from the same (amount, description), so re-creating one hands back + // the original record with `received` accumulated over its entire lifetime. + // Treating that as "paid" bounces the user straight to the success screen so + // they never get to see the QR or copy the lno — and a long-lived offer that + // takes regular payouts is effectively unusable, since its last payment may + // be hours or months old. A standing offer has no terminal paid state, so + // only a payment arriving live counts — the socket handler signals that + // with `?paid=<amount>`. + const reusable = invoice.type === "bolt12"; + justPaid = url.searchParams.get("paid"); + + const paid = reusable + ? !!justPaid + : (!amount && (pending || received)) || + (amount > 0 && (pending >= amount || received >= amount)); + if (paid && !url.pathname.endsWith("paid")) { - redirect(307, `/invoice/${id}/paid` + (options ? "?options=true" : "")); + const q = new URLSearchParams(); + if (options) q.set("options", "true"); + // Carry the amount of THIS payment through; the success screen would + // otherwise render lifetime `received` for a reusable offer. + if (justPaid) q.set("paid", justPaid); + const qs = q.toString(); + redirect(307, `/invoice/${id}/paid${qs ? `?${qs}` : ""}`); } subject = invoice.user; @@ -39,5 +63,5 @@ export async function load({ cookies, depends, params, url, parent }) { } const theme = cookies.get("theme") || "light"; - return { id, invoice, subject, user, token, theme }; + return { id, invoice, subject, user, token, theme, justPaid }; } diff --git a/src/routes/(app)/invoice/[id]/paid/+page.svelte b/src/routes/(app)/invoice/[id]/paid/+page.svelte index bc61d7fe..8e5bda27 100644 --- a/src/routes/(app)/invoice/[id]/paid/+page.svelte +++ b/src/routes/(app)/invoice/[id]/paid/+page.svelte @@ -10,6 +10,20 @@ ); let locale = $derived(loc(user)); + // `received` is the invoice's running total. For a reusable bolt12 offer that + // is the LIFETIME figure across every payment the offer has ever taken, so + // showing it here would announce the accumulated total rather than the payment + // that just arrived. For those, show the amount the socket reported — it is + // already net of tip, the same basis as `received - tip`. Single-use invoices + // keep using `received` so a top-up onto a partly-paid invoice still totals + // correctly. + let reusable = $derived(data.invoice?.type === "bolt12"); + let settled = $derived( + reusable && data.justPaid + ? Number.parseInt(data.justPaid) + (tip ?? 0) + : received, + ); + // toast.pop(0); </script> @@ -27,7 +41,7 @@ <Amount amount={pending - tip} {tip} {rate} {currency} {locale} /> {:else} <Success - amount={received - tip} + amount={settled - tip} {rate} {tip} {currency} |
