@djangocfg/payments 2.1.561 → 2.1.563

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ 'use client';
2
+
3
+ // ============================================================================
4
+ // @djangocfg/payments — UpdateCardDialog
5
+ // ============================================================================
6
+ // The whole add-a-card flow in one mountable surface: create the SetupIntent
7
+ // on open → render the provider's field against its clientSecret → confirm →
8
+ // report. ui-core + package internals only; the Stripe field arrives from the
9
+ // host through `renderPaymentField`, so this file imports no SDK.
10
+ //
11
+ // THE INTENT IS CREATED ON OPEN, NOT ON SUBMIT. CheckoutForm can create its
12
+ // intent lazily because it owns a mock-friendly text field. <Elements> cannot:
13
+ // it needs the clientSecret BEFORE it mounts, and remounting it after a submit
14
+ // would wipe whatever the customer typed. So opening the dialog starts the
15
+ // intent, and closing it resets the machine — an abandoned SetupIntent costs
16
+ // nothing and expires on its own.
17
+
18
+ import React, { useCallback, useEffect } from 'react';
19
+ import { Alert, AlertDescription, Button, Spinner } from '@djangocfg/ui-core';
20
+ import { AlertTriangle } from 'lucide-react';
21
+ import { CheckoutDialog } from './CheckoutDialog';
22
+ import { CardSetupForm } from './CardSetupForm';
23
+ import { useCardSetup } from '../hooks/useCardSetup';
24
+ import type { SaveCardInput } from '../domain/types';
25
+
26
+ export interface UpdateCardDialogProps {
27
+ open: boolean;
28
+ /** Called on any allowed close path. The machine resets itself. */
29
+ onClose: () => void;
30
+ /** Attach the saved card to this subscription's default. */
31
+ subscriptionId?: string;
32
+ /**
33
+ * Render the whole card step against the live clientSecret. The host mounts
34
+ * the provider's field (typically <StripePaymentElement>) and calls
35
+ * `renderForm` from inside its render prop, handing back the live elements
36
+ * context — which is what keeps this file free of @stripe/*.
37
+ *
38
+ * The two arrive TOGETHER on purpose: the elements context only exists
39
+ * inside <Elements>, so a callback that reported it upward would have to
40
+ * write state during the host's render. Passing the form INTO that scope
41
+ * needs no state at all.
42
+ */
43
+ renderCardStep?: (args: {
44
+ clientSecret: string;
45
+ /** Render the form with the live provider context and its field node. */
46
+ renderForm: (args: { elementsContext: unknown; field: React.ReactNode }) => React.ReactNode;
47
+ }) => React.ReactNode;
48
+ /** Mock path: renders the 4242 stub instead of a provider field. */
49
+ isMock?: boolean;
50
+ returnUrl?: string;
51
+ /** Fired after Stripe stored the card. NOT proof the subscription recovered. */
52
+ onSaved?: (setupIntentId: string) => void;
53
+ title?: React.ReactNode;
54
+ description?: React.ReactNode;
55
+ submitLabel?: string;
56
+ consentText?: string;
57
+ className?: string;
58
+ }
59
+
60
+ export function UpdateCardDialog({
61
+ open,
62
+ onClose,
63
+ subscriptionId,
64
+ renderCardStep,
65
+ isMock = false,
66
+ returnUrl,
67
+ onSaved,
68
+ title = 'Add a card',
69
+ description = 'Save a card for future renewals.',
70
+ submitLabel,
71
+ consentText,
72
+ className,
73
+ }: UpdateCardDialogProps) {
74
+ const setup = useCardSetup();
75
+ const { start, reset, status, error, intent, supported } = setup;
76
+
77
+ const input: SaveCardInput | undefined = subscriptionId ? { subscriptionId } : undefined;
78
+
79
+ useEffect(() => {
80
+ if (!open) {
81
+ reset();
82
+ return;
83
+ }
84
+ if (supported) void start(input);
85
+ // `input` is derived from subscriptionId; re-running on open is the intent.
86
+ // eslint-disable-next-line react-hooks/exhaustive-deps
87
+ }, [open, supported, subscriptionId]);
88
+
89
+ const handleSuccess = useCallback(
90
+ (setupIntentId: string) => {
91
+ onSaved?.(setupIntentId);
92
+ },
93
+ [onSaved],
94
+ );
95
+
96
+ let body: React.ReactNode;
97
+
98
+ if (!supported) {
99
+ body = (
100
+ <Alert variant="destructive">
101
+ <AlertTriangle className="h-4 w-4" />
102
+ <AlertDescription>
103
+ This account cannot save a card. Contact support to update your billing details.
104
+ </AlertDescription>
105
+ </Alert>
106
+ );
107
+ } else if (status === 'creating' || (!intent && status !== 'failed')) {
108
+ body = (
109
+ <div className="flex flex-col items-center gap-2 py-8">
110
+ <Spinner className="h-5 w-5" />
111
+ <p className="text-muted-foreground text-sm">Preparing the secure form…</p>
112
+ </div>
113
+ );
114
+ } else if (!intent) {
115
+ // Creation failed: the form would have no clientSecret to render against,
116
+ // so offer the retry here rather than an empty card field.
117
+ body = (
118
+ <div className="space-y-3 py-2">
119
+ <Alert variant="destructive">
120
+ <AlertTriangle className="h-4 w-4" />
121
+ <AlertDescription>{error ?? 'Could not open the card form.'}</AlertDescription>
122
+ </Alert>
123
+ <Button type="button" variant="outline" className="w-full" onClick={() => void start(input)}>
124
+ Retry
125
+ </Button>
126
+ </div>
127
+ );
128
+ } else {
129
+ const renderForm = ({
130
+ elementsContext,
131
+ field,
132
+ }: {
133
+ elementsContext: unknown;
134
+ field: React.ReactNode;
135
+ }) => (
136
+ <CardSetupForm
137
+ setup={setup}
138
+ isMock={isMock}
139
+ elementsContext={elementsContext}
140
+ paymentField={field}
141
+ returnUrl={returnUrl}
142
+ submitLabel={submitLabel}
143
+ consentText={consentText}
144
+ onSuccess={handleSuccess}
145
+ onCancel={onClose}
146
+ />
147
+ );
148
+
149
+ body = renderCardStep
150
+ ? renderCardStep({ clientSecret: intent.clientSecret, renderForm })
151
+ : renderForm({ elementsContext: undefined, field: undefined });
152
+ }
153
+
154
+ return (
155
+ <CheckoutDialog
156
+ open={open}
157
+ onClose={onClose}
158
+ title={title}
159
+ description={description}
160
+ className={className}
161
+ >
162
+ {body}
163
+ </CheckoutDialog>
164
+ );
165
+ }
@@ -19,3 +19,12 @@ export type { PaymentHistoryProps } from './PaymentHistory';
19
19
 
