@djangocfg/payments 2.1.560 → 2.1.562

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.
package/src/index.ts CHANGED
@@ -3,9 +3,14 @@
3
3
  // ============================================================================
4
4
  // The host injects ONE adapter (mock or stripe) via <PaymentProvider>.
5
5
  // Package code depends only on the domain types + the adapter seam — never on
6
- // a concrete SDK or a generated API client. @stripe/* is touched in exactly
7
- // two files (stripe-adapter.ts, StripePaymentElement.tsx); it ships as a
8
- // regular dep (stripe-js is just the CDN loader) and tree-shakes out of mock-only bundles.
6
+ // a concrete SDK or a generated API client.
7
+ //
8
+ // @stripe/* is a RUNTIME dependency of two files (stripe-adapter.ts,
9
+ // StripePaymentElement.tsx) and a type-only one of a third (appearance.ts).
10
+ // It ships as a regular dep — stripe-js is just the CDN loader — and
11
+ // tree-shakes out of mock-only bundles. Enforced by
12
+ // scripts/check-stripe-imports.mjs, because this rule was stated in four
13
+ // comments and checked by none until it had already drifted.
9
14
 
10
15
  // Domain (types + money helpers)
11
16
  export * from './domain';
@@ -36,15 +41,20 @@ export type {
36
41
  StripeAdapterConfig,
37
42
  BackendIntent,
38
43
  BackendSubscription,
44
+ BackendSetupIntent,
45
+ BackendSavedCard,
39
46
  Stripe,
40
47
  } from './transport';
41
48
 
42
49
  // Hooks
43
- export { useCheckout, usePaymentHistory } from './hooks';
50
+ export { useCheckout, usePaymentHistory, useSavedCards, useCardSetup } from './hooks';
44
51
  export type {
45
52
  UseCheckoutResult,
46
53
  UsePaymentHistoryProps,
47
54
  UsePaymentHistoryResult,
55
+ UseSavedCardsProps,
56
+ UseSavedCardsResult,
57
+ UseCardSetupResult,
48
58
  } from './hooks';
49
59
 
50
60
  // Components
@@ -56,6 +66,9 @@ export {
56
66
  buildStripeAppearance,
57
67
  PaymentHistory,
58
68
  PaymentStatusBadge,
69
+ SavedCardList,
70
+ CardSetupForm,
71
+ UpdateCardDialog,
59
72
  } from './components';
