diff options
Diffstat (limited to 'src/routes/(app)/invoice')
| -rw-r--r-- | src/routes/(app)/invoice/[id]/+layout.server.ts | 56 | ||||
| -rw-r--r-- | src/routes/(app)/invoice/[id]/+page.svelte | 43 | ||||
| -rw-r--r-- | src/routes/(app)/invoice/[id]/paid/+page.svelte | 16 | ||||
| -rw-r--r-- | src/routes/(app)/invoice/[id]/tip/+page.server.ts | 48 | ||||
| -rw-r--r-- | src/routes/(app)/invoice/[id]/tip/+page.svelte | 24 |
5 files changed, 132 insertions, 55 deletions
diff --git a/src/routes/(app)/invoice/[id]/+layout.server.ts b/src/routes/(app)/invoice/[id]/+layout.server.ts index 590e5d4d..faabb753 100644 --- a/src/routes/(app)/invoice/[id]/+layout.server.ts +++ b/src/routes/(app)/invoice/[id]/+layout.server.ts @@ -8,32 +8,60 @@ 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}`); const options = !!url.searchParams.get("options"); + let { amount, pending, received } = invoice; + amount = parseInt(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")) { + 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; if ( user && - invoice.uid !== user.id && + invoice.uid !== user?.id && !(url.pathname.includes("tip") || url.pathname.includes("memo")) && !options ) { - redirect(307, `/send/invoice/${id}`); - } - - let { amount, pending, received } = invoice; - amount = parseInt(amount); - subject = invoice.user; - - const paid = - (!amount && (pending || received)) || - (amount > 0 && (pending >= amount || received >= amount)); - if (paid && !url.pathname.endsWith("paid")) { - redirect(307, `/invoice/${id}/paid` + (options ? "?options=true" : "")); + if (invoice.prompt && invoice.tip === null) + redirect(307, `/invoice/${id}/tip`); + else { + redirect(307, `/send/invoice/${id}`); + } } } 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]/+page.svelte b/src/routes/(app)/invoice/[id]/+page.svelte index 0cb5aeac..a2a299bd 100644 --- a/src/routes/(app)/invoice/[id]/+page.svelte +++ b/src/routes/(app)/invoice/[id]/+page.svelte @@ -56,22 +56,24 @@ let subbed = false; - onMount(async () => { - if (browser) { - last.subscribe((v) => { - if (!v || subbed) return; - subbed = true; - send("subscribe", invoice); - }); + $effect(() => { + if (!browser) return; + last.subscribe((v) => { + if (!v || subbed) return; + subbed = true; + send("subscribe", invoice); + }); + }); - if ($amountPrompt && !amount) toggleAmount(); - } + onMount(async () => { + if (!browser) return; + if ($amountPrompt && !amount) toggleAmount(); }); - let update = async () => { + let update = async (body) => { try { ({ id } = await post(`/invoice`, { - invoice, + invoice: body, user: { username, currency }, })); @@ -92,10 +94,12 @@ if (type === types.lightning && !amount && typeof newAmount === "undefined") goto(`/${username}/receive`, { invalidateAll: true, noScroll: true }); else { - if (typeof newAmount !== "undefined") invoice.amount = newAmount; - invoice.address_type = address_type; - invoice.type = type; - await update(); + // Build the POST body from a snapshot + explicit changes. Mutating the + // $derived `invoice` proxy and POSTing it serializes unreliably, leaking + // stale type/amount/hash from a prior interaction (see funk POS bug). + const body = { ...$state.snapshot(invoice), type, address_type }; + if (typeof newAmount !== "undefined") body.amount = newAmount; + await update(body); } settingType = false; }; @@ -111,10 +115,9 @@ settingAmount = false; if (typeof $amountPrompt === "undefined") $amountPrompt = true; - invoice.amount = newAmount; - invoice.tip = 0; + const body = { ...$state.snapshot(invoice), amount: newAmount, tip: 0 }; - await update(); + await update(body); }; let setMemo = async (e) => { @@ -122,8 +125,8 @@ e.stopPropagation(); settingMemo = false; - invoice.memo = memo; - await update(); + const body = { ...$state.snapshot(invoice), memo }; + await update(body); }; let settingMemo = $state(); 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} diff --git a/src/routes/(app)/invoice/[id]/tip/+page.server.ts b/src/routes/(app)/invoice/[id]/tip/+page.server.ts index c3be68f0..851d3f1b 100644 --- a/src/routes/(app)/invoice/[id]/tip/+page.server.ts +++ b/src/routes/(app)/invoice/[id]/tip/+page.server.ts @@ -1,11 +1,36 @@ -import invoice from "$lib/invoice"; import getRates from "$lib/rates"; -import { redirect } from "@sveltejs/kit"; +import { auth, fd, get, post } from "$lib/utils"; +import { fail, redirect } from "@sveltejs/kit"; -export async function load({ parent }) { +export async function load({ cookies, params, parent }) { + const { id } = params; const rates = await getRates(); - const { invoice, subject } = await parent(); + let { invoice, subject, user } = await parent(); if (!invoice.amount) redirect(307, `/invoice/${invoice.id}`); + const pin = cookies.get("pin"); + + if (user) { + const trust = await get("/trust", auth(cookies)); + const trusted = trust.includes(invoice.uid); + if (trusted && (pin || !user.haspin)) { + let p; + try { + if (invoice.tip === null && invoice.user.prompt) { + if (user.tip > 0) { + invoice.tip = Math.round(invoice.amount * (user.tip / 100)); + invoice = await post(`/invoice/${id}`, { invoice }, auth(cookies)); + p = await post("/payments", { ...invoice, pin }, auth(cookies)); + } + } else { + p = await post("/payments", { ...invoice, pin }, auth(cookies)); + } + } catch (e) { + const { message } = e as Error; + fail(400, { message }); + } + if (p) redirect(307, `/sent/${p.id}`); + } + } const rate = rates[subject?.currency]; const invoiceRate = rates[invoice.currency]; @@ -13,5 +38,18 @@ export async function load({ parent }) { } export const actions = { - default: invoice, + default: async ({ cookies, request }) => { + const form = await fd(request); + const invoice = { + tip: form.tip ?? null, + }; + + const { id } = await post( + `/invoice/${form.id}`, + { invoice }, + auth(cookies), + ); + + redirect(307, `/invoice/${id}`); + }, }; diff --git a/src/routes/(app)/invoice/[id]/tip/+page.svelte b/src/routes/(app)/invoice/[id]/tip/+page.svelte index d609d86a..a1ea3586 100644 --- a/src/routes/(app)/invoice/[id]/tip/+page.svelte +++ b/src/routes/(app)/invoice/[id]/tip/+page.svelte @@ -97,19 +97,8 @@ <div class="container px-4 max-w-lg text-center mx-auto"> <form method="POST" use:enhance class="space-y-5"> - <input type="hidden" name="aid" value={aid} /> - <input type="hidden" name="amount" value={amount} /> + <input type="hidden" name="id" value={id} /> <input type="hidden" name="tip" value={tip} /> - <input type="hidden" name="username" value={username} /> - <input type="hidden" name="rate" value={invoice.rate} /> - <input type="hidden" name="prompt" value="false" /> - <input type="hidden" name="type" value={type} /> - <input type="hidden" name="hash" value={hash} /> - <input type="hidden" name="items" value={JSON.stringify(items)} /> - - {#if invoice.memoPrompt} - <input type="hidden" name="memoPrompt" value={invoice.memoPrompt} /> - {/if} <h1 class="text-4xl font-semibold my-8">{$t("invoice.addTipq")}</h1> {#if !showCustomAmount} @@ -127,7 +116,10 @@ <div class="ml-auto text-lg flex gap-2"> <div class="flex items-center"> - <iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300" + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" ></iconify-icon> <div>{s(amount, locale)}</div> </div> @@ -170,12 +162,14 @@ {/if} </div> - <div> + <div class="space-y-2"> <button type="button" class="btn" - onclick={() => (showCustomAmount = true)}>Custom</button + onclick={() => (showCustomAmount = true)} + >{$t("payments.custom")}</button > + <a href={`/invoice/${id}`} class="btn">{$t("payments.cancel")}</a> </div> {/if} |
