@djangocfg/payments 2.1.561 โ†’ 2.1.563

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 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 + recurring subscriptions with
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 or
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 checkout UX before any backend exists.
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> + usePaymentAdapter() (the injected seam)
43
- transport/ mock-adapter | stripe-adapter (only stripe-adapter touches @stripe/*)
44
- hooks/ useCheckout, usePaymentHistory (state machine over the adapter)
45
- components/ CheckoutForm, StripePaymentElement, PaymentHistory, PaymentStatusBadge
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 touched in **exactly two files** (`transport/stripe-adapter.ts`,
49
- `components/StripePaymentElement.tsx`) and ships as a regular dependency โ€”
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), and tree-shaking drops the Stripe components from
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 + create-intent call) |
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 (CheckoutGuard + ui-core Dialog); wrap your checkout body in it |
81
- | `CheckoutForm` | component | provider-agnostic form shell (ui-core only); inject the provider field via `paymentField` |
82
- | `StripePaymentElement` | component | `<Elements>` + `<PaymentElement>`; render-props the live Elements to the host |
83
- | `PaymentHistory` | component | sortable table of `PaymentRecord` (ui-core `Table`) |
84
- | `PaymentStatusBadge` | component | status pill on ui-core semantic tokens (success/warning/info/destructive) |
85
- | `toMinorUnits` / `toMajorUnits` / `formatAmount` | util | money at the boundary (amounts cross as integer minor units) |
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` (tsc).
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
- Payments need **three Stripe keys**, split by where they're safe to live. Stripe
101
- ships them in two flavours โ€” **test** (`sk_test_โ€ฆ`, `pk_test_โ€ฆ`) and **live**
102
- (`sk_live_โ€ฆ`, `pk_live_โ€ฆ`) โ€” with the **same variable names**; only the values
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 (it's public by design) |
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
- # --- Stripe (test) ---
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
- # --- Stripe (live) โ€” same names, live values ---
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 checkout UX still renders (no real charge), which is the
143
- mock-first dev path.
161
+ adapter** โ€” the whole UX still renders (no real charge).
144
162
 
145
- > โš ๏ธ **Security:** the secret (`sk_`) and webhook (`whsec_`) keys must live in a
146
- > **git-ignored** file or a secret manager โ€” never commit them. If the project's
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. **Get test keys** โ€” Stripe Dashboard โ†’ Developers โ†’ API keys (toggle "test
153
- mode"): copy `pk_test_โ€ฆ` and `sk_test_โ€ฆ`.
154
- 2. **Webhook** โ€” for local dev, use the Stripe CLI (it prints a `whsec_โ€ฆ` signing
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
- For a deployed env, create the endpoint in Dashboard โ†’ Developers โ†’ Webhooks
162
- pointing at your backend's webhook URL and subscribe to:
163
- `payment_intent.succeeded`, `payment_intent.payment_failed`, `charge.refunded`.
164
- 3. **Test a payment** โ€” open an order, hit **Pay**, use a Stripe **test card**
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 to their `โ€ฆ_live_โ€ฆ` counterparts and
174
- `NEXT_PUBLIC_STRIPE_PK` to `pk_live_โ€ฆ`.
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.561",
3
+ "version": "2.1.563",
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.561",
58
+ "@djangocfg/ui-core": "^2.1.563",
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.561",
64
- "@djangocfg/typescript-config": "^2.1.561",
65
- "@djangocfg/ui-core": "^2.1.561",
64
+ "@djangocfg/eslint-config": "^2.1.563",
65
+ "@djangocfg/typescript-config": "^2.1.563",
66
+ "@djangocfg/ui-core": "^2.1.563",
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 TWO files allowed to import @stripe/* (the other is stripe-adapter).
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`).