60
73
  export type {
61
74
  CheckoutDialogProps,
@@ -65,4 +78,8 @@ export type {
65
78
  AppearanceTokens,
66
79
  PaymentHistoryProps,
67
80
  PaymentStatusBadgeProps,
81
+ SavedCardListProps,
82
+ SavedCardListLabels,
83
+ CardSetupFormProps,
84
+ UpdateCardDialogProps,
68
85
  } from './components';
@@ -10,5 +10,7 @@ export type {
10
10
  StripeAdapterConfig,
11
11
  BackendIntent,
12
12
  BackendSubscription,
13
+ BackendSetupIntent,
14
+ BackendSavedCard,
13
15
  Stripe,
14
16
  } from './stripe-adapter';
@@ -13,6 +13,9 @@ import type {
13
13
  PaymentPage,
14
14
  PaymentProviderAdapter,
15
15
  PaymentRecord,
16
+ SavedCard,
17
+ SaveCardInput,
18
+ SetupIntent,
16
19
  StartSubscriptionInput,
17
20
  SubscriptionIntent,
18
21
  } from '../domain/types';
@@ -29,6 +32,16 @@ export interface MockAdapterConfig {
29
32
  failureMessage?: string;
30
33
  /** Fixture history for listPayments(). */
31
34
  history?: PaymentRecord[];
35
+ /**
36
+ * Fixture cards for listPaymentMethods(). Defaults to two: a default Visa
37
+ * and an Amex inside the expiry warning window, so a story renders both the
38
+ * ordinary and the warning row without configuration.
39
+ *
40
+ * Pass `[]` for the loaded-and-empty state. Pass `null` to drop the four
41
+ * card methods entirely — that is the `unavailable` state a host sees when
42
+ * it never wired the endpoints, and it must look different from `[]`.
43
+ */
44
+ cards?: SavedCard[] | null;
32
45
  /**
33
46
  * Deterministic timestamp factory (tests). Defaults to `new Date()`.
34
47
  * Kept injectable so stories/tests stay stable.
@@ -40,6 +53,29 @@ function delay(ms: number): Promise<void> {
40
53
  return new Promise((resolve) => setTimeout(resolve, ms));
41
54
  }
42
55
 
56
+ /** A default Visa plus an Amex inside the warning window, relative to `now`. */
57
+ function defaultMockCards(now: Date): SavedCard[] {
58
+ const soon = new Date(now.getFullYear(), now.getMonth() + 2, 1);
59
+ return [
60
+ {
61
+ id: 'pm_mock_visa',
62
+ brand: 'visa',
63
+ last4: '4242',
64
+ expMonth: now.getMonth() + 1,
65
+ expYear: now.getFullYear() + 3,
66
+ isDefault: true,
67
+ },
68
+ {
69
+ id: 'pm_mock_amex',
70
+ brand: 'amex',
71
+ last4: '0005',
72
+ expMonth: soon.getMonth() + 1,
73
+ expYear: soon.getFullYear(),
74
+ isDefault: false,
75
+ },
76
+ ];
77
+ }
78
+
43
79
  let counter = 0;
44
80
  function nextId(prefix: string): string {
45
81
  counter += 1;
@@ -59,6 +95,11 @@ export function createMockPaymentAdapter(
59
95
 
60
96
  let lastIntent: PaymentIntent | null = null;
61
97
 
98
+ // `cards: null` means "this adapter has no card-on-file capability", which
99
+ // is expressed by leaving the four methods undefined — not by an empty list.
100
+ const supportsCards = config.cards !== null;
101
+ let cards: SavedCard[] = config.cards ?? defaultMockCards(now());
102
+
62
103
  return {
63
104
  id: 'mock',
64
105
 
@@ -141,6 +182,63 @@ export function createMockPaymentAdapter(
141
182
  };
142
183
  },
143
184
 
185
+ // Card on file. Defined only when the fixture says this adapter has the
186
+ // capability — `cards: null` leaves all four undefined, which is how a
187
+ // reader tells "unsupported" from "none on file".
188
+ createSetupIntent: supportsCards
189
+ ? async (_input?: SaveCardInput): Promise<SetupIntent> => {
190
+ await delay(latencyMs);
191
+ const id = nextId('seti');
192
+ // Route mock confirm() through the same lastIntent slot the checkout
193
+ // path uses, so `outcome` drives the setup branch identically.
194
+ lastIntent = {
195
+ id,
196
+ clientSecret: nextId('seti_secret'),
197
+ amount: 0,
198
+ currency: 'usd',
199
+ status: 'requires_payment',
200
+ reference: { kind: 'subscription', id },
201
+ provider: 'mock',
202
+ createdAt: now().toISOString(),
203
+ };
204
+ return { id, clientSecret: lastIntent.clientSecret!, provider: 'mock' };
205
+ }
206
+ : undefined,
207
+
208
+ listPaymentMethods: supportsCards
209
+ ? async (): Promise<SavedCard[]> => {
210
+ await delay(latencyMs / 2);
211
+ return cards;
212
+ }
213
+ : undefined,
214
+
215
+ setDefaultPaymentMethod: supportsCards
216
+ ? async (id: string): Promise<void> => {
217
+ await delay(latencyMs / 2);
218
+ if (!cards.some((card) => card.id === id)) {
219
+ throw new Error(`No such payment method: ${id}`);
220
+ }
221
+ cards = cards.map((card) => ({ ...card, isDefault: card.id === id }));
222
+ }
223
+ : undefined,
224
+
225
+ removePaymentMethod: supportsCards
226
+ ? async (id: string): Promise<void> => {
227
+ await delay(latencyMs / 2);
228
+ const target = cards.find((card) => card.id === id);
229
+ if (!target) throw new Error(`No such payment method: ${id}`);
230
+ // Mirrors the server rule: the last card on an active subscription
231
+ // cannot be detached, or the next renewal has nothing to charge.
232
+ if (cards.length === 1) {
233
+ throw new Error('Cannot remove the only card on an active subscription.');
234
+ }
235
+ cards = cards.filter((card) => card.id !== id);
236
+ if (target.isDefault && cards.length > 0) {
237
+ cards = cards.map((card, index) => ({ ...card, isDefault: index === 0 }));
238
+ }
239
+ }
240
+ : undefined,
241
+
144
242
  async listPayments(): Promise<PaymentPage> {
145
243
  await delay(latencyMs / 2);
146
244
  return { items: history };
@@ -1,9 +1,10 @@
1
1
  // ============================================================================
2
2
  // @djangocfg/payments — Stripe adapter (transport layer, Stripe-specific)
3
3
  // ============================================================================
4
- // One of TWO files allowed to import @stripe/* (the other is
5
- // StripePaymentElement.tsx). Keeping the SDK isolated here keeps the
6
- // mock-only path Stripe-free in the bundle and the domain/ layer SDK-free.
4
+ // One of two files allowed a RUNTIME @stripe/* import (the other is
5
+ // StripePaymentElement.tsx; appearance.ts may name its types). Keeping the SDK
6
+ // isolated here keeps the mock-only path Stripe-free in the bundle and the
7
+ // domain/ layer SDK-free. Enforced by scripts/check-stripe-imports.mjs.
7
8
  //
8
9
  // The publishable key and the create-intent HTTP call are HOST
9
10
  // responsibilities, passed in via StripeAdapterConfig — the package never
@@ -24,6 +25,9 @@ import type {
24
25
  PaymentPage,
25
26
  PaymentProviderAdapter,
26
27
  PaymentStatus,
28
+ SavedCard,
29
+ SaveCardInput,
30
+ SetupIntent,
27
31
  StartSubscriptionInput,
28
32
  SubscriptionIntent,
29
33
  } from '../domain/types';
@@ -46,6 +50,22 @@ export interface BackendSubscription {
46
50
  client_secret: string | null;
47
51
  }
48
52
 
53
+ /** Raw shape the host's backend returns from its create-setup-intent endpoint. */
54
+ export interface BackendSetupIntent {
55
+ id: string;
56
+ client_secret: string;
57
+ }
58
+
59
+ /** Raw saved-card row from the host's backend (snake_case, as Django serializes). */
60
+ export interface BackendSavedCard {
61
+ id: string;
62
+ brand: string;
63
+ last4: string;
64
+ exp_month: number;
65
+ exp_year: number;
66
+ is_default: boolean;
67
+ }
68
+
49
69
  export interface StripeAdapterConfig {
50
70
  /** Resolved by the host: memoized loadStripe(publishableKey). */
51
71
  getStripe: () => Promise<Stripe | null>;
@@ -59,10 +79,38 @@ export interface StripeAdapterConfig {
59
79
  createSubscriptionOnBackend?: (input: StartSubscriptionInput) => Promise<BackendSubscription>;
60
80
  /** Optional history fetch (host transport → GET /payments). */
61
81
  listPaymentsOnBackend?: PaymentProviderAdapter['listPayments'];
82
+ /**
83
+ * Card on file. Wire all four or none — a host that lists cards but cannot
84
+ * add one shows a dead end.
85
+ *
86
+ * SERVER-SIDE RULES this adapter cannot enforce:
87
+ * - create the SetupIntent against the STORED customer, never an id from
88
+ * the client;
89
+ * - `usage: 'off_session'` — renewals are off-session, and the default
90
+ * (`on_session`) stores a card Stripe declines at renewal time;
91
+ * - setting a default writes `invoice_settings.default_payment_method` on
92
+ * the customer AND `default_payment_method` on the subscription. Only the
93
+ * second governs an existing subscription; writing one is a quiet no-op.
94
+ */
95
+ createSetupIntentOnBackend?: (input?: SaveCardInput) => Promise<BackendSetupIntent>;
96
+ listPaymentMethodsOnBackend?: () => Promise<BackendSavedCard[]>;
97
+ setDefaultPaymentMethodOnBackend?: (id: string) => Promise<void>;
98
+ removePaymentMethodOnBackend?: (id: string) => Promise<void>;
62
99
  /** Deterministic timestamp factory (tests). Defaults to `new Date()`. */
63
100
  now?: () => Date;
64
101
  }
65
102
 
103
+ function mapSavedCard(raw: BackendSavedCard): SavedCard {
104
+ return {
105
+ id: raw.id,
106
+ brand: raw.brand,
107
+ last4: raw.last4,
108
+ expMonth: raw.exp_month,
109
+ expYear: raw.exp_year,
110
+ isDefault: raw.is_default,
111
+ };
112
+ }
113
+
66
114
  /** Map a Stripe PaymentIntent status string to our normalized status. */
67
115
  function mapStripeStatus(status: string): PaymentStatus {
68
116
  switch (status) {
@@ -194,6 +242,32 @@ export function createStripeAdapter(
194
242
  }
195
243
  : undefined,
196
244
 
245
+ createSetupIntent: cfg.createSetupIntentOnBackend
246
+ ? async (input?: SaveCardInput): Promise<SetupIntent> => {
247
+ const raw = await cfg.createSetupIntentOnBackend!(input);
248
+ return {
249
+ id: raw.id,
250
+ clientSecret: raw.client_secret,
251
+ provider: 'stripe',
252
+ };
253
+ }
254
+ : undefined,
255
+
256
+ listPaymentMethods: cfg.listPaymentMethodsOnBackend
257
+ ? async (): Promise<SavedCard[]> => {
258
+ const raw = await cfg.listPaymentMethodsOnBackend!();
259
+ return raw.map(mapSavedCard);
260
+ }
261
+ : undefined,
262
+
263
+ setDefaultPaymentMethod: cfg.setDefaultPaymentMethodOnBackend
264
+ ? (id: string): Promise<void> => cfg.setDefaultPaymentMethodOnBackend!(id)
265
+ : undefined,
266
+
267
+ removePaymentMethod: cfg.removePaymentMethodOnBackend
268
+ ? (id: string): Promise<void> => cfg.removePaymentMethodOnBackend!(id)
269
+ : undefined,
270
+
197
271
  // No client-side `retrieve`: it needs the client secret, and the webhook
198
272
  // is the source of truth anyway. Callers reconcile against backend order
199
273
  // status instead. Intentionally omitted (the field is optional).