From 8d52553b9eb942c7f16560c2854f42cd5d37e58e Mon Sep 17 00:00:00 2001 From: v0l Date: Tue, 14 Jul 2026 19:44:02 +0100 Subject: [PATCH 1/2] feat: saved payment methods management UI Replaces the single NWC connection field on the account settings page with a payment-methods manager backed by the new /api/v1/payment-methods API: - List saved methods (Lightning wallet / card with brand, last4, expiry) - Set default, enable/disable, and remove methods - Add a Nostr Wallet Connect wallet - Flag expired cards The NWC payment option in the VM/subscription payment flows now shows based on whether the user has a saved NWC method (the account nwc_connection_string field was removed from the API). Companion to LNVPS/api#160. --- src/api.ts | 48 ++++- src/components/payment-methods.tsx | 201 +++++++++++++++++++ src/components/subscription-payment-flow.tsx | 15 +- src/components/vm-payment-flow.tsx | 11 +- src/pages/account-settings.tsx | 17 +- 5 files changed, 266 insertions(+), 26 deletions(-) create mode 100644 src/components/payment-methods.tsx diff --git a/src/api.ts b/src/api.ts index b333533..f033c73 100644 --- a/src/api.ts +++ b/src/api.ts @@ -138,7 +138,20 @@ export interface AccountDetail { state?: string; postcode?: string; tax_id?: string; - nwc_connection_string?: string; +} + +/** A saved payment method for automatic renewals */ +export interface SavedPaymentMethod { + id: number; + /** Payment processor: 'nwc' or 'revolut' */ + provider: string; + created: string; + card_brand?: string; + card_last_four?: string; + exp_month?: number; + exp_year?: number; + is_default: boolean; + enabled: boolean; } /** Which notification channels are configured on the server */ @@ -648,6 +661,39 @@ export class LNVpsApi { return data; } + async listPaymentMethods() { + const { data } = await this.#handleResponse< + ApiResponse> + >(await this.#req("/api/v1/payment-methods", "GET")); + return data; + } + + async addNwcPaymentMethod(nwc_connection_string: string) { + const { data } = await this.#handleResponse>( + await this.#req("/api/v1/payment-methods", "POST", { + nwc_connection_string, + }), + ); + return data; + } + + async updatePaymentMethod( + id: number, + patch: { is_default?: boolean; enabled?: boolean }, + ) { + const { data } = await this.#handleResponse>( + await this.#req(`/api/v1/payment-methods/${id}`, "PATCH", patch), + ); + return data; + } + + async deletePaymentMethod(id: number) { + const { data } = await this.#handleResponse>( + await this.#req(`/api/v1/payment-methods/${id}`, "DELETE"), + ); + return data; + } + async listVms() { const { data } = await this.#handleResponse>>( await this.#req("/api/v1/vm", "GET"), diff --git a/src/components/payment-methods.tsx b/src/components/payment-methods.tsx new file mode 100644 index 0000000..268e7c5 --- /dev/null +++ b/src/components/payment-methods.tsx @@ -0,0 +1,201 @@ +import { useCallback, useEffect, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import useLogin from "../hooks/login"; +import { SavedPaymentMethod } from "../api"; +import { AsyncButton } from "./button"; + +/** Human-readable label for a saved payment method. */ +function methodLabel(m: SavedPaymentMethod): string { + if (m.provider === "nwc") return "Lightning Wallet (NWC)"; + if (m.provider === "revolut") { + const brand = m.card_brand ?? "Card"; + const last4 = m.card_last_four ? ` •••• ${m.card_last_four}` : ""; + return `${brand}${last4}`; + } + return m.provider; +} + +function expiryLabel(m: SavedPaymentMethod): string | undefined { + if (m.exp_month && m.exp_year) { + const mm = String(m.exp_month).padStart(2, "0"); + return `${mm}/${String(m.exp_year).slice(-2)}`; + } + return undefined; +} + +function isExpired(m: SavedPaymentMethod): boolean { + if (!m.exp_month || !m.exp_year) return false; + const now = new Date(); + const y = now.getFullYear(); + const mo = now.getMonth() + 1; + return m.exp_year < y || (m.exp_year === y && m.exp_month < mo); +} + +/** + * Manage saved payment methods used for automatic renewals: list, choose the + * default, enable/disable, delete, and add a Nostr Wallet Connect wallet. + */ +export function PaymentMethods() { + const login = useLogin(); + const { formatMessage } = useIntl(); + const [methods, setMethods] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [nwc, setNwc] = useState(""); + + const reload = useCallback(async () => { + if (!login?.api) return; + setLoading(true); + try { + setMethods(await login.api.listPaymentMethods()); + setError(undefined); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [login?.api]); + + useEffect(() => { + reload(); + }, [reload]); + + async function setDefault(id: number) { + if (!login?.api) return; + await login.api.updatePaymentMethod(id, { is_default: true }); + await reload(); + } + + async function toggleEnabled(m: SavedPaymentMethod) { + if (!login?.api) return; + await login.api.updatePaymentMethod(m.id, { enabled: !m.enabled }); + await reload(); + } + + async function remove(id: number) { + if (!login?.api) return; + await login.api.deletePaymentMethod(id); + await reload(); + } + + async function addNwc() { + if (!login?.api) return; + const value = nwc.trim(); + if (!value) return; + try { + await login.api.addNwcPaymentMethod(value); + setNwc(""); + setError(undefined); + await reload(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( +
+ {error &&
{error}
} + + {loading ? ( +
+ +
+ ) : methods.length === 0 ? ( +
+ +
+ ) : ( +
    + {methods.map((m) => { + const expired = isExpired(m); + const exp = expiryLabel(m); + return ( +
  • +
    +
    + + {methodLabel(m)} + + {m.is_default && ( + + + + )} + {!m.enabled && ( + + + + )} + {expired && ( + + + + )} +
    + {exp && ( + + + + )} +
    +
    + {!m.is_default && m.enabled && !expired && ( + setDefault(m.id)} + > + + + )} + toggleEnabled(m)} + > + {m.enabled ? ( + + ) : ( + + )} + + remove(m.id)}> + + +
    +
  • + ); + })} +