20
20
  export { PaymentStatusBadge } from './PaymentStatusBadge';
21
21
  export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
22
+
23
+ export { SavedCardList } from './SavedCardList';
24
+ export type { SavedCardListProps, SavedCardListLabels } from './SavedCardList';
25
+
26
+ export { CardSetupForm } from './CardSetupForm';
27
+ export type { CardSetupFormProps } from './CardSetupForm';
28
+
29
+ export { UpdateCardDialog } from './UpdateCardDialog';
30
+ export type { UpdateCardDialogProps } from './UpdateCardDialog';
@@ -0,0 +1,63 @@
1
+ // ============================================================================
2
+ // @djangocfg/payments — card derivations (pure: no React, no Stripe)
3
+ // ============================================================================
4
+ // `SavedCard` stores only what the provider states. Everything below is
5
+ // derived against a clock, so it lives here rather than in a component: two
6
+ // readers computing "expiring soon" separately drift the first time one of
7
+ // them forgets that a card is valid through the END of its expiry month.
8
+
9
+ import type { SavedCard, SavedCardsPhase } from './types';
10
+
11
+ /** The cards a phase carries. `error` keeps the last known list, so it renders. */
12
+ export function cardsOf(phase: SavedCardsPhase): SavedCard[] {
13
+ if (phase.kind === 'ready') return phase.cards;
14
+ if (phase.kind === 'error') return phase.previous ?? [];
15
+ return [];
16
+ }
17
+
18
+ /** Days before expiry at which a card is worth flagging. */
19
+ export const EXPIRY_WARNING_DAYS = 60;
20
+
21
+ /**
22
+ * The instant a card stops working: midnight after the last day of its expiry
23
+ * month. A card marked 08/2026 is valid through 2026-08-31.
24
+ */
25
+ export function cardExpiresAt(card: SavedCard): Date {
26
+ // Day 1 of the FOLLOWING month. `Date` normalizes month 12 into next
27
+ // January, so December needs no special case.
28
+ return new Date(card.expYear, card.expMonth, 1);
29
+ }
30
+
31
+ /** Whether the card can no longer be charged. */
32
+ export function isCardExpired(card: SavedCard, now: Date = new Date()): boolean {
33
+ return cardExpiresAt(card).getTime() <= now.getTime();
34
+ }
35
+
36
+ /**
37
+ * Whether the card still works but is close enough to expiry to warn about.
38
+ * False for an already-expired card — that is a different, louder state.
39
+ */
40
+ export function isCardExpiringSoon(
41
+ card: SavedCard,
42
+ now: Date = new Date(),
43
+ withinDays: number = EXPIRY_WARNING_DAYS,
44
+ ): boolean {
45
+ const expiresAt = cardExpiresAt(card).getTime();
46
+ if (expiresAt <= now.getTime()) return false;
47
+ return expiresAt - now.getTime() <= withinDays * 24 * 60 * 60 * 1000;
48
+ }
49
+
50
+ /** `expMonth`/`expYear` as `MM/YY`. Display only. */
51
+ export function formatCardExpiry(card: SavedCard): string {
52
+ const month = String(card.expMonth).padStart(2, '0');
53
+ const year = String(card.expYear).slice(-2);
54
+ return `${month}/${year}`;
55
+ }
56
+
57
+ /** Default first, then soonest to expire — the order a chooser wants. */
58
+ export function sortCards(cards: readonly SavedCard[]): SavedCard[] {
59
+ return cards.slice().sort((a, b) => {
60
+ if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
61
+ return cardExpiresAt(a).getTime() - cardExpiresAt(b).getTime();
62
+ });
63
+ }
@@ -18,6 +18,20 @@ export type {
18
18
  PaymentRecord,
19
19
  PaymentPage,
20
20
  PaymentProviderAdapter,
21
+ SetupIntent,
22
+ SaveCardInput,
23
+ SavedCard,
24
+ SavedCardsPhase,
21
25
  } from './types';
