@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/README.md +82 -72
- package/package.json +7 -6
- package/src/components/CardSetupForm.tsx +165 -0
- package/src/components/SavedCardList.tsx +225 -0
- package/src/components/StripePaymentElement.tsx +2 -1
- package/src/components/UpdateCardDialog.tsx +165 -0
- package/src/components/index.ts +9 -0
- package/src/domain/cards.ts +63 -0
- package/src/domain/index.ts +14 -0
- package/src/domain/types.ts +83 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useCardSetup.ts +138 -0
- package/src/hooks/useSavedCards.ts +139 -0
- package/src/index.ts +21 -4
- package/src/transport/index.ts +2 -0
- package/src/transport/mock-adapter.ts +98 -0
- package/src/transport/stripe-adapter.ts +77 -3
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.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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';
|
package/src/transport/index.ts
CHANGED
|
@@ -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
|
|
5
|
-
// StripePaymentElement.tsx). Keeping the SDK
|
|
6
|
-
// mock-only path Stripe-free in the bundle and the
|
|
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).
|