aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-22 11:23:19 -0700
committerAdam Soltys <[email protected]>2026-09-22 11:27:15 -0700
commit802eea628fb77fed305de555995cf323174b0394 (patch)
tree2c9d1a74f5f94dda032b3c346a15f1dff2daffb4 /src
parent6735d9ec73949046932716dba90ebd9cd63b513d (diff)
downloadcoinos-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.ts7
-rw-r--r--src/routes/(app)/invoice/[id]/+layout.server.ts34
-rw-r--r--src/routes/(app)/invoice/[id]/paid/+page.svelte16
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}