22
26
 
27
+ export {
28
+ EXPIRY_WARNING_DAYS,
29
+ cardsOf,
30
+ cardExpiresAt,
31
+ isCardExpired,
32
+ isCardExpiringSoon,
33
+ formatCardExpiry,
34
+ sortCards,
35
+ } from './cards';
36
+
23
37
  export { toMinorUnits, toMajorUnits, formatAmount } from './money';
@@ -126,6 +126,73 @@ export interface SubscriptionIntent {
126
126
  provider: PaymentProviderId;
127
127
  }
128
128
 
129
+ // ---------------------------------------------------------------------------
130
+ // Card on file
131
+ // ---------------------------------------------------------------------------
132
+ // A subscription outlives the card that pays for it. Stripe retries a failed
133
+ // renewal for ~2 weeks against the SAME stored card, so a customer who cannot
134
+ // replace it loses the subscription regardless of intent to pay.
135
+ //
136
+ // Saving a card here is the same Stripe operation as the $0/trial branch of
137
+ // checkout — a SetupIntent confirmed with `confirmSetup`. Only the entry point
138
+ // is new; `ConfirmArgs.confirmType = 'setup'` already dispatches it.
139
+
140
+ /** A SetupIntent: authorization to store a card, charging nothing now. */
141
+ export interface SetupIntent {
142
+ /** Provider intent id (e.g. `seti_…` for Stripe). */
143
+ id: string;
144
+ /** Confirmed on the frontend with `confirmType: 'setup'`. */
145
+ clientSecret: string;
146
+ provider: PaymentProviderId;
147
+ }
148
+
149
+ /** Input to create a SetupIntent. */
150
+ export interface SaveCardInput {
151
+ /**
152
+ * Make the saved card this subscription's default on success. Omit to store
153
+ * it at account level only. The CUSTOMER is always resolved server-side from
154
+ * the session — never sent from here.
155
+ */
156
+ subscriptionId?: string;
157
+ }
158
+
159
+ /**
160
+ * A card already on file.
161
+ *
162
+ * Carries no `status` and no `isExpiring` on purpose: both are derivations
163
+ * from `expMonth`/`expYear` against today, and a stored copy goes stale on the
164
+ * first day of a month. Readers derive them.
165
+ */
166
+ export interface SavedCard {
167
+ /** Provider payment-method id (e.g. `pm_…`). */
168
+ id: string;
169
+ /** Display only — never branch on it. */
170
+ brand: string;
171
+ last4: string;
172
+ /** 1-12. */
173
+ expMonth: number;
174
+ /** Four digits. */
175
+ expYear: number;
176
+ /** Whether renewals charge this card. */
177
+ isDefault: boolean;
178
+ }
179
+
180
+ /**
181
+ * Cards on file as a phase, not a boolean pair.
182
+ *
183
+ * `[]` answers three different questions identically: the host wired no card
184
+ * endpoints, the read has not run, and the customer genuinely has no card.
185
+ * They are three screens, so they are three states.
186
+ */
187
+ export type SavedCardsPhase =
188
+ /** The adapter defines no card methods — no card-on-file capability here. */
189
+ | { kind: 'unavailable' }
190
+ /** Never loaded. A reserved-height skeleton, not nothing. */
191
+ | { kind: 'loading' }
192
+ | { kind: 'ready'; cards: SavedCard[]; refreshing: boolean }
193
+ /** `previous` keeps the last known list, so a failed refresh does not blank it. */
194
+ | { kind: 'error'; message: string; previous?: SavedCard[] };
195
+
129
196
  /**
130
197
  * Provider-agnostic seam. The HOST injects one implementation via
131
198
  * <PaymentProvider adapter={…}>. Package code depends on THIS interface,
@@ -150,4 +217,20 @@ export interface PaymentProviderAdapter {
150
217
  retrieve?(intentId: string): Promise<PaymentIntent>;
151
218
  /** Optional: paginated history for <PaymentHistory>. */
