diff --git a/src/api.ts b/src/api.ts index b333533..a468c3e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -138,7 +138,22 @@ 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; + /** Optional user-defined label */ + name?: 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 +663,40 @@ 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, name?: string) { + const { data } = await this.#handleResponse>( + await this.#req("/api/v1/payment-methods", "POST", { + nwc_connection_string, + name, + }), + ); + return data; + } + + async updatePaymentMethod( + id: number, + patch: { is_default?: boolean; enabled?: boolean; name?: string | null }, + ) { + 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..4657011 --- /dev/null +++ b/src/components/payment-methods.tsx @@ -0,0 +1,251 @@ +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"; + +/** 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"; + 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 [nwcName, setNwcName] = useState(""); + const [editingId, setEditingId] = useState(); + const [editingName, setEditingName] = 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, nwcName.trim() || undefined); + setNwc(""); + setNwcName(""); + setError(undefined); + await reload(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + 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}
} + + {loading ? ( +
+ +
+ ) : methods.length === 0 ? ( +
+ +
+ ) : ( +
    + {methods.map((m) => { + const expired = isExpired(m); + const exp = expiryLabel(m); + return ( +
  • +
    +
    + + {m.name?.trim() || defaultLabel(m)} + + {m.is_default && ( + + + + )} + {!m.enabled && ( + + + + )} + {expired && ( + + + + )} +
    + + {/* 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 && ( + setDefault(m.id)} + > + + + )} + toggleEnabled(m)} + > + {m.enabled ? ( + + ) : ( + + )} + + remove(m.id)}> + + +
    +
  • + ); + })} +
+ )} + +
+ +
+ setNwc(e.target.value)} + /> + setNwcName(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 })} - /> - +