+ )} + +
+ +
+ setNwc(e.target.value)} + /> + + + +
+

+ {formatMessage({ + defaultMessage: + "Cards are saved automatically when you pay with Revolut and automatic renewal is enabled.", + })} +

+
+
+ ); +} diff --git a/src/components/subscription-payment-flow.tsx b/src/components/subscription-payment-flow.tsx index 864932d..c29d2de 100644 --- a/src/components/subscription-payment-flow.tsx +++ b/src/components/subscription-payment-flow.tsx @@ -53,6 +53,7 @@ export default function SubscriptionPaymentFlow({ const [error, setError] = useState(); const [account, setAccount] = useState(); const [autoRenewalEnabled, setAutoRenewalEnabled] = useState(false); + const [hasNwc, setHasNwc] = useState(false); useEffect(() => { if (!login?.api) return; @@ -60,6 +61,12 @@ export default function SubscriptionPaymentFlow({ .getAccount() .then(setAccount) .catch((e) => console.error("Failed to load account info:", e)); + login.api + .listPaymentMethods() + .then((m) => + setHasNwc(m.some((x) => x.provider === "nwc" && x.enabled)), + ) + .catch((e) => console.error("Failed to load payment methods:", e)); }, [login?.api]); // Load the subscription so we know whether to save the card for off-session @@ -154,12 +161,8 @@ export default function SubscriptionPaymentFlow({ function renderPaymentMethod(method: PaymentMethod) { // No LNURL renewal endpoint exists for subscriptions. if (method.name === "lnurl") return null; - // Hide NWC unless the account has a connection string configured. - if ( - method.name === "nwc" && - (!account?.nwc_connection_string || - account.nwc_connection_string.trim() === "") - ) { + // Hide NWC unless the user has a saved NWC payment method. + if (method.name === "nwc" && !hasNwc) { return null; } diff --git a/src/components/vm-payment-flow.tsx b/src/components/vm-payment-flow.tsx index cceef86..c2270cd 100644 --- a/src/components/vm-payment-flow.tsx +++ b/src/components/vm-payment-flow.tsx @@ -47,6 +47,7 @@ export default function VmPaymentFlow({ const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [account, setAccount] = useState(); + const [hasNwc, setHasNwc] = useState(false); const [intervals, setIntervals] = useState(1); const intervalsRef = useRef(intervals); intervalsRef.current = intervals; @@ -57,6 +58,8 @@ export default function VmPaymentFlow({ try { const accountData = await login.api.getAccount(); setAccount(accountData); + const pms = await login.api.listPaymentMethods(); + setHasNwc(pms.some((x) => x.provider === "nwc" && x.enabled)); } catch (e) { console.error("Failed to load account info:", e); } @@ -136,12 +139,8 @@ export default function VmPaymentFlow({ }, [paymentMethod, payment, loading, createPayment]); function renderPaymentMethod(method: PaymentMethod) { - // Filter out NWC method when user has no NWC connection configured - if ( - method.name === "nwc" && - (!account?.nwc_connection_string || - account.nwc_connection_string.trim() === "") - ) { + // Filter out NWC method when the user has no saved NWC payment method + if (method.name === "nwc" && !hasNwc) { return null; } diff --git a/src/pages/account-settings.tsx b/src/pages/account-settings.tsx index 9b3b8cc..6c27f49 100644 --- a/src/pages/account-settings.tsx +++ b/src/pages/account-settings.tsx @@ -2,6 +2,7 @@ import useLogin from "../hooks/login"; import { ReactNode, useEffect, useState } from "react"; import { AccountDetail, NotificationChannels } from "../api"; import { AsyncButton } from "../components/button"; +import { PaymentMethods } from "../components/payment-methods"; import { default as iso } from "iso-3166-1"; import classNames from "classnames"; import { FormattedMessage } from "react-intl"; @@ -230,22 +231,12 @@ export function AccountSettings() { } + title={} description={ - + } > - } - > - update({ nwc_connection_string: e.target.value })} - /> - + Date: Tue, 14 Jul 2026 19:50:02 +0100 Subject: [PATCH 2/2] feat: user-defined names for saved payment methods Show and edit an optional label per saved method (rename inline, and set a label when adding an NWC wallet) so users can distinguish multiple methods. Refs LNVPS/api#85 --- src/api.ts | 7 +++- src/components/payment-methods.tsx | 66 ++++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/api.ts b/src/api.ts index f033c73..a468c3e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -145,6 +145,8 @@ export interface SavedPaymentMethod { id: number; /** Payment processor: 'nwc' or 'revolut' */ provider: string; + /** Optional user-defined label */ + name?: string; created: string; card_brand?: string; card_last_four?: string; @@ -668,10 +670,11 @@ export class LNVpsApi { return data; } - async addNwcPaymentMethod(nwc_connection_string: string) { + async addNwcPaymentMethod(nwc_connection_string: string, name?: string) { const { data } = await this.#handleResponse>( await this.#req("/api/v1/payment-methods", "POST", { nwc_connection_string, + name, }), ); return data; @@ -679,7 +682,7 @@ export class LNVpsApi { async updatePaymentMethod( id: number, - patch: { is_default?: boolean; enabled?: boolean }, + patch: { is_default?: boolean; enabled?: boolean; name?: string | null }, ) { const { data } = await this.#handleResponse>( await this.#req(`/api/v1/payment-methods/${id}`, "PATCH", patch), diff --git a/src/components/payment-methods.tsx b/src/components/payment-methods.tsx index 268e7c5..4657011 100644 --- a/src/components/payment-methods.tsx +++ b/src/components/payment-methods.tsx @@ -4,8 +4,8 @@ import useLogin from "../hooks/login"; import { SavedPaymentMethod } from "../api"; import { AsyncButton } from "./button"; -/** Human-readable label for a saved payment method. */ -function methodLabel(m: SavedPaymentMethod): string { +/** Default (provider-derived) label for a saved payment method. */ +function defaultLabel(m: SavedPaymentMethod): string { if (m.provider === "nwc") return "Lightning Wallet (NWC)"; if (m.provider === "revolut") { const brand = m.card_brand ?? "Card"; @@ -42,6 +42,9 @@ export function PaymentMethods() { const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [nwc, setNwc] = useState(""); + const [nwcName, setNwcName] = useState(""); + const [editingId, setEditingId] = useState(); + const [editingName, setEditingName] = useState(""); const reload = useCallback(async () => { if (!login?.api) return; @@ -83,8 +86,9 @@ export function PaymentMethods() { const value = nwc.trim(); if (!value) return; try { - await login.api.addNwcPaymentMethod(value); + await login.api.addNwcPaymentMethod(value, nwcName.trim() || undefined); setNwc(""); + setNwcName(""); setError(undefined); await reload(); } catch (e) { @@ -92,6 +96,14 @@ export function PaymentMethods() { } } + async function saveName(id: number) { + if (!login?.api) return; + await login.api.updatePaymentMethod(id, { name: editingName.trim() || null }); + setEditingId(undefined); + setEditingName(""); + await reload(); + } + return (
{error &&
{error}
} @@ -117,7 +129,7 @@ export function PaymentMethods() {
- {methodLabel(m)} + {m.name?.trim() || defaultLabel(m)} {m.is_default && ( @@ -135,16 +147,47 @@ export function PaymentMethods() { )}
- {exp && ( - + + {/* Show the derived label as a subtitle when a custom name is set */} + {m.name?.trim() ? `${defaultLabel(m)}` : null} + {m.name?.trim() && exp ? " · " : null} + {exp ? ( - + ) : null} + + {editingId === m.id && ( +
+ setEditingName(e.target.value)} + /> + saveName(m.id)} + > + + +
)}
+ { + setEditingId(editingId === m.id ? undefined : m.id); + setEditingName(m.name ?? ""); + }} + > + + {!m.is_default && m.enabled && !expired && ( -
+
setNwc(e.target.value)} /> + setNwcName(e.target.value)} + />