152
219
  listPayments?(params?: { limit?: number; cursor?: string }): Promise<PaymentPage>;
220
+
221
+ // --- Card on file (all four optional together) ---------------------------
222
+ // ABSENCE IS THE SIGNAL. A one-time-payment adapter has no card on file, and
223
+ // an undefined method — not a capability flag — is what says so. Readers
224
+ // MUST render an `unavailable` state rather than coercing to an empty list:
225
+ // "no cards on file" and "this host never wired the endpoint" look identical
226
+ // once both are `[]`, and they mean opposite things.
227
+
228
+ /** Backend creates a SetupIntent against the stored customer. */
229
+ createSetupIntent?(input?: SaveCardInput): Promise<SetupIntent>;
230
+ /** Cards on file, newest default first. */
231
+ listPaymentMethods?(): Promise<SavedCard[]>;
232
+ /** Make one saved card the default for renewals. */
233
+ setDefaultPaymentMethod?(id: string): Promise<void>;
234
+ /** Detach a saved card. Rejected server-side when it is the last one on an active subscription. */
235
+ removePaymentMethod?(id: string): Promise<void>;
153
236
  }
@@ -7,3 +7,9 @@ export type { UseCheckoutResult } from './useCheckout';
7
7
 
8
8
  export { usePaymentHistory } from './usePaymentHistory';
