@djangocfg/payments 2.1.457

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,149 @@
1
+ // ============================================================================
2
+ // @djangocfg/payments — mock adapter (mock-first development)
3
+ // ============================================================================
4
+ // Fakes the full payment state machine with delays so apps/my can build and
5
+ // verify the entire checkout UX before any backend endpoint or Stripe key
6
+ // exists. Swapping to the real Stripe adapter is a one-line host change.
7
+
8
+ import type {
9
+ ConfirmArgs,
10
+ ConfirmResult,
11
+ CreatePaymentInput,
12
+ PaymentIntent,
13
+ PaymentPage,
14
+ PaymentProviderAdapter,
15
+ PaymentRecord,
16
+ StartSubscriptionInput,
17
+ SubscriptionIntent,
18
+ } from '../domain/types';
19
+
20
+ /** Outcome the mock should simulate on confirm. */
21
+ export type MockOutcome = 'succeeded' | 'failed' | 'requires_action';
22
+
23
+ export interface MockAdapterConfig {
24
+ /** Latency per step, ms. Default 600. */
25
+ latencyMs?: number;
26
+ /** What confirm() resolves to. Default 'succeeded'. */
27
+ outcome?: MockOutcome;
28
+ /** Error message when outcome === 'failed'. */
29
+ failureMessage?: string;
30
+ /** Fixture history for listPayments(). */
31
+ history?: PaymentRecord[];
32
+ /**
33
+ * Deterministic timestamp factory (tests). Defaults to `new Date()`.
34
+ * Kept injectable so stories/tests stay stable.
35
+ */
36
+ now?: () => Date;
37
+ }
38
+
39
+ function delay(ms: number): Promise<void> {
40
+ return new Promise((resolve) => setTimeout(resolve, ms));
41
+ }
42
+
43
+ let counter = 0;
44
+ function nextId(prefix: string): string {
45
+ counter += 1;
46
+ return `${prefix}_mock_${counter}`;
47
+ }
48
+
49
+ export function createMockPaymentAdapter(
50
+ config: MockAdapterConfig = {},
51
+ ): PaymentProviderAdapter {
52
+ const {
53
+ latencyMs = 600,
54
+ outcome = 'succeeded',
55
+ failureMessage = 'Your card was declined.',
56
+ history = [],
57
+ now = () => new Date(),
58
+ } = config;
59
+
60
+ let lastIntent: PaymentIntent | null = null;
61
+
62
+ return {
63
+ id: 'mock',
64
+
65
+ async createIntent(input: CreatePaymentInput): Promise<PaymentIntent> {
66
+ await delay(latencyMs);
67
+ const intent: PaymentIntent = {
68
+ id: nextId('pi'),
69
+ clientSecret: nextId('secret'),
70
+ amount: input.amount,
71
+ currency: input.currency,
72
+ status: 'requires_payment',
73
+ reference: input.reference,
74
+ provider: 'mock',
75
+ createdAt: now().toISOString(),
76
+ };
77
+ lastIntent = intent;
78
+ return intent;
79
+ },
80
+
81
+ async confirm(_args: ConfirmArgs): Promise<ConfirmResult> {
82
+ await delay(latencyMs);
83
+ const intentId = lastIntent?.id ?? nextId('pi');
84
+
85
+ if (outcome === 'failed') {
86
+ if (lastIntent) lastIntent = { ...lastIntent, status: 'failed' };
87
+ return { status: 'failed', intentId, error: failureMessage };
88
+ }
89
+
90
+ if (outcome === 'requires_action') {
91
+ if (lastIntent) lastIntent = { ...lastIntent, status: 'requires_action' };
92
+ return {
93
+ status: 'requires_action',
94
+ intentId,
95
+ nextActionUrl: 'https://mock.test/3ds-challenge',
96
+ };
97
+ }
98
+
99
+ if (lastIntent) lastIntent = { ...lastIntent, status: 'succeeded' };
100
+ return { status: 'succeeded', intentId };
101
+ },
102
+
103
+ async createSubscription(input: StartSubscriptionInput): Promise<SubscriptionIntent> {
104
+ await delay(latencyMs);
105
+ const subId = nextId('sub');
106
+ // Mock the deferred flow: a paid first invoice → confirmPayment path, with
107
+ // a fake client_secret the mock confirm() accepts. (Trial/$0 setup path
108
+ // isn't exercised here — Free plans never reach the adapter.)
109
+ const intent: PaymentIntent = {
110
+ id: subId,
111
+ clientSecret: nextId('seti_or_pi_secret'),
112
+ amount: 0,
113
+ currency: 'usd',
114
+ status: 'requires_payment',
115
+ reference: { kind: 'subscription', id: subId },
116
+ provider: 'mock',
117
+ createdAt: now().toISOString(),
118
+ };
119
+ lastIntent = intent;
120
+ return {
121
+ stripeSubscriptionId: subId,
122
+ status: 'incomplete',
123
+ confirmType: 'payment',
124
+ clientSecret: intent.clientSecret,
125
+ provider: 'mock',
126
+ };
127
+ },
128
+
129
+ async retrieve(intentId: string): Promise<PaymentIntent> {
130
+ await delay(latencyMs / 2);
131
+ if (lastIntent && lastIntent.id === intentId) return lastIntent;
132
+ return {
133
+ id: intentId,
134
+ clientSecret: null,
135
+ amount: 0,
136
+ currency: 'usd',
137
+ status: 'succeeded',
138
+ reference: { kind: 'order', id: 'unknown' },
139
+ provider: 'mock',
140
+ createdAt: now().toISOString(),
141
+ };
142
+ },
143
+
144
+ async listPayments(): Promise<PaymentPage> {
145
+ await delay(latencyMs / 2);
146
+ return { items: history };
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,199 @@
1
+ // ============================================================================
2
+ // @djangocfg/payments — Stripe adapter (transport layer, Stripe-specific)
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.
7
+ //
8
+ // The publishable key and the create-intent HTTP call are HOST
9
+ // responsibilities, passed in via StripeAdapterConfig — the package never
10
+ // reads env vars or imports the generated API client.
11
+
12
+ import type { Stripe, StripeElements } from '@stripe/stripe-js';
13
+ import type {
14
+ ConfirmArgs,
15
+ ConfirmResult,
16
+ CreatePaymentInput,
17
+ PaymentIntent,
18
+ PaymentPage,
19
+ PaymentProviderAdapter,
20
+ PaymentStatus,
21
+ StartSubscriptionInput,
22
+ SubscriptionIntent,
23
+ } from '../domain/types';
24
+
25
+ /** Raw shape the host's backend returns from its create-intent endpoint. */
26
+ export interface BackendIntent {
27
+ id: string;
28
+ client_secret: string;
29
+ status: string;
30
+ }
31
+
32
+ /**
33
+ * Raw shape the host's backend returns from `subscribe-checkout` (paid branch).
34
+ * Mirrors the Django `SubscribeCheckoutResult` (snake_case).
35
+ */
36
+ export interface BackendSubscription {
37
+ stripe_subscription_id: string;
38
+ status: string;
39
+ confirm_type: 'payment' | 'setup';
40
+ client_secret: string | null;
41
+ }
42
+
43
+ export interface StripeAdapterConfig {
44
+ /** Resolved by the host: memoized loadStripe(publishableKey). */
45
+ getStripe: () => Promise<Stripe | null>;
46
+ /** Host transport call → POST /payments/intents → { id, client_secret, status }. */
47
+ createIntentOnBackend: (input: CreatePaymentInput) => Promise<BackendIntent>;
48
+ /**
49
+ * Host transport call → POST /billing/subscribe-checkout/ (paid branch only) →
50
+ * the raw `SubscribeCheckoutResult`. Injected by the host so the package never
51
+ * touches the generated client. Omit on adapters that don't do subscriptions.
52
+ */
53
+ createSubscriptionOnBackend?: (input: StartSubscriptionInput) => Promise<BackendSubscription>;
54
+ /** Optional history fetch (host transport → GET /payments). */
55
+ listPaymentsOnBackend?: PaymentProviderAdapter['listPayments'];
56
+ /** Deterministic timestamp factory (tests). Defaults to `new Date()`. */
57
+ now?: () => Date;
58
+ }
59
+
60
+ /** Map a Stripe PaymentIntent status string to our normalized status. */
61
+ function mapStripeStatus(status: string): PaymentStatus {
62
+ switch (status) {
63
+ case 'requires_payment_method':
64
+ case 'requires_confirmation':
65
+ return 'requires_payment';
66
+ case 'requires_action':
67
+ case 'requires_capture':
68
+ return 'requires_action';
69
+ case 'processing':
70
+ return 'processing';
71
+ case 'succeeded':
72
+ return 'succeeded';
73
+ case 'canceled':
74
+ return 'canceled';
75
+ default:
76
+ return 'failed';
77
+ }
78
+ }
79
+
80
+ export function createStripeAdapter(
81
+ cfg: StripeAdapterConfig,
82
+ ): PaymentProviderAdapter {
83
+ const now = cfg.now ?? (() => new Date());
84
+
85
+ return {
86
+ id: 'stripe',
87
+
88
+ async createIntent(input: CreatePaymentInput): Promise<PaymentIntent> {
89
+ const raw = await cfg.createIntentOnBackend(input);
90
+ return {
91
+ id: raw.id,
92
+ clientSecret: raw.client_secret,
93
+ amount: input.amount,
94
+ currency: input.currency,
95
+ status: mapStripeStatus(raw.status),
96
+ reference: input.reference,
97
+ provider: 'stripe',
98
+ createdAt: now().toISOString(),
99
+ };
100
+ },
101
+
102
+ async confirm(args: ConfirmArgs): Promise<ConfirmResult> {
103
+ const stripe = await cfg.getStripe();
104
+ if (!stripe) {
105
+ return {
106
+ status: 'failed',
107
+ intentId: '',
108
+ error: 'Stripe failed to initialize.',
109
+ };
110
+ }
111
+
112
+ const elements = args.elementsContext as StripeElements | undefined;
113
+ if (!elements) {
114
+ return {
115
+ status: 'failed',
116
+ intentId: '',
117
+ error: 'Missing Stripe Elements context.',
118
+ };
119
+ }
120
+
121
+ // `mandate_data` is REQUIRED for recurring alt-methods (Cash App Pay,
122
+ // Amazon Pay, SEPA…) or they silently fail to confirm on a subscription.
123
+ // Harmless for card. `infer_from_client` lets Stripe derive the mandate
124
+ // text from the on-screen agreement (research §2).
125
+ const confirmParams = {
126
+ ...(args.returnUrl ? { return_url: args.returnUrl } : {}),
127
+ mandate_data: {
128
+ customer_acceptance: {
129
+ type: 'online' as const,
130
+ online: { infer_from_client: true },
131
+ },
132
+ },
133
+ };
134
+
135
+ // Deferred subscription with nothing to charge now ($0 / trial): confirm a
136
+ // SetupIntent to save the card for future renewals. Otherwise confirm the
137
+ // (first-invoice) PaymentIntent. Same <Elements clientSecret> either way —
138
+ // only the confirm call differs (research §1 branch).
139
+ if (args.confirmType === 'setup') {
140
+ const { error, setupIntent } = await stripe.confirmSetup({
141
+ elements,
142
+ confirmParams,
143
+ redirect: 'if_required',
144
+ });
145
+ if (error) {
146
+ return {
147
+ status: 'failed',
148
+ intentId: error.setup_intent?.id ?? '',
149
+ error: error.message ?? 'Could not save your card.',
150
+ };
151
+ }
152
+ return {
153
+ status: mapStripeStatus(setupIntent?.status ?? 'failed'),
154
+ intentId: setupIntent?.id ?? '',
155
+ };
156
+ }
157
+
158
+ const { error, paymentIntent } = await stripe.confirmPayment({
159
+ elements,
160
+ confirmParams,
161
+ redirect: 'if_required',
162
+ });
163
+
164
+ if (error) {
165
+ return {
166
+ status: 'failed',
167
+ intentId: error.payment_intent?.id ?? '',
168
+ error: error.message ?? 'Payment failed.',
169
+ };
170
+ }
171
+
172
+ return {
173
+ status: mapStripeStatus(paymentIntent?.status ?? 'failed'),
174
+ intentId: paymentIntent?.id ?? '',
175
+ };
176
+ },
177
+
178
+ createSubscription: cfg.createSubscriptionOnBackend
179
+ ? async (input: StartSubscriptionInput): Promise<SubscriptionIntent> => {
180
+ const raw = await cfg.createSubscriptionOnBackend!(input);
181
+ return {
182
+ stripeSubscriptionId: raw.stripe_subscription_id,
183
+ status: raw.status,
184
+ confirmType: raw.confirm_type,
185
+ clientSecret: raw.client_secret,
186
+ provider: 'stripe',
187
+ };
188
+ }
189
+ : undefined,
190
+
191
+ // No client-side `retrieve`: it needs the client secret, and the webhook
192
+ // is the source of truth anyway. Callers reconcile against backend order
193
+ // status instead. Intentionally omitted (the field is optional).
194
+
195
+ listPayments: cfg.listPaymentsOnBackend
196
+ ? (params): Promise<PaymentPage> => cfg.listPaymentsOnBackend!(params)
197
+ : undefined,
198
+ };
199
+ }