@djangocfg/payments 2.1.561 โ 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/README.md
CHANGED
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Provider-agnostic React payments module. **Stripe-first**, but the package
|
|
4
4
|
never depends on a concrete SDK โ the host injects an adapter. Battle-tested
|
|
5
|
-
in production (one-time PaymentIntent checkout
|
|
6
|
-
Stripe Link) before extraction into `@djangocfg/*`.
|
|
5
|
+
in production (one-time PaymentIntent checkout, recurring subscriptions with
|
|
6
|
+
Stripe Link, card on file) before extraction into `@djangocfg/*`.
|
|
7
|
+
|
|
8
|
+
๐ **[Full documentation โ](./@docs/README.md)** โ architecture, the server
|
|
9
|
+
contract, Stripe traps, testing.
|
|
7
10
|
|
|
8
11
|
## The seam
|
|
9
12
|
|
|
10
13
|
The host picks **one** adapter and injects it via `<PaymentProvider>`. Package
|
|
11
|
-
hooks/components read it through `usePaymentAdapter()` and never import Stripe
|
|
12
|
-
a generated API client directly.
|
|
14
|
+
hooks/components read it through `usePaymentAdapter()` and never import Stripe
|
|
15
|
+
or a generated API client directly.
|
|
13
16
|
|
|
14
17
|
```tsx
|
|
15
18
|
import { PaymentProvider, createMockPaymentAdapter } from '@djangocfg/payments';
|
|
16
19
|
|
|
17
|
-
// Mock-first: build & verify the whole
|
|
20
|
+
// Mock-first: build & verify the whole UX before any backend exists.
|
|
18
21
|
<PaymentProvider adapter={createMockPaymentAdapter()}>
|
|
19
22
|
{children}
|
|
20
23
|
</PaymentProvider>
|
|
@@ -38,18 +41,21 @@ const adapter = createStripeAdapter({
|
|
|
38
41
|
## Layering
|
|
39
42
|
|
|
40
43
|
```
|
|
41
|
-
domain/ pure types + money helpers (no React, no Stripe)
|
|
42
|
-
context/ <PaymentProvider> +
|
|
43
|
-
transport/ mock-adapter | stripe-adapter
|
|
44
|
-
hooks/ useCheckout, usePaymentHistory
|
|
45
|
-
components/ CheckoutForm, StripePaymentElement,
|
|
44
|
+
domain/ pure types + money/card helpers (no React, no Stripe)
|
|
45
|
+
context/ <PaymentProvider> + <CheckoutGuard> (the seam, the in-flight lock)
|
|
46
|
+
transport/ mock-adapter | stripe-adapter (the SDK seam; see the note below)
|
|
47
|
+
hooks/ useCheckout, useCardSetup, usePaymentHistory, useSavedCards
|
|
48
|
+
components/ CheckoutForm, CardSetupForm, StripePaymentElement, dialogs, tables, badges
|
|
46
49
|
```
|
|
47
50
|
|
|
48
|
-
`@stripe/*` is
|
|
49
|
-
`components/StripePaymentElement.tsx`) and
|
|
51
|
+
`@stripe/*` is a **runtime** dependency of exactly two files
|
|
52
|
+
(`transport/stripe-adapter.ts`, `components/StripePaymentElement.tsx`) and a
|
|
53
|
+
**type-only** one of a third (`components/appearance.ts`) โ enforced by
|
|
54
|
+
`scripts/check-stripe-imports.mjs`, which `pnpm check` runs.
|
|
50
55
|
`@stripe/stripe-js` is only the tiny CDN loader (the real SDK loads from
|
|
51
|
-
js.stripe.com at runtime),
|
|
52
|
-
mock-only bundles.
|
|
56
|
+
js.stripe.com at runtime), so tree-shaking drops the Stripe components from
|
|
57
|
+
mock-only bundles. See [`@docs/ARCHITECTURE.md`](./@docs/ARCHITECTURE.md) for
|
|
58
|
+
why this is one package and where the boundaries are.
|
|
53
59
|
|
|
54
60
|
## Styles (Tailwind v4) โ required wiring
|
|
55
61
|
|
|
@@ -73,16 +79,22 @@ Next.js hosts also add the package to `transpilePackages`.
|
|
|
73
79
|
| Export | Kind | Purpose |
|
|
74
80
|
|---|---|---|
|
|
75
81
|
| `PaymentProvider` / `usePaymentAdapter` | context | inject / read the adapter |
|
|
76
|
-
| `createMockPaymentAdapter` | adapter | full fake state machine (success / declined / 3DS) |
|
|
77
|
-
| `createStripeAdapter` | adapter | real Stripe (host supplies key +
|
|
82
|
+
| `createMockPaymentAdapter` | adapter | full fake state machine (success / declined / 3DS / cards) |
|
|
83
|
+
| `createStripeAdapter` | adapter | real Stripe (host supplies key + backend calls) |
|
|
78
84
|
| `useCheckout` | hook | `idle โ creating โ requires_payment โ processing โ succeeded\|failed\|requires_action` |
|
|
85
|
+
| `useCardSetup` | hook | the same machine for saving a card with no charge |
|
|
86
|
+
| `useSavedCards` | hook | cards on file as a phase (`unavailable\|loading\|ready\|error`) + set-default / remove |
|
|
79
87
|
| `usePaymentHistory` | hook | paginated history via `adapter.listPayments` |
|
|
80
|
-
| `CheckoutDialog` | component | close-locked dialog shell
|
|
81
|
-
| `CheckoutForm` | component | provider-agnostic form shell
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
88
|
+
| `CheckoutDialog` | component | close-locked dialog shell; wrap your checkout body in it |
|
|
89
|
+
| `CheckoutForm` | component | provider-agnostic form shell; inject the field via `paymentField` |
|
|
90
|
+
| `CardSetupForm` | component | the same, for saving a card (no amount, no "Paid") |
|
|
91
|
+
| `UpdateCardDialog` | component | the whole add-a-card flow: intent on open, confirm, report |
|
|
92
|
+
| `SavedCardList` | component | cards table: default badge, expiry warning, row actions |
|
|
93
|
+
| `StripePaymentElement` | component | `<Elements>` + `<PaymentElement>`; render-props the live Elements |
|
|
94
|
+
| `PaymentHistory` | component | sortable table of `PaymentRecord` |
|
|
95
|
+
| `PaymentStatusBadge` | component | status pill on ui-core semantic tokens |
|
|
96
|
+
| `toMinorUnits` / `toMajorUnits` / `formatAmount` | util | money at the boundary (integer minor units) |
|
|
97
|
+
| `cardExpiresAt` / `isCardExpired` / `isCardExpiringSoon` / `formatCardExpiry` / `sortCards` / `cardsOf` | util | card derivations against a clock |
|
|
86
98
|
|
|
87
99
|
## Conventions
|
|
88
100
|
|
|
@@ -91,78 +103,78 @@ Next.js hosts also add the package to `transpilePackages`.
|
|
|
91
103
|
widget (`widgets/CLAUDE.md`).
|
|
92
104
|
- Does **not** mount its own `<UiProviders>` โ the host provides it once.
|
|
93
105
|
- Locale-free: user-facing labels are props/slots (host supplies i18n).
|
|
94
|
-
- Verify with `pnpm -F @djangocfg/payments check`
|
|
106
|
+
- Verify with `pnpm -F @djangocfg/payments check` and `โฆ lint`.
|
|
107
|
+
|
|
108
|
+
## Card on file
|
|
109
|
+
|
|
110
|
+
A subscription outlives the card that pays for it: when a renewal fails Stripe
|
|
111
|
+
retries for ~2 weeks **against the same stored card**, then gives up. Without
|
|
112
|
+
a way to replace it, a customer who wanted to pay loses the subscription.
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
const { phase, canAddCard, canManageCards, refresh, setDefault, remove, isBusy } =
|
|
116
|
+
useSavedCards();
|
|
117
|
+
|
|
118
|
+
<SavedCardList
|
|
119
|
+
phase={phase}
|
|
120
|
+
busy={isBusy}
|
|
121
|
+
onAddCard={canAddCard ? () => setOpen(true) : undefined}
|
|
122
|
+
onSetDefault={canManageCards ? setDefault : undefined}
|
|
123
|
+
onRemove={canManageCards ? remove : undefined}
|
|
124
|
+
/>
|
|
125
|
+
<UpdateCardDialog open={open} onClose={close} onSaved={() => { close(); void refresh(); }} />
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Four host endpoints, wired all-or-none, plus four server rules that fail
|
|
129
|
+
silently โ see [`@docs/CARD_ON_FILE.md`](./@docs/CARD_ON_FILE.md) and
|
|
130
|
+
[`@docs/SERVER_CONTRACT.md`](./@docs/SERVER_CONTRACT.md).
|
|
95
131
|
|
|
96
|
-
|
|
132
|
+
> **Not Link, not the Billing Portal.** Link is a buyer-side wallet โ
|
|
133
|
+
> `link.com` does not know your subscription exists, and a renewal charges the
|
|
134
|
+
> stored `pm_โฆ`. The Billing Portal is a server-side redirect that discards
|
|
135
|
+
> this package's theming and the host's locale. Reasoning in
|
|
136
|
+
> [`@docs/STRIPE_NOTES.md`](./@docs/STRIPE_NOTES.md).
|
|
97
137
|
|
|
98
138
|
## Configuration & keys
|
|
99
139
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
differ. Bring up **test first**, verify end-to-end, then switch to live.
|
|
140
|
+
Three Stripe keys, split by where they're safe to live. Stripe ships **test**
|
|
141
|
+
(`sk_test_โฆ`, `pk_test_โฆ`) and **live** (`sk_live_โฆ`, `pk_live_โฆ`) flavours
|
|
142
|
+
with the **same variable names**; only the values differ. Bring up test first.
|
|
104
143
|
|
|
105
144
|
| Key | Prefix | Lives in | Exposed to browser? |
|
|
106
145
|
|---|---|---|---|
|
|
107
146
|
| Secret key | `sk_test_โฆ` / `sk_live_โฆ` | **Backend only** (`STRIPE__SECRET_KEY`) | โ never |
|
|
108
147
|
| Webhook signing secret | `whsec_โฆ` | **Backend only** (`STRIPE__WEBHOOK_SECRET`) | โ never |
|
|
109
|
-
| Publishable key | `pk_test_โฆ` / `pk_live_โฆ` | Backend + Frontend | โ
safe (
|
|
110
|
-
|
|
111
|
-
> The publishable key is typically returned to the browser by the backend's
|
|
112
|
-
> create-checkout response (`publishable_key`) and/or read from
|
|
113
|
-
> `NEXT_PUBLIC_STRIPE_PK`. The secret and webhook keys **never** leave the server.
|
|
114
|
-
|
|
115
|
-
### Backend (Django) โ `STRIPE__*`
|
|
116
|
-
|
|
117
|
-
This package is frontend-only; it pairs with any Django backend that creates
|
|
118
|
-
PaymentIntents (a built-in `django_cfg.apps.payments` module is planned โ see
|
|
119
|
-
`@dev/active/payments-v1`). Paths below are the reference host's; adjust env
|
|
120
|
-
names to your backend. Set the keys server-side (git-ignored secrets file):
|
|
148
|
+
| Publishable key | `pk_test_โฆ` / `pk_live_โฆ` | Backend + Frontend | โ
safe (public by design) |
|
|
121
149
|
|
|
122
150
|
```dotenv
|
|
123
|
-
#
|
|
151
|
+
# backend, git-ignored secrets file
|
|
124
152
|
STRIPE__SECRET_KEY=sk_test_xxx
|
|
125
153
|
STRIPE__PUBLISHABLE_KEY=pk_test_xxx
|
|
126
154
|
STRIPE__WEBHOOK_SECRET=whsec_xxx # comma-separate to rotate: whsec_new,whsec_old
|
|
127
155
|
|
|
128
|
-
#
|
|
129
|
-
# STRIPE__SECRET_KEY=sk_live_xxx
|
|
130
|
-
# STRIPE__PUBLISHABLE_KEY=pk_live_xxx
|
|
131
|
-
# STRIPE__WEBHOOK_SECRET=whsec_live_xxx
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
### Frontend โ `NEXT_PUBLIC_STRIPE_PK`
|
|
135
|
-
|
|
136
|
-
```dotenv
|
|
137
|
-
# your Next.js app .env (test โ pk_test_โฆ, live โ pk_live_โฆ)
|
|
156
|
+
# your Next.js app .env
|
|
138
157
|
NEXT_PUBLIC_STRIPE_PK=pk_test_xxx
|
|
139
158
|
```
|
|
140
159
|
|
|
141
160
|
With **no** `NEXT_PUBLIC_STRIPE_PK` set, the host falls back to the **mock
|
|
142
|
-
adapter** โ the whole
|
|
143
|
-
mock-first dev path.
|
|
161
|
+
adapter** โ the whole UX still renders (no real charge).
|
|
144
162
|
|
|
145
|
-
> โ ๏ธ
|
|
146
|
-
>
|
|
147
|
-
> `.env*` files are tracked by git, add the secrets file to `.gitignore` and
|
|
148
|
-
> `git rm --cached` it first. Only `pk_โฆ` (publishable) is safe in tracked config.
|
|
163
|
+
> โ ๏ธ The secret (`sk_`) and webhook (`whsec_`) keys must live in a git-ignored
|
|
164
|
+
> file or a secret manager. Only `pk_โฆ` is safe in tracked config.
|
|
149
165
|
|
|
150
166
|
## Stripe setup (test mode)
|
|
151
167
|
|
|
152
|
-
1. **
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
secret for the forwarded endpoint):
|
|
168
|
+
1. **Test keys** โ Dashboard โ Developers โ API keys (toggle "test mode").
|
|
169
|
+
2. **Webhook** โ locally, the Stripe CLI prints a `whsec_โฆ` for the forwarded
|
|
170
|
+
endpoint:
|
|
156
171
|
```bash
|
|
157
|
-
stripe login
|
|
158
172
|
stripe listen --forward-to localhost:8000/<your-webhook-path>/
|
|
159
|
-
# โ copy the "whsec_โฆ" it prints into STRIPE__WEBHOOK_SECRET
|
|
160
173
|
```
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
`
|
|
164
|
-
3. **Test a payment** โ
|
|
165
|
-
(`4242 4242 4242 4242`, any future expiry/CVC). Trigger events directly with:
|
|
174
|
+
Deployed: create the endpoint in the Dashboard and subscribe to
|
|
175
|
+
`payment_intent.succeeded`, `payment_intent.payment_failed`,
|
|
176
|
+
`charge.refunded` (plus the invoice/subscription events your dunning needs).
|
|
177
|
+
3. **Test a payment** โ card `4242 4242 4242 4242`, any future expiry/CVC.
|
|
166
178
|
```bash
|
|
167
179
|
stripe trigger payment_intent.succeeded
|
|
168
180
|
stripe events resend <evt_id> # test idempotency / replay
|
|
@@ -170,9 +182,7 @@ mock-first dev path.
|
|
|
170
182
|
|
|
171
183
|
## Going live
|
|
172
184
|
|
|
173
|
-
1. Switch all three `STRIPE__*` values
|
|
174
|
-
|
|
175
|
-
2. Create a **live** webhook endpoint in the Dashboard (live mode has its own
|
|
176
|
-
signing secret โ set it in `STRIPE__WEBHOOK_SECRET`).
|
|
185
|
+
1. Switch all three `STRIPE__*` values and `NEXT_PUBLIC_STRIPE_PK` to live.
|
|
186
|
+
2. Create a **live** webhook endpoint (live mode has its own signing secret).
|
|
177
187
|
3. Register your domain for Apple Pay / Google Pay if you enable wallets.
|
|
178
188
|
4. Do a small real charge to confirm end-to-end.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/payments",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.562",
|
|
4
4
|
"description": "Provider-agnostic React payments module (Stripe-first): checkout state machine, Payment Element wrapper, close-guard context, mock adapter for keyless development",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"payments",
|
|
@@ -42,11 +42,12 @@
|
|
|
42
42
|
"dist",
|
|
43
43
|
"src",
|
|
44
44
|
"!src/**/*.stories.tsx",
|
|
45
|
+
"@docs",
|
|
45
46
|
"README.md",
|
|
46
47
|
"LICENSE"
|
|
47
48
|
],
|
|
48
49
|
"scripts": {
|
|
49
|
-
"check": "tsc --noEmit",
|
|
50
|
+
"check": "tsc --noEmit && node scripts/check-stripe-imports.mjs",
|
|
50
51
|
"lint": "eslint . --max-warnings 0"
|
|
51
52
|
},
|
|
52
53
|
"dependencies": {
|
|
@@ -54,15 +55,15 @@
|
|
|
54
55
|
"@stripe/stripe-js": "^9.9.0"
|
|
55
56
|
},
|
|
56
57
|
"peerDependencies": {
|
|
57
|
-
"@djangocfg/ui-core": "^2.1.
|
|
58
|
+
"@djangocfg/ui-core": "^2.1.562",
|
|
58
59
|
"lucide-react": "^0.545.0",
|
|
59
60
|
"react": "^19.0.0",
|
|
60
61
|
"react-dom": "^19.0.0"
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
|
-
"@djangocfg/eslint-config": "^2.1.
|
|
64
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
65
|
-
"@djangocfg/ui-core": "^2.1.
|
|
64
|
+
"@djangocfg/eslint-config": "^2.1.562",
|
|
65
|
+
"@djangocfg/typescript-config": "^2.1.562",
|
|
66
|
+
"@djangocfg/ui-core": "^2.1.562",
|
|
66
67
|
"@storybook/react-vite": "^10.5.0",
|
|
67
68
|
"@types/node": "^25.9.5",
|
|
68
69
|
"@types/react": "19.2.15",
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// @djangocfg/payments โ CardSetupForm (save a card, charge nothing)
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// ui-core only โ no Stripe import; the provider's field arrives via the
|
|
7
|
+
// `paymentField` slot, exactly as CheckoutForm takes it.
|
|
8
|
+
//
|
|
9
|
+
// NOT CheckoutForm with amount=0. That form is built around a sum: its CTA
|
|
10
|
+
// reads `Pay {amount}`, its summary states what is being bought, its success
|
|
11
|
+
// state says "Paid". A card being saved has no amount, buys nothing, and ends
|
|
12
|
+
// at "Card saved" โ passing a meaningless zero through all three would produce
|
|
13
|
+
// a form that lies in three places.
|
|
14
|
+
//
|
|
15
|
+
// CONSENT IS NOT DECORATION. Stripe's `mandate_data.customer_acceptance` is
|
|
16
|
+
// sent as `infer_from_client`, which derives the mandate from the text ON
|
|
17
|
+
// SCREEN. `consentText` is that text. Off-session charging requires it, so the
|
|
18
|
+
// component renders a default rather than allowing an empty one.
|
|
19
|
+
|
|
20
|
+
import React, { useCallback, useRef } from 'react';
|
|
21
|
+
import { Alert, AlertDescription, Button, Spinner } from '@djangocfg/ui-core';
|
|
22
|
+
import { AlertTriangle, Lock, ShieldCheck } from 'lucide-react';
|
|
23
|
+
import { isCheckoutLocked, usePublishCheckoutStatus } from '../context/CheckoutGuard';
|
|
24
|
+
import type { UseCardSetupResult } from '../hooks/useCardSetup';
|
|
25
|
+
|
|
26
|
+
export interface CardSetupFormProps {
|
|
27
|
+
/** The setup machine, created by the parent so it also owns the clientSecret. */
|
|
28
|
+
setup: UseCardSetupResult;
|
|
29
|
+
/** Slot for the provider's card field (Stripe PaymentElement / mock input). */
|
|
30
|
+
paymentField?: React.ReactNode;
|
|
31
|
+
/** Renders a `4242โฆ` stub when no `paymentField` is supplied. Mock path only. */
|
|
32
|
+
isMock?: boolean;
|
|
33
|
+
/** Confirm payload passed to the adapter (a Stripe Elements instance). */
|
|
34
|
+
elementsContext?: unknown;
|
|
35
|
+
/** Where the provider returns after a 3DS redirect. */
|
|
36
|
+
returnUrl?: string;
|
|
37
|
+
onSuccess?: (setupIntentId: string) => void;
|
|
38
|
+
onFailure?: (error: string) => void;
|
|
39
|
+
onCancel?: () => void;
|
|
40
|
+
submitLabel?: string;
|
|
41
|
+
cancelLabel?: string;
|
|
42
|
+
/** The mandate text. Shown to the customer AND inferred by Stripe. */
|
|
43
|
+
consentText?: string;
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_CONSENT =
|
|
48
|
+
'By saving this card you authorise us to charge it for your subscription renewals, including when you are not present.';
|
|
49
|
+
|
|
50
|
+
export function CardSetupForm({
|
|
51
|
+
setup,
|
|
52
|
+
paymentField,
|
|
53
|
+
isMock = false,
|
|
54
|
+
elementsContext,
|
|
55
|
+
returnUrl,
|
|
56
|
+
onSuccess,
|
|
57
|
+
onFailure,
|
|
58
|
+
onCancel,
|
|
59
|
+
submitLabel = 'Save card',
|
|
60
|
+
cancelLabel = 'Cancel',
|
|
61
|
+
consentText = DEFAULT_CONSENT,
|
|
62
|
+
className,
|
|
63
|
+
}: CardSetupFormProps) {
|
|
64
|
+
const { status, error, isBusy, confirm, intent } = setup;
|
|
65
|
+
|
|
66
|
+
// Same guard CheckoutForm publishes into: a SetupIntent mid-confirm is a
|
|
67
|
+
// real authorisation with the issuer, so the dialog must not close over it.
|
|
68
|
+
usePublishCheckoutStatus(status);
|
|
69
|
+
const locked = isCheckoutLocked(status);
|
|
70
|
+
|
|
71
|
+
const isDone = status === 'succeeded';
|
|
72
|
+
const needsAction = status === 'requires_action';
|
|
73
|
+
|
|
74
|
+
// A ref, not the disabled prop: between `start` and `confirm` the status sits
|
|
75
|
+
// at `requires_payment` with `isBusy` false, so a second Enter would confirm
|
|
76
|
+
// the same SetupIntent twice. A ref flips with no render in between.
|
|
77
|
+
const submittingRef = useRef(false);
|
|
78
|
+
|
|
79
|
+
const handleSubmit = useCallback(
|
|
80
|
+
async (e: React.FormEvent) => {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
if (submittingRef.current) return;
|
|
83
|
+
submittingRef.current = true;
|
|
84
|
+
try {
|
|
85
|
+
await confirm(elementsContext, returnUrl);
|
|
86
|
+
} finally {
|
|
87
|
+
submittingRef.current = false;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
[confirm, elementsContext, returnUrl],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
React.useEffect(() => {
|
|
94
|
+
if (status === 'succeeded' && intent) onSuccess?.(intent.id);
|
|
95
|
+
if (status === 'failed' && error) onFailure?.(error);
|
|
96
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
97
|
+
}, [status]);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<form onSubmit={handleSubmit} className={className}>
|
|
101
|
+
<div className="space-y-5">
|
|
102
|
+
<div>
|
|
103
|
+
{paymentField ? (
|
|
104
|
+
<div className="min-h-[40px]">{paymentField}</div>
|
|
105
|
+
) : isMock ? (
|
|
106
|
+
<div className="border-border bg-muted/30 text-muted-foreground flex h-10 items-center rounded-md border px-3 text-sm">
|
|
107
|
+
4242 4242 4242 4242
|
|
108
|
+
</div>
|
|
109
|
+
) : null}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<p className="text-muted-foreground text-xs leading-relaxed">{consentText}</p>
|
|
113
|
+
|
|
114
|
+
{error ? (
|
|
115
|
+
<Alert variant="destructive">
|
|
116
|
+
<AlertDescription>{error}</AlertDescription>
|
|
117
|
+
</Alert>
|
|
118
|
+
) : null}
|
|
119
|
+
|
|
120
|
+
{needsAction ? (
|
|
121
|
+
<Alert>
|
|
122
|
+
<AlertTriangle className="h-4 w-4" />
|
|
123
|
+
<AlertDescription>
|
|
124
|
+
Your bank needs to confirm this card before it can be saved.
|
|
125
|
+
</AlertDescription>
|
|
126
|
+
</Alert>
|
|
127
|
+
) : null}
|
|
128
|
+
|
|
129
|
+
<Button type="submit" size="lg" className="w-full" disabled={isBusy || isDone}>
|
|
130
|
+
{isBusy ? (
|
|
131
|
+
<>
|
|
132
|
+
<Spinner className="mr-1.5 h-4 w-4" />
|
|
133
|
+
Savingโฆ
|
|
134
|
+
</>
|
|
135
|
+
) : isDone ? (
|
|
136
|
+
<>
|
|
137
|
+
<ShieldCheck className="mr-1.5 h-4 w-4" />
|
|
138
|
+
Card saved
|
|
139
|
+
</>
|
|
140
|
+
) : (
|
|
141
|
+
submitLabel
|
|
142
|
+
)}
|
|
143
|
+
</Button>
|
|
144
|
+
|
|
145
|
+
{onCancel && !locked ? (
|
|
146
|
+
<Button
|
|
147
|
+
type="button"
|
|
148
|
+
variant="ghost"
|
|
149
|
+
size="sm"
|
|
150
|
+
className="text-muted-foreground w-full"
|
|
151
|
+
onClick={onCancel}
|
|
152
|
+
disabled={isBusy}
|
|
153
|
+
>
|
|
154
|
+
{cancelLabel}
|
|
155
|
+
</Button>
|
|
156
|
+
) : null}
|
|
157
|
+
|
|
158
|
+
<p className="text-muted-foreground flex items-center justify-center gap-1.5 text-xs">
|
|
159
|
+
<Lock className="h-3 w-3" />
|
|
160
|
+
Secure ยท Powered by Stripe
|
|
161
|
+
</p>
|
|
162
|
+
</div>
|
|
163
|
+
</form>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// @djangocfg/payments โ SavedCardList
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// The cards-on-file table. Pure presentational: the host feeds `phase` (from
|
|
7
|
+
// useSavedCards) and the action callbacks. ui-core Table, like PaymentHistory
|
|
8
|
+
// โ no data-grid, since a package may not depend on a widget.
|
|
9
|
+
//
|
|
10
|
+
// FOUR STATES, NOT TWO. `unavailable` (the adapter has no card methods),
|
|
11
|
+
// `loading` (never read), `ready` (possibly empty), `error` (with whatever was
|
|
12
|
+
// last known). Collapsing the first three into an empty table shows "no cards
|
|
13
|
+
// on file" to a host that simply never wired the endpoints.
|
|
14
|
+
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import {
|
|
17
|
+
Alert,
|
|
18
|
+
AlertDescription,
|
|
19
|
+
Badge,
|
|
20
|
+
Button,
|
|
21
|
+
Skeleton,
|
|
22
|
+
Table,
|
|
23
|
+
TableBody,
|
|
24
|
+
TableCell,
|
|
25
|
+
TableHead,
|
|
26
|
+
TableHeader,
|
|
27
|
+
TableRow,
|
|
28
|
+
} from '@djangocfg/ui-core';
|
|
29
|
+
import { AlertTriangle, CreditCard, Plus } from 'lucide-react';
|
|
30
|
+
import type { SavedCard, SavedCardsPhase } from '../domain/types';
|
|
31
|
+
import { cardsOf, formatCardExpiry, isCardExpired, isCardExpiringSoon } from '../domain/cards';
|
|
32
|
+
|
|
33
|
+
export interface SavedCardListProps {
|
|
34
|
+
phase: SavedCardsPhase;
|
|
35
|
+
/** Open the add-card dialog. Omit to hide the action entirely. */
|
|
36
|
+
onAddCard?: () => void;
|
|
37
|
+
/** Make a card the default for renewals. Omit to hide the row action. */
|
|
38
|
+
onSetDefault?: (id: string) => void;
|
|
39
|
+
/** Detach a card. Omit to hide the row action. */
|
|
40
|
+
onRemove?: (id: string) => void;
|
|
41
|
+
/** Disables every action (a write is in flight). */
|
|
42
|
+
busy?: boolean;
|
|
43
|
+
/** Host-supplied words โ this package ships no locale. */
|
|
44
|
+
labels?: Partial<SavedCardListLabels>;
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SavedCardListLabels {
|
|
49
|
+
card: string;
|
|
50
|
+
expires: string;
|
|
51
|
+
addCard: string;
|
|
52
|
+
setDefault: string;
|
|
53
|
+
remove: string;
|
|
54
|
+
defaultBadge: string;
|
|
55
|
+
expiringSoon: string;
|
|
56
|
+
expired: string;
|
|
57
|
+
empty: string;
|
|
58
|
+
unavailable: string;
|
|
59
|
+
/** Why the only card on an active subscription cannot be detached. */
|
|
60
|
+
lastCardLocked: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_LABELS: SavedCardListLabels = {
|
|
64
|
+
card: 'Card',
|
|
65
|
+
expires: 'Expires',
|
|
66
|
+
addCard: 'Add card',
|
|
67
|
+
setDefault: 'Make default',
|
|
68
|
+
remove: 'Remove',
|
|
69
|
+
defaultBadge: 'Default',
|
|
70
|
+
expiringSoon: 'Expires soon',
|
|
71
|
+
expired: 'Expired',
|
|
72
|
+
empty: 'No card on file.',
|
|
73
|
+
unavailable: 'Card management is not available for this account.',
|
|
74
|
+
lastCardLocked: 'Renewals need a card โ add another one first.',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** `visa` โ `Visa`. Display only; never branch on a brand. */
|
|
78
|
+
function formatBrand(brand: string): string {
|
|
79
|
+
if (!brand) return 'Card';
|
|
80
|
+
return brand.charAt(0).toUpperCase() + brand.slice(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface CardRow {
|
|
84
|
+
card: SavedCard;
|
|
85
|
+
label: string;
|
|
86
|
+
expiry: string;
|
|
87
|
+
expired: boolean;
|
|
88
|
+
expiringSoon: boolean;
|
|
89
|
+
/** Detaching the only card would leave renewals with nothing to charge. */
|
|
90
|
+
removalLocked: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function SavedCardList({
|
|
94
|
+
phase,
|
|
95
|
+
onAddCard,
|
|
96
|
+
onSetDefault,
|
|
97
|
+
onRemove,
|
|
98
|
+
busy = false,
|
|
99
|
+
labels,
|
|
100
|
+
className,
|
|
101
|
+
}: SavedCardListProps) {
|
|
102
|
+
const text = { ...DEFAULT_LABELS, ...labels };
|
|
103
|
+
|
|
104
|
+
// Not memoized: a customer has a handful of cards, and this surface renders
|
|
105
|
+
// on one page. Memoizing here would buy nothing and hide the derivation.
|
|
106
|
+
const cards = cardsOf(phase);
|
|
107
|
+
const now = new Date();
|
|
108
|
+
const rows: CardRow[] = cards.map((card) => ({
|
|
109
|
+
card,
|
|
110
|
+
label: `${formatBrand(card.brand)} ยทยทยทยท ${card.last4}`,
|
|
111
|
+
expiry: formatCardExpiry(card),
|
|
112
|
+
expired: isCardExpired(card, now),
|
|
113
|
+
expiringSoon: isCardExpiringSoon(card, now),
|
|
114
|
+
removalLocked: cards.length === 1,
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
if (phase.kind === 'unavailable') {
|
|
118
|
+
return (
|
|
119
|
+
<div className={className}>
|
|
120
|
+
<p className="text-muted-foreground py-6 text-center text-sm">{text.unavailable}</p>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A reserved-height skeleton, not nothing: this lane crosses the network, so
|
|
126
|
+
// content appearing seconds later would read as a bug rather than as loading.
|
|
127
|
+
if (phase.kind === 'loading') {
|
|
128
|
+
return (
|
|
129
|
+
<div className={`space-y-2 ${className ?? ''}`}>
|
|
130
|
+
<Skeleton className="h-10 w-full" />
|
|
131
|
+
<Skeleton className="h-10 w-full" />
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const showActions = Boolean(onSetDefault || onRemove);
|
|
137
|
+
const columnCount = showActions ? 4 : 3;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className={`space-y-3 ${className ?? ''}`}>
|
|
141
|
+
{phase.kind === 'error' ? (
|
|
142
|
+
<Alert variant="destructive">
|
|
143
|
+
<AlertTriangle className="h-4 w-4" />
|
|
144
|
+
<AlertDescription>{phase.message}</AlertDescription>
|
|
145
|
+
</Alert>
|
|
146
|
+
) : null}
|
|
147
|
+
|
|
148
|
+
<Table>
|
|
149
|
+
<TableHeader>
|
|
150
|
+
<TableRow>
|
|
151
|
+
<TableHead>{text.card}</TableHead>
|
|
152
|
+
<TableHead>{text.expires}</TableHead>
|
|
153
|
+
<TableHead />
|
|
154
|
+
{showActions ? <TableHead className="text-right" /> : null}
|
|
155
|
+
</TableRow>
|
|
156
|
+
</TableHeader>
|
|
157
|
+
<TableBody>
|
|
158
|
+
{rows.length === 0 ? (
|
|
159
|
+
<TableRow>
|
|
160
|
+
<TableCell colSpan={columnCount} className="text-muted-foreground py-8 text-center">
|
|
161
|
+
{text.empty}
|
|
162
|
+
</TableCell>
|
|
163
|
+
</TableRow>
|
|
164
|
+
) : (
|
|
165
|
+
rows.map((row) => (
|
|
166
|
+
<TableRow key={row.card.id}>
|
|
167
|
+
<TableCell className="flex items-center gap-2 font-medium">
|
|
168
|
+
<CreditCard className="text-muted-foreground h-4 w-4" aria-hidden />
|
|
169
|
+
{row.label}
|
|
170
|
+
</TableCell>
|
|
171
|
+
<TableCell>{row.expiry}</TableCell>
|
|
172
|
+
<TableCell className="space-x-1">
|
|
173
|
+
{row.card.isDefault ? (
|
|
174
|
+
<Badge variant="secondary">{text.defaultBadge}</Badge>
|
|
175
|
+
) : null}
|
|
176
|
+
{row.expired ? (
|
|
177
|
+
<Badge variant="destructive">{text.expired}</Badge>
|
|
178
|
+
) : row.expiringSoon ? (
|
|
179
|
+
<Badge variant="outline">{text.expiringSoon}</Badge>
|
|
180
|
+
) : null}
|
|
181
|
+
</TableCell>
|
|
182
|
+
{showActions ? (
|
|
183
|
+
<TableCell className="space-x-2 text-right whitespace-nowrap">
|
|
184
|
+
{onSetDefault && !row.card.isDefault ? (
|
|
185
|
+
<Button
|
|
186
|
+
type="button"
|
|
187
|
+
variant="ghost"
|
|
188
|
+
size="sm"
|
|
189
|
+
disabled={busy}
|
|
190
|
+
onClick={() => onSetDefault(row.card.id)}
|
|
191
|
+
>
|
|
192
|
+
{text.setDefault}
|
|
193
|
+
</Button>
|
|
194
|
+
) : null}
|
|
195
|
+
{onRemove ? (
|
|
196
|
+
// Disabled WITH a reason, never hidden: a control that
|
|
197
|
+
// vanishes reads as a bug, one that explains itself teaches.
|
|
198
|
+
<Button
|
|
199
|
+
type="button"
|
|
200
|
+
variant="ghost"
|
|
201
|
+
size="sm"
|
|
202
|
+
disabled={busy || row.removalLocked}
|
|
203
|
+
title={row.removalLocked ? text.lastCardLocked : undefined}
|
|
204
|
+
onClick={() => onRemove(row.card.id)}
|
|
205
|
+
>
|
|
206
|
+
{text.remove}
|
|
207
|
+
</Button>
|
|
208
|
+
) : null}
|
|
209
|
+
</TableCell>
|
|
210
|
+
) : null}
|
|
211
|
+
</TableRow>
|
|
212
|
+
))
|
|
213
|
+
)}
|
|
214
|
+
</TableBody>
|
|
215
|
+
</Table>
|
|
216
|
+
|
|
217
|
+
{onAddCard ? (
|
|
218
|
+
<Button type="button" variant="outline" disabled={busy} onClick={onAddCard}>
|
|
219
|
+
<Plus className="mr-1 h-4 w-4" aria-hidden />
|
|
220
|
+
{text.addCard}
|
|
221
|
+
</Button>
|
|
222
|
+
) : null}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// ============================================================================
|
|
4
4
|
// @djangocfg/payments โ StripePaymentElement
|
|
5
5
|
// ============================================================================
|
|
6
|
-
// One of
|
|
6
|
+
// One of two files allowed a RUNTIME @stripe/* import (the other is
|
|
7
|
+
// stripe-adapter.ts). Enforced by scripts/check-stripe-imports.mjs.
|
|
7
8
|
// Wraps the Stripe <Elements> provider + <PaymentElement>, and exposes the
|
|
8
9
|
// live Elements instance to the host via a render prop so it can be handed to
|
|
9
10
|
// the adapter's confirm() (CheckoutForm's `elementsContext`).
|