@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.
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/package.json +80 -0
- package/src/components/CheckoutDialog.tsx +122 -0
- package/src/components/CheckoutForm.tsx +213 -0
- package/src/components/PaymentHistory.tsx +79 -0
- package/src/components/PaymentStatusBadge.tsx +94 -0
- package/src/components/StripePaymentElement.tsx +274 -0
- package/src/components/appearance.ts +131 -0
- package/src/components/index.ts +21 -0
- package/src/context/CheckoutGuard.tsx +94 -0
- package/src/context/PaymentProvider.tsx +38 -0
- package/src/domain/index.ts +23 -0
- package/src/domain/money.ts +48 -0
- package/src/domain/types.ts +153 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useCheckout.ts +160 -0
- package/src/hooks/usePaymentHistory.ts +80 -0
- package/src/index.ts +64 -0
- package/src/styles.css +14 -0
- package/src/transport/index.ts +13 -0
- package/src/transport/mock-adapter.ts +149 -0
- package/src/transport/stripe-adapter.ts +199 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// @djangocfg/payments — PaymentProvider context (the injected transport seam)
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// The host picks ONE adapter (mock or stripe) and injects it here. Package
|
|
7
|
+
// hooks/components read it via usePaymentAdapter() — they never import a
|
|
8
|
+
// concrete SDK or a generated client.
|
|
9
|
+
|
|
10
|
+
import React, { createContext, useContext } from 'react';
|
|
11
|
+
import type { PaymentProviderAdapter } from '../domain/types';
|
|
12
|
+
|
|
13
|
+
const PaymentAdapterContext = createContext<PaymentProviderAdapter | null>(null);
|
|
14
|
+
|
|
15
|
+
export interface PaymentProviderProps {
|
|
16
|
+
adapter: PaymentProviderAdapter;
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function PaymentProvider({ adapter, children }: PaymentProviderProps) {
|
|
21
|
+
return (
|
|
22
|
+
<PaymentAdapterContext.Provider value={adapter}>
|
|
23
|
+
{children}
|
|
24
|
+
</PaymentAdapterContext.Provider>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Read the injected adapter. Throws if no <PaymentProvider> is mounted. */
|
|
29
|
+
export function usePaymentAdapter(): PaymentProviderAdapter {
|
|
30
|
+
const adapter = useContext(PaymentAdapterContext);
|
|
31
|
+
if (!adapter) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
'usePaymentAdapter must be used within a <PaymentProvider adapter={…}>. ' +
|
|
34
|
+
'Inject a mock or stripe adapter at the app root.',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return adapter;
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @djangocfg/payments — domain barrel
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
Currency,
|
|
7
|
+
MinorUnits,
|
|
8
|
+
PaymentProviderId,
|
|
9
|
+
PaymentReference,
|
|
10
|
+
PaymentStatus,
|
|
11
|
+
PaymentIntent,
|
|
12
|
+
CreatePaymentInput,
|
|
13
|
+
ConfirmResult,
|
|
14
|
+
ConfirmArgs,
|
|
15
|
+
ConfirmType,
|
|
16
|
+
StartSubscriptionInput,
|
|
17
|
+
SubscriptionIntent,
|
|
18
|
+
PaymentRecord,
|
|
19
|
+
PaymentPage,
|
|
20
|
+
PaymentProviderAdapter,
|
|
21
|
+
} from './types';
|
|
22
|
+
|
|
23
|
+
export { toMinorUnits, toMajorUnits, formatAmount } from './money';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @djangocfg/payments — money helpers (framework-free)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Amounts cross the package boundary as MinorUnits (integer cents) to avoid
|
|
5
|
+
// float drift. These helpers convert at the display boundary only.
|
|
6
|
+
|
|
7
|
+
import type { Currency, MinorUnits } from './types';
|
|
8
|
+
|
|
9
|
+
/** Currencies with no minor unit (amount == major unit). Extend as needed. */
|
|
10
|
+
const ZERO_DECIMAL_CURRENCIES = new Set([
|
|
11
|
+
'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
|
|
12
|
+
'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function fractionDigits(currency: Currency): number {
|
|
16
|
+
return ZERO_DECIMAL_CURRENCIES.has(currency.toLowerCase()) ? 0 : 2;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Convert a decimal major-unit amount (e.g. 49.0 or "49.00") to minor units. */
|
|
20
|
+
export function toMinorUnits(amount: number | string, currency: Currency): MinorUnits {
|
|
21
|
+
const value = typeof amount === 'string' ? Number.parseFloat(amount) : amount;
|
|
22
|
+
if (!Number.isFinite(value)) return 0;
|
|
23
|
+
const factor = 10 ** fractionDigits(currency);
|
|
24
|
+
return Math.round(value * factor);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Convert minor units back to a major-unit number. */
|
|
28
|
+
export function toMajorUnits(minor: MinorUnits, currency: Currency): number {
|
|
29
|
+
const factor = 10 ** fractionDigits(currency);
|
|
30
|
+
return minor / factor;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Format minor units as a localized currency string (e.g. "$49.00"). */
|
|
34
|
+
export function formatAmount(
|
|
35
|
+
minor: MinorUnits,
|
|
36
|
+
currency: Currency,
|
|
37
|
+
locale?: string,
|
|
38
|
+
): string {
|
|
39
|
+
try {
|
|
40
|
+
return new Intl.NumberFormat(locale, {
|
|
41
|
+
style: 'currency',
|
|
42
|
+
currency: currency.toUpperCase(),
|
|
43
|
+
}).format(toMajorUnits(minor, currency));
|
|
44
|
+
} catch {
|
|
45
|
+
// Unknown currency code → fall back to a plain number + code.
|
|
46
|
+
return `${toMajorUnits(minor, currency).toFixed(fractionDigits(currency))} ${currency.toUpperCase()}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @djangocfg/payments — domain types (framework-free: no React, no Stripe)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// This is the stable contract the host app compiles against. Package code
|
|
5
|
+
// depends on these types and the PaymentProviderAdapter seam — never on a
|
|
6
|
+
// concrete SDK or a generated API client.
|
|
7
|
+
|
|
8
|
+
/** ISO 4217 currency, lowercase (e.g. 'usd'). */
|
|
9
|
+
export type Currency = string;
|
|
10
|
+
|
|
11
|
+
/** Amount in the smallest unit of the currency (e.g. cents for USD). */
|
|
12
|
+
export type MinorUnits = number;
|
|
13
|
+
|
|
14
|
+
/** Provider identifier. `(string & {})` keeps the union open for future providers. */
|
|
15
|
+
export type PaymentProviderId = 'stripe' | 'mock' | (string & {});
|
|
16
|
+
|
|
17
|
+
/** What an app entity a payment settles. */
|
|
18
|
+
export interface PaymentReference {
|
|
19
|
+
kind: 'order' | 'subscription' | 'addon';
|
|
20
|
+
id: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The checkout state machine. Shared by `useCheckout` and the mock adapter so
|
|
25
|
+
* the whole UX can be exercised before any provider exists.
|
|
26
|
+
*/
|
|
27
|
+
export type PaymentStatus =
|
|
28
|
+
| 'idle'
|
|
29
|
+
| 'creating' // creating the PaymentIntent on the backend
|
|
30
|
+
| 'requires_payment' // client_secret ready, awaiting payment details
|
|
31
|
+
| 'processing' // confirming with the provider
|
|
32
|
+
| 'requires_action' // 3DS / redirect needed
|
|
33
|
+
| 'succeeded'
|
|
34
|
+
| 'failed'
|
|
35
|
+
| 'canceled';
|
|
36
|
+
|
|
37
|
+
/** A payment in flight or settled. Mirrors a provider intent, normalized. */
|
|
38
|
+
export interface PaymentIntent {
|
|
39
|
+
/** Provider intent id (e.g. `pi_…` for Stripe). */
|
|
40
|
+
id: string;
|
|
41
|
+
/** Provider client secret used to confirm on the frontend (Stripe Elements). */
|
|
42
|
+
clientSecret: string | null;
|
|
43
|
+
amount: MinorUnits;
|
|
44
|
+
currency: Currency;
|
|
45
|
+
status: PaymentStatus;
|
|
46
|
+
reference: PaymentReference;
|
|
47
|
+
provider: PaymentProviderId;
|
|
48
|
+
/** ISO timestamp. */
|
|
49
|
+
createdAt: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Input to create a payment intent. Amount is authoritative server-side. */
|
|
53
|
+
export interface CreatePaymentInput {
|
|
54
|
+
amount: MinorUnits;
|
|
55
|
+
currency: Currency;
|
|
56
|
+
reference: PaymentReference;
|
|
57
|
+
/** Provider-specific extras (coupon, save card, return_url…). */
|
|
58
|
+
metadata?: Record<string, string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Result of confirming a payment on the frontend. */
|
|
62
|
+
export interface ConfirmResult {
|
|
63
|
+
status: PaymentStatus;
|
|
64
|
+
intentId: string;
|
|
65
|
+
error?: string;
|
|
66
|
+
/** Set when the provider needs a redirect / 3DS step. */
|
|
67
|
+
nextActionUrl?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A historical payment row for <PaymentHistory>. */
|
|
71
|
+
export interface PaymentRecord {
|
|
72
|
+
id: string;
|
|
73
|
+
amount: MinorUnits;
|
|
74
|
+
currency: Currency;
|
|
75
|
+
status: PaymentStatus;
|
|
76
|
+
reference: PaymentReference;
|
|
77
|
+
provider: PaymentProviderId;
|
|
78
|
+
createdAt: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Page of payment records. */
|
|
82
|
+
export interface PaymentPage {
|
|
83
|
+
items: PaymentRecord[];
|
|
84
|
+
nextCursor?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Which client-side confirm to run (deferred subscription flow). For a one-time
|
|
89
|
+
* PaymentIntent this is always `'payment'` (the default). For a subscription it
|
|
90
|
+
* comes from the backend: `'payment'` when a first invoice is due
|
|
91
|
+
* (`stripe.confirmPayment`), `'setup'` for a $0/trial card-on-file
|
|
92
|
+
* (`stripe.confirmSetup`).
|
|
93
|
+
*/
|
|
94
|
+
export type ConfirmType = 'payment' | 'setup';
|
|
95
|
+
|
|
96
|
+
/** Arguments for the adapter's confirm step. */
|
|
97
|
+
export interface ConfirmArgs {
|
|
98
|
+
clientSecret: string;
|
|
99
|
+
/** Provider-native confirm payload, e.g. a Stripe Elements instance. */
|
|
100
|
+
elementsContext?: unknown;
|
|
101
|
+
returnUrl?: string;
|
|
102
|
+
/** Dispatch confirmPayment vs confirmSetup. Default `'payment'`. */
|
|
103
|
+
confirmType?: ConfirmType;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Input to start a subscription checkout (deferred flow). */
|
|
107
|
+
export interface StartSubscriptionInput {
|
|
108
|
+
plan: string;
|
|
109
|
+
cycle: 'monthly' | 'annual' | (string & {});
|
|
110
|
+
/** Prefill for Stripe Link / receipt. */
|
|
111
|
+
email?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The backend's deferred-subscription handle, normalized. The subscription is
|
|
116
|
+
* created `incomplete`; the client confirms `clientSecret` with the call named
|
|
117
|
+
* by `confirmType`. `clientSecret` is null only when there is nothing to
|
|
118
|
+
* confirm (a Free plan subscribed directly — the host handles that BEFORE
|
|
119
|
+
* calling the adapter, so in practice the adapter always gets a secret).
|
|
120
|
+
*/
|
|
121
|
+
export interface SubscriptionIntent {
|
|
122
|
+
stripeSubscriptionId: string;
|
|
123
|
+
status: string;
|
|
124
|
+
confirmType: ConfirmType;
|
|
125
|
+
clientSecret: string | null;
|
|
126
|
+
provider: PaymentProviderId;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Provider-agnostic seam. The HOST injects one implementation via
|
|
131
|
+
* <PaymentProvider adapter={…}>. Package code depends on THIS interface,
|
|
132
|
+
* never on a concrete SDK or a generated client.
|
|
133
|
+
*/
|
|
134
|
+
export interface PaymentProviderAdapter {
|
|
135
|
+
readonly id: PaymentProviderId;
|
|
136
|
+
/** Backend creates the intent; returns a client_secret to confirm with. */
|
|
137
|
+
createIntent(input: CreatePaymentInput): Promise<PaymentIntent>;
|
|
138
|
+
/**
|
|
139
|
+
* Confirm the intent on the frontend. Dispatches on `args.confirmType`:
|
|
140
|
+
* `'payment'` → `stripe.confirmPayment`, `'setup'` → `stripe.confirmSetup`.
|
|
141
|
+
*/
|
|
142
|
+
confirm(args: ConfirmArgs): Promise<ConfirmResult>;
|
|
143
|
+
/**
|
|
144
|
+
* Optional (subscription providers): backend creates an `incomplete` Stripe
|
|
145
|
+
* subscription (deferred flow) and returns a `clientSecret` + `confirmType`.
|
|
146
|
+
* Absent on the mock/one-time-only adapters.
|
|
147
|
+
*/
|
|
148
|
+
createSubscription?(input: StartSubscriptionInput): Promise<SubscriptionIntent>;
|
|
149
|
+
/** Optional: poll/retrieve status after a redirect. */
|
|
150
|
+
retrieve?(intentId: string): Promise<PaymentIntent>;
|
|
151
|
+
/** Optional: paginated history for <PaymentHistory>. */
|
|
152
|
+
listPayments?(params?: { limit?: number; cursor?: string }): Promise<PaymentPage>;
|
|
153
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @djangocfg/payments — hooks barrel
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
export { useCheckout } from './useCheckout';
|
|
6
|
+
export type { UseCheckoutResult } from './useCheckout';
|
|
7
|
+
|
|
8
|
+
export { usePaymentHistory } from './usePaymentHistory';
|
|
9
|
+
export type { UsePaymentHistoryProps, UsePaymentHistoryResult } from './usePaymentHistory';
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// @djangocfg/payments — useCheckout (the checkout state machine)
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Owns the explicit state machine over the injected adapter:
|
|
7
|
+
// idle → creating → requires_payment → processing → succeeded | failed | requires_action
|
|
8
|
+
// Reads the adapter via usePaymentAdapter(); never touches a concrete SDK.
|
|
9
|
+
|
|
10
|
+
import { useCallback, useRef, useState } from 'react';
|
|
11
|
+
import { usePaymentAdapter } from '../context/PaymentProvider';
|
|
12
|
+
import type {
|
|
13
|
+
ConfirmArgs,
|
|
14
|
+
ConfirmType,
|
|
15
|
+
CreatePaymentInput,
|
|
16
|
+
PaymentIntent,
|
|
17
|
+
PaymentStatus,
|
|
18
|
+
StartSubscriptionInput,
|
|
19
|
+
} from '../domain/types';
|
|
20
|
+
|
|
21
|
+
export interface UseCheckoutResult {
|
|
22
|
+
status: PaymentStatus;
|
|
23
|
+
intent: PaymentIntent | null;
|
|
24
|
+
error: string | null;
|
|
25
|
+
/** Set when the provider needs a redirect / 3DS step. */
|
|
26
|
+
nextActionUrl: string | null;
|
|
27
|
+
/** Step 1 (one-time): create the PaymentIntent → client_secret ready. */
|
|
28
|
+
start: (input: CreatePaymentInput) => Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Step 1 (subscription, deferred flow): create an `incomplete` Stripe
|
|
31
|
+
* subscription on the backend → client_secret + confirmType ready. `confirm`
|
|
32
|
+
* then dispatches confirmPayment vs confirmSetup automatically.
|
|
33
|
+
*/
|
|
34
|
+
startSubscription: (input: StartSubscriptionInput) => Promise<void>;
|
|
35
|
+
/** Step 2: confirm with the provider (Stripe Elements or mock). */
|
|
36
|
+
confirm: (
|
|
37
|
+
elementsContext?: ConfirmArgs['elementsContext'],
|
|
38
|
+
returnUrl?: string,
|
|
39
|
+
) => Promise<void>;
|
|
40
|
+
reset: () => void;
|
|
41
|
+
/** True while a network step is in flight. */
|
|
42
|
+
isBusy: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useCheckout(): UseCheckoutResult {
|
|
46
|
+
const adapter = usePaymentAdapter();
|
|
47
|
+
|
|
48
|
+
const [status, setStatus] = useState<PaymentStatus>('idle');
|
|
49
|
+
const [intent, setIntent] = useState<PaymentIntent | null>(null);
|
|
50
|
+
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
const [nextActionUrl, setNextActionUrl] = useState<string | null>(null);
|
|
52
|
+
|
|
53
|
+
// Keep the latest intent for confirm() without stale closures.
|
|
54
|
+
const intentRef = useRef<PaymentIntent | null>(null);
|
|
55
|
+
// Which confirm call step 2 must run (subscription setup vs payment). One-time
|
|
56
|
+
// checkout leaves this at 'payment'.
|
|
57
|
+
const confirmTypeRef = useRef<ConfirmType>('payment');
|
|
58
|
+
|
|
59
|
+
const reset = useCallback(() => {
|
|
60
|
+
setStatus('idle');
|
|
61
|
+
setIntent(null);
|
|
62
|
+
setError(null);
|
|
63
|
+
setNextActionUrl(null);
|
|
64
|
+
intentRef.current = null;
|
|
65
|
+
confirmTypeRef.current = 'payment';
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const start = useCallback(
|
|
69
|
+
async (input: CreatePaymentInput) => {
|
|
70
|
+
setError(null);
|
|
71
|
+
setNextActionUrl(null);
|
|
72
|
+
setStatus('creating');
|
|
73
|
+
confirmTypeRef.current = 'payment';
|
|
74
|
+
try {
|
|
75
|
+
const created = await adapter.createIntent(input);
|
|
76
|
+
intentRef.current = created;
|
|
77
|
+
setIntent(created);
|
|
78
|
+
setStatus(created.status === 'idle' ? 'requires_payment' : created.status);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
setStatus('failed');
|
|
81
|
+
setError(e instanceof Error ? e.message : 'Failed to start payment.');
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
[adapter],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const startSubscription = useCallback(
|
|
88
|
+
async (input: StartSubscriptionInput) => {
|
|
89
|
+
setError(null);
|
|
90
|
+
setNextActionUrl(null);
|
|
91
|
+
setStatus('creating');
|
|
92
|
+
if (!adapter.createSubscription) {
|
|
93
|
+
setStatus('failed');
|
|
94
|
+
setError('This payment provider does not support subscriptions.');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const sub = await adapter.createSubscription(input);
|
|
99
|
+
confirmTypeRef.current = sub.confirmType;
|
|
100
|
+
// Normalize into the same PaymentIntent shape the confirm step reads —
|
|
101
|
+
// the subscription id stands in for `id`, the deferred client_secret
|
|
102
|
+
// drives Elements exactly like a one-time intent.
|
|
103
|
+
const asIntent: PaymentIntent = {
|
|
104
|
+
id: sub.stripeSubscriptionId,
|
|
105
|
+
clientSecret: sub.clientSecret,
|
|
106
|
+
amount: 0,
|
|
107
|
+
currency: 'usd',
|
|
108
|
+
status: 'requires_payment',
|
|
109
|
+
reference: { kind: 'subscription', id: sub.stripeSubscriptionId },
|
|
110
|
+
provider: sub.provider,
|
|
111
|
+
createdAt: new Date().toISOString(),
|
|
112
|
+
};
|
|
113
|
+
intentRef.current = asIntent;
|
|
114
|
+
setIntent(asIntent);
|
|
115
|
+
setStatus('requires_payment');
|
|
116
|
+
} catch (e) {
|
|
117
|
+
setStatus('failed');
|
|
118
|
+
setError(e instanceof Error ? e.message : 'Failed to start subscription.');
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[adapter],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const confirm = useCallback(
|
|
125
|
+
async (elementsContext?: ConfirmArgs['elementsContext'], returnUrl?: string) => {
|
|
126
|
+
const current = intentRef.current;
|
|
127
|
+
if (!current?.clientSecret) {
|
|
128
|
+
setStatus('failed');
|
|
129
|
+
setError('Payment is not ready to confirm.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setError(null);
|
|
134
|
+
setStatus('processing');
|
|
135
|
+
try {
|
|
136
|
+
const result = await adapter.confirm({
|
|
137
|
+
clientSecret: current.clientSecret,
|
|
138
|
+
elementsContext,
|
|
139
|
+
returnUrl,
|
|
140
|
+
confirmType: confirmTypeRef.current,
|
|
141
|
+
});
|
|
142
|
+
setStatus(result.status);
|
|
143
|
+
if (result.error) setError(result.error);
|
|
144
|
+
if (result.nextActionUrl) setNextActionUrl(result.nextActionUrl);
|
|
145
|
+
|
|
146
|
+
const updated: PaymentIntent = { ...current, status: result.status };
|
|
147
|
+
intentRef.current = updated;
|
|
148
|
+
setIntent(updated);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
setStatus('failed');
|
|
151
|
+
setError(e instanceof Error ? e.message : 'Payment failed.');
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
[adapter],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const isBusy = status === 'creating' || status === 'processing';
|
|
158
|
+
|
|
159
|
+
return { status, intent, error, nextActionUrl, start, startSubscription, confirm, reset, isBusy };
|
|
160
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// @djangocfg/payments — usePaymentHistory
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Loads paginated payment records via the injected adapter's optional
|
|
7
|
+
// listPayments(). Returns an empty list (not an error) when the adapter
|
|
8
|
+
// doesn't support history.
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
11
|
+
import { usePaymentAdapter } from '../context/PaymentProvider';
|
|
12
|
+
import type { PaymentRecord } from '../domain/types';
|
|
13
|
+
|
|
14
|
+
export interface UsePaymentHistoryResult {
|
|
15
|
+
records: PaymentRecord[];
|
|
16
|
+
loading: boolean;
|
|
17
|
+
error: string | null;
|
|
18
|
+
nextCursor: string | null;
|
|
19
|
+
/** Reload from the start. */
|
|
20
|
+
refresh: () => Promise<void>;
|
|
21
|
+
/** Append the next page (if any). */
|
|
22
|
+
loadMore: () => Promise<void>;
|
|
23
|
+
/** Whether the adapter supports history at all. */
|
|
24
|
+
supported: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UsePaymentHistoryProps {
|
|
28
|
+
limit?: number;
|
|
29
|
+
/** Load on mount. Default true. */
|
|
30
|
+
autoLoad?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function usePaymentHistory(
|
|
34
|
+
{ limit, autoLoad = true }: UsePaymentHistoryProps = {},
|
|
35
|
+
): UsePaymentHistoryResult {
|
|
36
|
+
const adapter = usePaymentAdapter();
|
|
37
|
+
const supported = typeof adapter.listPayments === 'function';
|
|
38
|
+
|
|
39
|
+
const [records, setRecords] = useState<PaymentRecord[]>([]);
|
|
40
|
+
const [loading, setLoading] = useState(false);
|
|
41
|
+
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
43
|
+
|
|
44
|
+
const refresh = useCallback(async () => {
|
|
45
|
+
if (!adapter.listPayments) return;
|
|
46
|
+
setLoading(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
const page = await adapter.listPayments({ limit });
|
|
50
|
+
setRecords(page.items);
|
|
51
|
+
setNextCursor(page.nextCursor ?? null);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
setError(e instanceof Error ? e.message : 'Failed to load payments.');
|
|
54
|
+
} finally {
|
|
55
|
+
setLoading(false);
|
|
56
|
+
}
|
|
57
|
+
}, [adapter, limit]);
|
|
58
|
+
|
|
59
|
+
const loadMore = useCallback(async () => {
|
|
60
|
+
if (!adapter.listPayments || !nextCursor) return;
|
|
61
|
+
setLoading(true);
|
|
62
|
+
setError(null);
|
|
63
|
+
try {
|
|
64
|
+
const page = await adapter.listPayments({ limit, cursor: nextCursor });
|
|
65
|
+
setRecords((prev) => [...prev, ...page.items]);
|
|
66
|
+
setNextCursor(page.nextCursor ?? null);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
setError(e instanceof Error ? e.message : 'Failed to load payments.');
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
72
|
+
}, [adapter, limit, nextCursor]);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (autoLoad && supported) void refresh();
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, [autoLoad, supported]);
|
|
78
|
+
|
|
79
|
+
return { records, loading, error, nextCursor, refresh, loadMore, supported };
|
|
80
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @djangocfg/payments - Provider-agnostic React payments module (Stripe-first)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// The host injects ONE adapter (mock or stripe) via <PaymentProvider>.
|
|
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.
|
|
9
|
+
|
|
10
|
+
// Domain (types + money helpers)
|
|
11
|
+
export * from './domain';
|
|
12
|
+
|
|
13
|
+
// Provider context (the injected transport seam)
|
|
14
|
+
export { PaymentProvider, usePaymentAdapter } from './context/PaymentProvider';
|
|
15
|
+
export type { PaymentProviderProps } from './context/PaymentProvider';
|
|
16
|
+
|
|
17
|
+
// Checkout guard (dialog-owned in-flight lock — gates accidental close)
|
|
18
|
+
export {
|
|
19
|
+
CheckoutGuardProvider,
|
|
20
|
+
useCheckoutGuard,
|
|
21
|
+
usePublishCheckoutStatus,
|
|
22
|
+
isCheckoutLocked,
|
|
23
|
+
} from './context/CheckoutGuard';
|
|
24
|
+
|
|
25
|
+
// Transport adapters (mock + stripe)
|
|
26
|
+
export {
|
|
27
|
+
createMockPaymentAdapter,
|
|
28
|
+
createStripeAdapter,
|
|
29
|
+
} from './transport';
|
|
30
|
+
export type {
|
|
31
|
+
MockAdapterConfig,
|
|
32
|
+
MockOutcome,
|
|
33
|
+
StripeAdapterConfig,
|
|
34
|
+
BackendIntent,
|
|
35
|
+
BackendSubscription,
|
|
36
|
+
} from './transport';
|
|
37
|
+
|
|
38
|
+
// Hooks
|
|
39
|
+
export { useCheckout, usePaymentHistory } from './hooks';
|
|
40
|
+
export type {
|
|
41
|
+
UseCheckoutResult,
|
|
42
|
+
UsePaymentHistoryProps,
|
|
43
|
+
UsePaymentHistoryResult,
|
|
44
|
+
} from './hooks';
|
|
45
|
+
|
|
46
|
+
// Components
|
|
47
|
+
export {
|
|
48
|
+
CheckoutDialog,
|
|
49
|
+
CheckoutForm,
|
|
50
|
+
StripePaymentElement,
|
|
51
|
+
DEFAULT_PAYMENT_ELEMENT_OPTIONS,
|
|
52
|
+
buildStripeAppearance,
|
|
53
|
+
PaymentHistory,
|
|
54
|
+
PaymentStatusBadge,
|
|
55
|
+
} from './components';
|
|
56
|
+
export type {
|
|
57
|
+
CheckoutDialogProps,
|
|
58
|
+
CheckoutFormProps,
|
|
59
|
+
StripePaymentElementProps,
|
|
60
|
+
StripeElementsContext,
|
|
61
|
+
AppearanceTokens,
|
|
62
|
+
PaymentHistoryProps,
|
|
63
|
+
PaymentStatusBadgeProps,
|
|
64
|
+
} from './components';
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailwind v4 Source Detection for the Payments Package
|
|
3
|
+
*
|
|
4
|
+
* This package is consumed from source and styled with Tailwind utility
|
|
5
|
+
* classes. Tailwind v4 only generates classes it can see — consumers MUST
|
|
6
|
+
* import this entry in their global CSS (after ui-core styles) so the
|
|
7
|
+
* package's sources get scanned:
|
|
8
|
+
*
|
|
9
|
+
* @import "@djangocfg/payments/styles";
|
|
10
|
+
*
|
|
11
|
+
* Without it, status-tint classes (e.g. the PaymentStatusBadge chips) are
|
|
12
|
+
* silently missing from the build.
|
|
13
|
+
*/
|
|
14
|
+
@source "../**/*.{ts,tsx}";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @djangocfg/payments — transport barrel
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
export { createMockPaymentAdapter } from './mock-adapter';
|
|
6
|
+
export type { MockAdapterConfig, MockOutcome } from './mock-adapter';
|
|
7
|
+
|
|
8
|
+
export { createStripeAdapter } from './stripe-adapter';
|
|
9
|
+
export type {
|
|
10
|
+
StripeAdapterConfig,
|
|
11
|
+
BackendIntent,
|
|
12
|
+
BackendSubscription,
|
|
13
|
+
} from './stripe-adapter';
|