9
9
  export type { UsePaymentHistoryProps, UsePaymentHistoryResult } from './usePaymentHistory';
10
+
11
+ export { useSavedCards } from './useSavedCards';
12
+ export type { UseSavedCardsProps, UseSavedCardsResult } from './useSavedCards';
13
+
14
+ export { useCardSetup } from './useCardSetup';
15
+ export type { UseCardSetupResult } from './useCardSetup';
@@ -0,0 +1,138 @@
1
+ 'use client';
2
+
3
+ // ============================================================================
4
+ // @djangocfg/payments — useCardSetup (save a card, charge nothing now)
5
+ // ============================================================================
6
+ // The state machine for putting a card on file:
7
+ // idle → creating → requires_payment → processing → succeeded | failed | requires_action
8
+ //
9
+ // Deliberately the SAME PaymentStatus vocabulary as useCheckout. One status
10
+ // union across the package means <PaymentStatusBadge> and every host branch
11
+ // read one set of names, not two that mean the same things.
12
+ //
13
+ // What it is NOT: proof that a subscription recovered. A succeeded SetupIntent
14
+ // means Stripe stored the card. Whether the open invoice then gets paid is a
15
+ // server decision reported by webhook — see the track's §6.
16
+
17
+ import { useCallback, useRef, useState } from 'react';
18
+ import { usePaymentAdapter } from '../context/PaymentProvider';
19
+ import type {
20
+ ConfirmArgs,
21
+ PaymentStatus,
22
+ SaveCardInput,
23
+ SetupIntent,
24
+ } from '../domain/types';
25
+
26
+ export interface UseCardSetupResult {
27
+ status: PaymentStatus;
28
+ /** The SetupIntent once created; its clientSecret drives <StripePaymentElement>. */
29
+ intent: SetupIntent | null;
30
+ error: string | null;
31
+ /** Set when the provider needs a redirect / 3DS step. */
32
+ nextActionUrl: string | null;
33
+ /** Whether the adapter can save a card at all. */
34
+ supported: boolean;
35
+ /** Step 1: create the SetupIntent. Resolves with it, or undefined on failure. */
36
+ start: (input?: SaveCardInput) => Promise<SetupIntent | undefined>;
37
+ /** Step 2: confirm with the provider (Stripe Elements or mock). */
38
+ confirm: (
39
+ elementsContext?: ConfirmArgs['elementsContext'],
40
+ returnUrl?: string,
41
+ ) => Promise<void>;
42
+ reset: () => void;
43
+ /** True while a network step is in flight. */
44
+ isBusy: boolean;
45
+ }
46
+
47
+ export function useCardSetup(): UseCardSetupResult {
48
+ const adapter = usePaymentAdapter();
49
+ const supported = typeof adapter.createSetupIntent === 'function';
50
+
51
+ const [status, setStatus] = useState<PaymentStatus>('idle');
52
+ const [intent, setIntent] = useState<SetupIntent | null>(null);
53
+ const [error, setError] = useState<string | null>(null);
54
+ const [nextActionUrl, setNextActionUrl] = useState<string | null>(null);
55
+
56
+ // Latest intent for confirm() without a stale closure — same device as
57
+ // useCheckout's intentRef.
58
+ const intentRef = useRef<SetupIntent | null>(null);
59
+
60
+ const reset = useCallback(() => {
61
+ setStatus('idle');
62
+ setIntent(null);
63
+ setError(null);
64
+ setNextActionUrl(null);
65
+ intentRef.current = null;
66
+ }, []);
67
+
68
+ const start = useCallback(
69
+ async (input?: SaveCardInput) => {
70
+ setError(null);
71
+ setNextActionUrl(null);
72
+ setStatus('creating');
73
+ if (!adapter.createSetupIntent) {
74
+ setStatus('failed');
75
+ setError('This payment provider cannot save a card.');
76
+ return undefined;
77
+ }
78
+ try {
79
+ const created = await adapter.createSetupIntent(input);
80
+ intentRef.current = created;
81
+ setIntent(created);
82
+ setStatus('requires_payment');
83
+ return created;
84
+ } catch (e) {
85
+ setStatus('failed');
86
+ setError(e instanceof Error ? e.message : 'Failed to start saving the card.');
87
+ return undefined;
88
+ }
89
+ },
90
+ [adapter],
91
+ );
92
+
93
+ const confirm = useCallback(
94
+ async (elementsContext?: ConfirmArgs['elementsContext'], returnUrl?: string) => {
95
+ const current = intentRef.current;
96
+ if (!current?.clientSecret) {
97
+ setStatus('failed');
98
+ setError('The card form is not ready to submit.');
99
+ return;
100
+ }
101
+
102
+ setError(null);
103
+ setStatus('processing');
104
+ try {
105
+ // `confirmType: 'setup'` is what dispatches stripe.confirmSetup. The
106
+ // adapter already sends mandate_data.customer_acceptance with it, which
107
+ // is what makes the stored card chargeable off-session at renewal.
108
+ const result = await adapter.confirm({
109
+ clientSecret: current.clientSecret,
110
+ elementsContext,
111
+ returnUrl,
112
+ confirmType: 'setup',
113
+ });
114
+ setStatus(result.status);
115
+ if (result.error) setError(result.error);
116
+ if (result.nextActionUrl) setNextActionUrl(result.nextActionUrl);
117
+ } catch (e) {
118
+ setStatus('failed');
119
+ setError(e instanceof Error ? e.message : 'Could not save your card.');
120
+ }
121
+ },
122
+ [adapter],
123
+ );
124
+
125
+ const isBusy = status === 'creating' || status === 'processing';
126
+
127
+ return {
128
+ status,
129
+ intent,
130
+ error,
131
+ nextActionUrl,
132
+ supported,
133
+ start,
134
+ confirm,
135
+ reset,
136
+ isBusy,
137
+ };
138
+ }
@@ -0,0 +1,139 @@
1
+ 'use client';
2
+
3
+ // ============================================================================
4
+ // @djangocfg/payments — useSavedCards
5
+ // ============================================================================
6
+ // Owns the cards-on-file list via the adapter's optional listPaymentMethods().
7
+ //
8
+ // WHY A PHASE AND NOT A BOOLEAN PAIR. `[]` answers three different questions
9
+ // identically: the host never wired the endpoint, the request has not run yet,
10
+ // and the customer genuinely has no card. The first renders a dead "Add card"
11
+ // button, the second flashes an empty table before data lands, the third is a
12
+ // real empty state. They are different screens, so they are different states.
13
+
14
+ import { useCallback, useEffect, useState } from 'react';
15
+ import { usePaymentAdapter } from '../context/PaymentProvider';
16
+ import type { SavedCard, SavedCardsPhase } from '../domain/types';
17
+ import { cardsOf, sortCards } from '../domain/cards';
18
+
19
+ export interface UseSavedCardsResult {
20
+ phase: SavedCardsPhase;
21
+ /** Cards known right now, including during a refresh or after a failed one. */
22
+ cards: SavedCard[];
23
+ /** Whether the adapter can add a card. Gates the primary action. */
24
+ canAddCard: boolean;
25
+ /** Whether the adapter can change the default and detach a card. */
26
+ canManageCards: boolean;
27
+ refresh: () => Promise<void>;
28
+ /** Make one card the default for renewals, then re-read. */
29
+ setDefault: (id: string) => Promise<void>;
30
+ /** Detach a card, then re-read. Rejects on the last card of an active subscription. */
31
+ remove: (id: string) => Promise<void>;
32
+ /** True while any of the three calls is in flight. */
33
+ isBusy: boolean;
34
+ }
35
+
36
+ export interface UseSavedCardsProps {
37
+ /** Load on mount. Default true. */
38
+ autoLoad?: boolean;
39
+ }
40
+
41
+ export function useSavedCards(
42
+ { autoLoad = true }: UseSavedCardsProps = {},
43
+ ): UseSavedCardsResult {
44
+ const adapter = usePaymentAdapter();
45
+ const canList = typeof adapter.listPaymentMethods === 'function';
46
+ const canAddCard = typeof adapter.createSetupIntent === 'function';
47
+ const canManageCards =
48
+ typeof adapter.setDefaultPaymentMethod === 'function' &&
49
+ typeof adapter.removePaymentMethod === 'function';
50
+
51
+ const [phase, setPhase] = useState<SavedCardsPhase>(
52
+ canList ? { kind: 'loading' } : { kind: 'unavailable' },
53
+ );
54
+ const [isBusy, setIsBusy] = useState(false);
55
+
56
+ const refresh = useCallback(async () => {
57
+ if (!adapter.listPaymentMethods) {
58
+ setPhase({ kind: 'unavailable' });
59
+ return;
60
+ }
61
+ setPhase((prev) =>
62
+ prev.kind === 'ready'
63
+ ? { ...prev, refreshing: true }
64
+ : prev.kind === 'error'
65
+ ? { kind: 'loading' }
66
+ : prev,
67
+ );
68
+ try {
69
+ const list = await adapter.listPaymentMethods();
70
+ setPhase({ kind: 'ready', cards: sortCards(list), refreshing: false });
71
+ } catch (e) {
72
+ setPhase((prev) => ({
73
+ kind: 'error',
74
+ message: e instanceof Error ? e.message : 'Failed to load cards.',
75
+ previous: cardsOf(prev),
76
+ }));
77
+ }
78
+ }, [adapter]);
79
+
80
+ // A write is only believed once the re-read confirms it: the server may
81
+ // refuse (detaching the last card) or adjust (promoting a new default), and
82
+ // an optimistic list would state the opposite of what renewals will charge.
83
+ const mutate = useCallback(
84
+ async (run: () => Promise<void>, fallback: string) => {
85
+ setIsBusy(true);
86
+ try {
87
+ await run();
88
+ await refresh();
89
+ } catch (e) {
90
+ setPhase((prev) => ({
91
+ kind: 'error',
92
+ message: e instanceof Error ? e.message : fallback,
93
+ previous: cardsOf(prev),
94
+ }));
95
+ } finally {
96
+ setIsBusy(false);
97
+ }
98
+ },
99
+ [refresh],
100
+ );
101
+
102
+ const setDefault = useCallback(
103
+ (id: string) =>
104
+ mutate(async () => {
105
+ if (!adapter.setDefaultPaymentMethod) {
106
+ throw new Error('This payment provider cannot change the default card.');
107
+ }
108
+ await adapter.setDefaultPaymentMethod(id);
109
+ }, 'Failed to change the default card.'),
110
+ [adapter, mutate],
111
+ );
112
+
113
+ const remove = useCallback(
114
+ (id: string) =>
115
+ mutate(async () => {
116
+ if (!adapter.removePaymentMethod) {
117
+ throw new Error('This payment provider cannot remove a card.');
118
+ }
119
+ await adapter.removePaymentMethod(id);
120
+ }, 'Failed to remove the card.'),
121
+ [adapter, mutate],
122
+ );
123
+
124
+ useEffect(() => {
125
+ if (autoLoad && canList) void refresh();
126
+ // eslint-disable-next-line react-hooks/exhaustive-deps
127
+ }, [autoLoad, canList]);
128
+
129
+ return {
130
+ phase,
131
+ cards: cardsOf(phase),
132
+ canAddCard,
133
+ canManageCards,
134
+ refresh,
135
+ setDefault,
136
+ remove,
137
+ isBusy,
138
+ };
139
+ }