aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes/(app)/invoice
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes/(app)/invoice')
-rw-r--r--src/routes/(app)/invoice/[id]/+layout.server.ts56
-rw-r--r--src/routes/(app)/invoice/[id]/+page.svelte43
-rw-r--r--src/routes/(app)/invoice/[id]/paid/+page.svelte16
-rw-r--r--src/routes/(app)/invoice/[id]/tip/+page.server.ts48
-rw-r--r--src/routes/(app)/invoice/[id]/tip/+page.svelte24
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}