@volter/twin-stripe 3.0.2 → 3.0.3
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 +28 -31
- package/dist/journeys/customer-life.json +10 -2
- package/dist/spec/SOURCE.md +4 -0
- package/dist/src/engine/stripe.js +25 -3
- package/dist/src/screens/billing-portal.js +7 -7
- package/dist/src/screens/checkout.js +5 -5
- package/dist/src/screens/connect-onboarding.d.ts +1 -1
- package/dist/src/screens/connect-onboarding.js +79 -5
- package/dist/src/screens/dashboard.js +65 -18
- package/journeys/customer-life.json +10 -2
- package/package.json +1 -1
- package/spec/SOURCE.md +4 -0
- package/src/engine/stripe.ts +26 -3
- package/src/screens/billing-portal.tsx +7 -7
- package/src/screens/checkout.tsx +5 -5
- package/src/screens/connect-onboarding.tsx +67 -9
- package/src/screens/dashboard.tsx +84 -41
package/README.md
CHANGED
|
@@ -5,20 +5,24 @@ application sends a person through, and Stripe.js, over one state.
|
|
|
5
5
|
|
|
6
6
|
## Use with an existing app
|
|
7
7
|
|
|
8
|
+
Use Node 22.6 or newer.
|
|
9
|
+
|
|
8
10
|
In an app that already uses this vendor, install this exact release and the product CLI:
|
|
9
11
|
|
|
10
12
|
```console
|
|
11
|
-
npm install --save-dev --save-exact @volter/world@3.0.
|
|
12
|
-
|
|
13
|
+
npm install --save-dev --save-exact @volter/world@3.0.147 @volter/twin-stripe@3.0.3
|
|
14
|
+
./node_modules/.bin/volter world init --name my-app --twins stripe --source stripe=@volter/twin-stripe
|
|
13
15
|
```
|
|
14
16
|
|
|
17
|
+
Run the local executable from this app’s folder; if it is missing, complete the installation here before continuing.
|
|
18
|
+
|
|
15
19
|
Review the detected vendor and generated bindings before booting. Read the credential names and limitations below; the World supplies throwaway credentials. Then run your app's own command through the World:
|
|
16
20
|
|
|
17
21
|
```console
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
./node_modules/.bin/volter world up
|
|
23
|
+
./node_modules/.bin/volter world run -- npm test
|
|
24
|
+
./node_modules/.bin/volter world log
|
|
25
|
+
./node_modules/.bin/volter world down
|
|
22
26
|
```
|
|
23
27
|
|
|
24
28
|
Here `npm test` is your app's existing command; replace it with your app or test command. `down` retains state.
|
|
@@ -47,9 +51,10 @@ vendor-backed World's refresh; each served operation is decided in `journeys/dec
|
|
|
47
51
|
- **Catalog**: products and prices created and listed, prices retrieved, coupons and promotion codes managed. Product search and administrative product/price updates answer the gap.
|
|
48
52
|
- **Checkout Sessions** and the customer portal (sessions and configurations): pay, add a card, pay an open invoice,
|
|
49
53
|
cancel at the period's end or at once.
|
|
50
|
-
- **Subscriptions**: created
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
- **Subscriptions**: created by hosted Checkout (including trials), read, updated (items, discounts, a trial
|
|
55
|
+
ended early or added) and canceled, with renewals on the World clock. Direct `POST /v1/subscriptions` is outside
|
|
56
|
+
the declared application demand and returns the gap; use the release operation table to check other subscription
|
|
57
|
+
and schedule endpoints before choosing this twin.
|
|
53
58
|
- **Invoices**: invoice items, drafts, finalize, pay (a draft is finalized on the way), void, delete, lines,
|
|
54
59
|
previews and their payments. Invoice lines are read in the invoice; the standalone lines endpoint answers the gap.
|
|
55
60
|
- **Intents**: payment and setup intents, confirmed on the server or by Stripe.js with the publishable key and the
|
|
@@ -98,30 +103,22 @@ SDK's `constructEvent` verifies) and delivers to the account's enabled webhook e
|
|
|
98
103
|
(`STRIPE_CONNECT_WEBHOOK_SECRET`), the same on every boot; an endpoint made on the Webhooks page (a door) is signed
|
|
99
104
|
with the one of its kind. The twin reads no env.
|
|
100
105
|
|
|
106
|
+
The documented card-brand samples keep their brand and credit/debit/prepaid funding type when saved
|
|
107
|
+
through Checkout or the portal. Other raw numbers retain synthetic Visa metadata; the twin does not
|
|
108
|
+
look up a real card issuer.
|
|
109
|
+
|
|
101
110
|
## Screens
|
|
102
111
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
- **Checkout** (`checkout.stripe.com/c/pay/{session}`):
|
|
106
|
-
|
|
107
|
-
- **
|
|
108
|
-
|
|
109
|
-
- **
|
|
110
|
-
- **
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
`<code data-testid="connect-client-id">`. OAuth starts off: a runner POSTs that page as a browser does,
|
|
114
|
-
`oauth_enabled=on&redirect_uris=<one per line>`. The authorize page offers the test-mode **Skip this form** (a new
|
|
115
|
-
Standard account, connected) and **Deny access**; the token endpoint exchanges a code once, within 5 minutes (a
|
|
116
|
-
reused code revokes the connection), and refreshes to an equal or lesser scope; after a deauthorization the
|
|
117
|
-
account's `Stripe-Account` header is refused 403 `account_invalid`. The deprecated `access_token` and
|
|
118
|
-
`stripe_publishable_key` act as the connected account. Not modelled: connecting an existing Stripe account, the full
|
|
119
|
-
account application, holding a `read_only` connection to reads.
|
|
120
|
-
- **Stripe.js** (`js.stripe.com/v3/`, `/v3/stripe.js`, `/<release train>/stripe.js`): `redirectToCheckout({ sessionId })`
|
|
121
|
-
goes to the hosted Checkout page; any other member
|
|
122
|
-
throws, naming itself.
|
|
123
|
-
- **Dashboard** (`dashboard.stripe.com/settings/public`, `/settings/connect/onboarding-options/oauth`): Public details
|
|
124
|
-
(the business name Checkout and the portal show) and the Connect OAuth settings.
|
|
112
|
+
Authored pages and content at the declared vendor URLs (`src/screens/`):
|
|
113
|
+
|
|
114
|
+
- **Checkout** (`checkout.stripe.com/c/pay/{session}`): payment, setup and subscription sessions, including trials.
|
|
115
|
+
- **Customer portal** (`billing.stripe.com/p/session/{session}`): cancel or renew a plan, update the card and pay an open invoice.
|
|
116
|
+
- **Connect onboarding** (`connect.stripe.com/setup/…`): the Account Link's business, identity, payout and agreement form.
|
|
117
|
+
- **Stripe.js** (`js.stripe.com/v3/`, `/v3/stripe.js`, `/<release train>/stripe.js`): `redirectToCheckout({ sessionId })` opens the hosted session. Unsupported members refuse.
|
|
118
|
+
- **Checkout client wire** (`api.stripe.com/v1/payment_pages/{session}`): the authored session operations used by Stripe.js CheckoutProvider.
|
|
119
|
+
- **Dashboard** (`dashboard.stripe.com/settings/public`, `/radar/lists`, `/test/issuing/balance`): public business name, Radar list creation and test Issuing funding. The declared World workspace opens Public details, with navigation between these stored operator actions. Other dashboard controls are unavailable.
|
|
120
|
+
|
|
121
|
+
Page actions use the same stored resources as the API. Public screen references and authored choices are in [spec/SOURCE.md](./spec/SOURCE.md).
|
|
125
122
|
|
|
126
123
|
## Doors
|
|
127
124
|
|
|
@@ -2183,7 +2183,11 @@
|
|
|
2183
2183
|
"expect": [
|
|
2184
2184
|
{
|
|
2185
2185
|
"path": "",
|
|
2186
|
-
"contains": "Radar
|
|
2186
|
+
"contains": "Payments / Radar"
|
|
2187
|
+
},
|
|
2188
|
+
{
|
|
2189
|
+
"path": "",
|
|
2190
|
+
"contains": "New list"
|
|
2187
2191
|
}
|
|
2188
2192
|
],
|
|
2189
2193
|
"evidence": "Stripe: https://docs.stripe.com/radar/lists"
|
|
@@ -2690,7 +2694,11 @@
|
|
|
2690
2694
|
"expect": [
|
|
2691
2695
|
{
|
|
2692
2696
|
"path": "",
|
|
2693
|
-
"contains": "
|
|
2697
|
+
"contains": "Issuing balance"
|
|
2698
|
+
},
|
|
2699
|
+
{
|
|
2700
|
+
"path": "",
|
|
2701
|
+
"contains": "Add test funds before creating test Issuing transactions."
|
|
2694
2702
|
}
|
|
2695
2703
|
],
|
|
2696
2704
|
"evidence": "Stripe: https://docs.stripe.com/issuing/testing"
|
package/dist/spec/SOURCE.md
CHANGED
|
@@ -16,3 +16,7 @@ The pack serves this document's version (`SERVED_VERSION` in `src/engine/version
|
|
|
16
16
|
publish (its testing page's cards, the payment lifecycle's states, each reference page's stated rule), each with its
|
|
17
17
|
page's URL and the page's words for what it does, read with twin-standard's `read-page`. **Read by:**
|
|
18
18
|
`bun scripts/vendor-examples.ts stripe`.
|
|
19
|
+
|
|
20
|
+
Screen references, read 2026-10-08: [Stripe's hosted-onboarding guide](https://docs.stripe.com/connect/hosted-onboarding), its hosted_onboarding_form.e59ba8300f563e43489953f06127f52c.png image, supplies the two-column layout, progress and sequential form. The image's example platform branding is configurable; this screen retains the Stripe skin. Back/Continue keep local form answers; only the existing final submit changes the connected account. The existing US requirements and refusal behavior remain the contract. No upstream page, image or script is shipped.
|
|
21
|
+
|
|
22
|
+
Dashboard references, read 2026-10-08: [the Web Dashboard guide](https://docs.stripe.com/dashboard/basics) supplies the primary sidebar categories, Shortcuts and Settings. The public [Stripe Apps testing guide](https://docs.stripe.com/stripe-apps/test-app), image `test-app-uninstall.710b89177767cff3a7f53de749558d88.png`, illustrates the earlier horizontal navigation, settings breadcrumbs, form density and type; its publication date is unspecified, and it is not a current Public details screenshot. The authored shell follows the current documented sidebar, while the controls remain the existing public business name, Radar list creation and test Issuing funding. The declared root opens Public details; other Dashboard products are unavailable, and there are no invented analytics. [Radar Lists](https://docs.stripe.com/radar/lists) supplies New, name, alias, type and Add. No upstream image, DOM, script or account sign-in is shipped.
|
|
@@ -672,6 +672,27 @@ export function disputeEvidence() {
|
|
|
672
672
|
};
|
|
673
673
|
}
|
|
674
674
|
const subParams = (params, k) => (params[k] && typeof params[k] === 'object' ? params[k] : {});
|
|
675
|
+
// source: https://docs.stripe.com/testing
|
|
676
|
+
// The documented brand cards keep their brand and funding type when Checkout or the portal saves them.
|
|
677
|
+
// Where the documentation stops and the twin decides: other numbers retain the existing synthetic Visa default.
|
|
678
|
+
const TEST_CARD_PROFILES = {
|
|
679
|
+
'4000056655665556': { brand: 'visa', funding: 'debit' },
|
|
680
|
+
'5555555555554444': { brand: 'mastercard', funding: 'credit' },
|
|
681
|
+
'2223003122003222': { brand: 'mastercard', funding: 'credit' },
|
|
682
|
+
'5200828282828210': { brand: 'mastercard', funding: 'debit' },
|
|
683
|
+
'5105105105105100': { brand: 'mastercard', funding: 'prepaid' },
|
|
684
|
+
'378282246310005': { brand: 'amex', funding: 'credit' },
|
|
685
|
+
'371449635398431': { brand: 'amex', funding: 'credit' },
|
|
686
|
+
'6011111111111117': { brand: 'discover', funding: 'credit' },
|
|
687
|
+
'6011000990139424': { brand: 'discover', funding: 'credit' },
|
|
688
|
+
'6011981111111113': { brand: 'discover', funding: 'debit' },
|
|
689
|
+
'3056930009020004': { brand: 'diners', funding: 'credit' },
|
|
690
|
+
'36227206271667': { brand: 'diners', funding: 'credit' },
|
|
691
|
+
'3566002020360505': { brand: 'jcb', funding: 'credit' },
|
|
692
|
+
'6200000000000005': { brand: 'unionpay', funding: 'credit' },
|
|
693
|
+
'6200000000000047': { brand: 'unionpay', funding: 'debit' },
|
|
694
|
+
'6205500000000000004': { brand: 'unionpay', funding: 'credit' },
|
|
695
|
+
};
|
|
675
696
|
/** A card's own sub-object, from the number and expiry given. */
|
|
676
697
|
function cardSubObject(params) {
|
|
677
698
|
const c = subParams(params, 'card');
|
|
@@ -680,15 +701,16 @@ function cardSubObject(params) {
|
|
|
680
701
|
// produces its real last4 instead of silently falling back to the '4242' default.
|
|
681
702
|
const number = typeof c.number === 'string' ? c.number.replace(/\D/g, '')
|
|
682
703
|
: typeof c.number === 'number' ? String(c.number).replace(/\D/g, '') : '';
|
|
704
|
+
const profile = TEST_CARD_PROFILES[number] ?? { brand: 'visa', funding: 'credit' };
|
|
683
705
|
return { card: {
|
|
684
|
-
brand:
|
|
706
|
+
brand: profile.brand, last4: number ? number.slice(-4) : '4242',
|
|
685
707
|
exp_month: Number(c.exp_month) || 12, exp_year: Number(c.exp_year) || 2034,
|
|
686
|
-
funding:
|
|
708
|
+
funding: profile.funding, country: 'US',
|
|
687
709
|
// source: spec:/components/schemas/payment_method_card/properties/fingerprint/description "Uniquely identifies this particular card number."
|
|
688
710
|
// The synthetic fingerprint is stable for a number; Stripe's opaque fingerprint algorithm is not published.
|
|
689
711
|
fingerprint: digest('sha256', number || '4242424242424242').slice(0, 16),
|
|
690
712
|
checks: { address_line1_check: null, address_postal_code_check: null, cvc_check: 'pass' },
|
|
691
|
-
networks: { available: [
|
|
713
|
+
networks: { available: [profile.brand], preferred: null },
|
|
692
714
|
three_d_secure_usage: { supported: true }, wallet: null,
|
|
693
715
|
} };
|
|
694
716
|
}
|
|
@@ -56,8 +56,8 @@ function page(ctx, id, session, problem) {
|
|
|
56
56
|
badge: s.status === 'trialing' ? 'Trial' : s.status === 'past_due' ? 'Past due' : ending ? 'Cancels' : undefined,
|
|
57
57
|
note: ending ? `Your plan will be canceled on ${day(s.current_period_end)}.` : s.status === 'trialing' ? `Trial ends ${day(s.trial_end)}.` : `Renews on ${day(s.current_period_end)}.`,
|
|
58
58
|
actions: ending
|
|
59
|
-
? [{ label: 'Renew plan', action:
|
|
60
|
-
: features.cancel.enabled ? [{ label: 'Cancel plan', action:
|
|
59
|
+
? [{ label: 'Renew plan', action: `${ctx.publicBase}/p/session/${id}/renew`, fields: { subscription: String(s.id) }, tone: 'primary' }]
|
|
60
|
+
: features.cancel.enabled ? [{ label: 'Cancel plan', action: `${ctx.publicBase}/p/session/${id}/cancel`, fields: { subscription: String(s.id) }, tone: 'danger' }] : [],
|
|
61
61
|
};
|
|
62
62
|
});
|
|
63
63
|
const defaultPm = customer.invoice_settings?.default_payment_method ?? undefined;
|
|
@@ -72,7 +72,7 @@ function page(ctx, id, session, problem) {
|
|
|
72
72
|
? ctx.rows('invoice').filter((i) => i.customer === session.customer && i.status !== 'draft').sort((a, b) => Number(b.created) - Number(a.created))
|
|
73
73
|
.map((i) => ({
|
|
74
74
|
title: day(i.created), detail: money(i.amount_due ?? i.total, i.currency), badge: String(i.status ?? '').replace(/^./, (c) => c.toUpperCase()),
|
|
75
|
-
...(i.status === 'open' && i.collection_method !== 'send_invoice' ? { actions: [{ label: 'Pay', action:
|
|
75
|
+
...(i.status === 'open' && i.collection_method !== 'send_invoice' ? { actions: [{ label: 'Pay', action: `${ctx.publicBase}/p/session/${id}/pay`, fields: { invoice: String(i.id) }, tone: 'primary' }] } : {}),
|
|
76
76
|
}))
|
|
77
77
|
: [];
|
|
78
78
|
const back = typeof session.return_url === 'string' ? session.return_url : undefined;
|
|
@@ -95,7 +95,7 @@ function page(ctx, id, session, problem) {
|
|
|
95
95
|
title: 'Billing',
|
|
96
96
|
css: [PORTAL_CSS, SKIN],
|
|
97
97
|
...(problem ? { status: 402 } : {}),
|
|
98
|
-
body: (_jsx(Portal, { merchant: merchant, ...(problem ? { notice: problem.error } : {}), ...(features.paymentMethod ? { forms: [{ heading: pm ? 'Update payment method' : 'Add payment method', action:
|
|
98
|
+
body: (_jsx(Portal, { merchant: merchant, ...(problem ? { notice: problem.error } : {}), ...(features.paymentMethod ? { forms: [{ heading: pm ? 'Update payment method' : 'Add payment method', action: `${ctx.publicBase}/p/session/${id}/payment_method`, fields: cardFields, submit: { label: pm ? 'Update' : 'Add' } }] } : {}), ...(back ? { back: { href: back, label: `← Return to ${merchant}` } } : {}), sections: [
|
|
99
99
|
{ heading: 'Current plan', items: plans, empty: 'You have no active plans.' },
|
|
100
100
|
{ heading: 'Payment method', items: methods, empty: 'No payment method on file.' },
|
|
101
101
|
{ heading: 'Billing information', items: details },
|
|
@@ -108,7 +108,7 @@ async function move(ctx, id, session, subscription, kind) {
|
|
|
108
108
|
const sub = resourceView(ctx, SUB, subscription);
|
|
109
109
|
if (!sub || sub.customer !== session.customer || sub.status === 'canceled')
|
|
110
110
|
return gone();
|
|
111
|
-
const back = new Response(null, { status: 303, headers: { location:
|
|
111
|
+
const back = new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
|
|
112
112
|
if (kind === 'renew') {
|
|
113
113
|
await ctx.write(SUB, subscription, { cancel_at_period_end: false, cancel_at: null, canceled_at: null, cancellation_details: { comment: null, feedback: null, reason: null } }, 'subscription.update');
|
|
114
114
|
return back;
|
|
@@ -149,7 +149,7 @@ async function updatePaymentMethod(ctx, id, session, v) {
|
|
|
149
149
|
for (const sub of ctx.rows(SUB).map((s) => ctx.expand(SUB, s)).filter((s) => s.customer === customer && s.status !== 'canceled' && s.status !== 'incomplete_expired')) {
|
|
150
150
|
await ctx.write(SUB, String(sub.id), { default_payment_method: pm.id }, 'subscription.update');
|
|
151
151
|
}
|
|
152
|
-
return new Response(null, { status: 303, headers: { location:
|
|
152
|
+
return new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
|
|
153
153
|
}
|
|
154
154
|
/** The customer pays one of their open invoices with the card on file. */
|
|
155
155
|
async function payInvoice(ctx, id, session, invoiceId) {
|
|
@@ -164,7 +164,7 @@ async function payInvoice(ctx, id, session, invoiceId) {
|
|
|
164
164
|
const declined = await payOpenInvoice(ctx, invoiceId, pm);
|
|
165
165
|
if (declined)
|
|
166
166
|
return page(ctx, id, session, { error: declined.message, values: {} });
|
|
167
|
-
return new Response(null, { status: 303, headers: { location:
|
|
167
|
+
return new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
|
|
168
168
|
}
|
|
169
169
|
/** billing.stripe.com's portal pages, or undefined for any other request. */
|
|
170
170
|
export async function screen(ctx) {
|
|
@@ -66,7 +66,7 @@ function customerEmailOf(session) {
|
|
|
66
66
|
const email = session._customer?.email;
|
|
67
67
|
return typeof email === 'string' && EMAIL.test(email) ? email : undefined;
|
|
68
68
|
}
|
|
69
|
-
function page(session, id, now, values = {}, error) {
|
|
69
|
+
function page(publicBase, session, id, now, values = {}, error) {
|
|
70
70
|
const lines = ((session.line_items?.data) ?? []).map((item) => ({
|
|
71
71
|
name: String(item.description ?? 'Item'),
|
|
72
72
|
amount: money(item.amount_total, item.currency ?? session.currency),
|
|
@@ -95,7 +95,7 @@ function page(session, id, now, values = {}, error) {
|
|
|
95
95
|
title: 'Checkout',
|
|
96
96
|
css: [PAYMENT_CSS, SKIN],
|
|
97
97
|
status: error ? 402 : 200,
|
|
98
|
-
body: (_jsx(Payment, { merchant: merchant, lines: lines, ...(trialText ? { total: trialText.lead, note: trialText.note } : session.amount_total !== null && session.amount_total !== undefined ? { total: { label: mode === 'subscription' ? 'Subscribe' : `Pay ${merchant}`, amount: money(session.amount_total, session.currency) } } : {}), action:
|
|
98
|
+
body: (_jsx(Payment, { merchant: merchant, lines: lines, ...(trialText ? { total: trialText.lead, note: trialText.note } : session.amount_total !== null && session.amount_total !== undefined ? { total: { label: mode === 'subscription' ? 'Subscribe' : `Pay ${merchant}`, amount: money(session.amount_total, session.currency) } } : {}), action: `${publicBase}/c/pay/${id}`, sections: [{ heading: 'Contact information', fields: contact }, { heading: 'Payment method', fields: card }], submit: { label: submit, testId: 'hosted-payment-submit-button' }, ...(typeof session.cancel_url === 'string' ? { back: { href: session.cancel_url, label: '← Back' } } : {}), ...(error ? { error } : {}) })),
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
const gone = () => flowPage({ title: 'Checkout', status: 404, css: [PAYMENT_CSS, SKIN], body: _jsx("main", { className: "pay-form", children: _jsx("h1", { children: "This Checkout Session is no longer active." }) }) });
|
|
@@ -104,14 +104,14 @@ async function pay(ctx, id, session, v) {
|
|
|
104
104
|
const email = onFile ?? (typeof session.customer_email === 'string' ? session.customer_email : (v.email ?? '').trim());
|
|
105
105
|
if (session.mode !== 'setup' || !session.customer) {
|
|
106
106
|
if (!EMAIL.test(email))
|
|
107
|
-
return page(session, id, nowUnix(ctx.occurredAt), v, 'Your email address is incomplete.');
|
|
107
|
+
return page(ctx.publicBase, session, id, nowUnix(ctx.occurredAt), v, 'Your email address is incomplete.');
|
|
108
108
|
}
|
|
109
109
|
// a trial charges nothing now unless a one-time price is on the first invoice
|
|
110
110
|
const oneTimeDue = ((session.line_items?.data) ?? []).some((it, i) => isOneTime(it, inlineOf(session, i)));
|
|
111
111
|
const chargesNow = session.mode === 'payment' || (session.mode === 'subscription' && (!trialOf(session, nowUnix(ctx.occurredAt)) || oneTimeDue));
|
|
112
112
|
const refused = cardAnswer(v, nowUnix(ctx.occurredAt), chargesNow);
|
|
113
113
|
if (refused)
|
|
114
|
-
return page(session, id, nowUnix(ctx.occurredAt), v, refused);
|
|
114
|
+
return page(ctx.publicBase, session, id, nowUnix(ctx.occurredAt), v, refused);
|
|
115
115
|
let existing = session;
|
|
116
116
|
// Checkout always makes a customer for a subscription; a payment makes one only when the session asks
|
|
117
117
|
if (!session.customer && (session.mode === 'subscription' || session.customer_creation === 'always')) {
|
|
@@ -162,5 +162,5 @@ export async function screen(ctx) {
|
|
|
162
162
|
const stored = ctx.row(CS, id) ?? {};
|
|
163
163
|
const coupon = stored._discount?.coupon;
|
|
164
164
|
const full = { ...session, _subscription_data: stored._subscription_data, _price_data: stored._price_data, _merchant: merchantOf(ctx, stored), _coupon: typeof coupon === 'string' ? ctx.get('coupon', coupon) : undefined, _customer: typeof session.customer === 'string' ? ctx.get('customer', session.customer) : undefined };
|
|
165
|
-
return request.method === 'GET' ? page(full, id, nowUnix(ctx.occurredAt)) : pay(ctx, id, full, values);
|
|
165
|
+
return request.method === 'GET' ? page(ctx.publicBase, full, id, nowUnix(ctx.occurredAt)) : pay(ctx, id, full, values);
|
|
166
166
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type HandlerContext } from '@volter/world-core';
|
|
2
2
|
/** connect.stripe.com's onboarding pages, or undefined for any other request. */
|
|
3
3
|
export declare function screen(ctx: HandlerContext): Promise<Response>;
|
|
@@ -1,4 +1,31 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// STRIPE CONNECT HOSTED ONBOARDING — a hosted flow (docs/contributing/architecture.md, "Screens"): a platform
|
|
3
|
+
// creates an Account Link for a connected account and sends the account's owner to its `url`. The link is a
|
|
4
|
+
// single-use entry point: visiting it takes the owner to their onboarding session, and visiting it again, or after it
|
|
5
|
+
// expires, sends them to refresh_url, where the platform makes a new one (docs.stripe.com/connect/hosted-onboarding).
|
|
6
|
+
// At the session's own address they give Stripe what it requires, and Stripe sends them to return_url. Submitting is
|
|
7
|
+
// the owner's move, not an API call. Authored from @volter/world-ui's payment piece and Stripe's published visual
|
|
8
|
+
// example; the form moves through one section at a time, retaining answers until the final submit.
|
|
9
|
+
//
|
|
10
|
+
// The page asks for exactly what Stripe requires before a US Express account with card_payments and transfers can
|
|
11
|
+
// take card payments (docs.stripe.com/connect/required-verification-information; the set is the one Stripe's
|
|
12
|
+
// requirements endpoint answers, docs.stripe.com/_endpoint/get-requirements-for-setups with apiVersion=v1,
|
|
13
|
+
// platformCountry=US, accountCountry=US, dashboardType=express, tosType=full, capabilities card_payments and transfers):
|
|
14
|
+
// thirty requirements for a company (the business profile, the statement descriptor, the company with its tax id,
|
|
15
|
+
// phone and address, its owners, its representative with their title and address, the agreement and the payout bank
|
|
16
|
+
// account) and eighteen for an individual. A submit that leaves any out is refused with what is missing. The account's
|
|
17
|
+
// requirements.currently_due lists what is still missing, and charges and payouts are enabled, and its requested
|
|
18
|
+
// capabilities become active (Stripe's move on the capability machine), only once none is. The agreement is the
|
|
19
|
+
// page's own checkbox, recorded as tos_acceptance with the request's address and the time.
|
|
20
|
+
//
|
|
21
|
+
// Where the documentation stops and the twin decides: only these two US sets are modelled; an account elsewhere, or
|
|
22
|
+
// of another business type, is told its requirements are not modelled. Test mode verifies at once whatever is
|
|
23
|
+
// submitted (docs.stripe.com/connect/testing lists the values that pass: date of birth 1901-01-01, SSN last four
|
|
24
|
+
// 0000, tax id 000000000, routing 110000000, account 000123456789); the industries offered are Stripe's supported
|
|
25
|
+
// merchant category codes as its list gives them (./industries.ts), and an industry the page does not offer leaves
|
|
26
|
+
// business_profile.mcc due; the states are the fifty and the District of Columbia; a request with no forwarded address
|
|
27
|
+
// records 127.0.0.1. An update link (account_update) shows the same page.
|
|
28
|
+
import { twinVendorUrl } from '@volter/world-core';
|
|
2
29
|
import { CONSENT_CSS, flowPage, Payment, PAYMENT_CSS } from '@volter/world-ui';
|
|
3
30
|
import { nowUnix } from "../engine/stripe.js";
|
|
4
31
|
import { formOf, notFound, seeOther, STRIPE_CONSENT_SKIN } from "./shared.js";
|
|
@@ -58,9 +85,9 @@ function page(ctx, sessionUrl, link, kind, values = {}, error) {
|
|
|
58
85
|
: [{ heading: 'Business details', fields: business }, { heading: 'About you', fields: person }, { heading: 'Payout account', fields: payout }, { heading: 'Agreement', fields: agreement }];
|
|
59
86
|
return flowPage({
|
|
60
87
|
title: 'Stripe · Account onboarding',
|
|
61
|
-
css: [
|
|
88
|
+
css: [ONBOARDING_CSS],
|
|
62
89
|
...(error ? { status: 400 } : {}),
|
|
63
|
-
body: (_jsx(
|
|
90
|
+
body: (_jsx(ConnectOnboarding, { merchant: "Stripe", account: String(link.account), checked: ['owners_provided', 'tos'].filter(name => v[name] === (name === 'tos' ? 'accepted' : 'provided')), lines: sections.map(section => ({ name: section.heading, amount: '' })), action: sessionUrl, sections: sections, submit: { label: 'Submit' }, back: { href: String(link.return_url), label: '← Return to platform' }, ...(error ? { error } : {}) })),
|
|
64
91
|
});
|
|
65
92
|
}
|
|
66
93
|
/** An account whose requirements the twin does not model: outside the US, or of another business type. */
|
|
@@ -124,7 +151,7 @@ async function visit(ctx, id) {
|
|
|
124
151
|
return seeOther(String(link.refresh_url));
|
|
125
152
|
const session = `onbs_${id.replace(/^acctlink_/, '')}`;
|
|
126
153
|
await ctx.write(LINK, id, { used: true, _session: session }, 'account_link.used');
|
|
127
|
-
return seeOther(`${SESSIONS}${session}`);
|
|
154
|
+
return seeOther(twinVendorUrl(ctx.call.request, `${SESSIONS}${session}`));
|
|
128
155
|
}
|
|
129
156
|
/** connect.stripe.com's onboarding pages, or undefined for any other request. */
|
|
130
157
|
export async function screen(ctx) {
|
|
@@ -143,7 +170,7 @@ export async function screen(ctx) {
|
|
|
143
170
|
const link = stored ? ctx.get(LINK, String(stored.id)) : undefined;
|
|
144
171
|
if (!stored || !link)
|
|
145
172
|
return gone();
|
|
146
|
-
const sessionUrl = `${SESSIONS}${sid}
|
|
173
|
+
const sessionUrl = twinVendorUrl(request, `${SESSIONS}${sid}`);
|
|
147
174
|
const account = ctx.get('account', String(link.account)) ?? {};
|
|
148
175
|
const kind = kindOf(account);
|
|
149
176
|
if (!kind)
|
|
@@ -157,3 +184,50 @@ export async function screen(ctx) {
|
|
|
157
184
|
await submit(ctx, link, kind, form, request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1');
|
|
158
185
|
return seeOther(String(link.return_url));
|
|
159
186
|
}
|
|
187
|
+
// Visual reference: Stripe's own hosted onboarding example, read 2026-10-08:
|
|
188
|
+
// https://docs.stripe.com/connect/hosted-onboarding
|
|
189
|
+
// hosted_onboarding_form.e59ba8300f563e43489953f06127f52c.png. Platform branding
|
|
190
|
+
// in that example is configurable; this twin uses its existing Stripe skin.
|
|
191
|
+
const ONBOARDING_CSS = PAYMENT_CSS + `
|
|
192
|
+
*{box-sizing:border-box}body{color:#30313d;background:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
193
|
+
.pay{grid-template-columns:minmax(280px,40%) minmax(0,60%)}.pay-summary{background:#f7f7f9;padding:48px max(32px,calc((100vw - 1000px)/2));padding-right:40px;position:relative}
|
|
194
|
+
.pay-back{color:#596171;font-size:13px;margin-bottom:40px}.pay-merchant{color:#635bff;font-size:30px;font-weight:700;letter-spacing:-1px;margin-bottom:32px}
|
|
195
|
+
.pay-lines{counter-reset:step;max-width:280px}.pay-line{counter-increment:step;display:block;position:relative;padding:12px 0 12px 38px;color:#687385;font-size:14px}
|
|
196
|
+
.pay-line::before{content:counter(step);position:absolute;left:0;top:11px;width:24px;height:24px;line-height:22px;text-align:center;border:1px solid #d8dee4;border-radius:50%;background:#fff;font-size:12px}
|
|
197
|
+
.pay-line[aria-current=step]{color:#30313d;font-weight:600}.pay-line[aria-current=step]::before{border-color:#635bff;color:#635bff}.pay-line.is-complete::before{background:#635bff;border-color:#635bff;color:#fff}
|
|
198
|
+
.pay-form{padding:56px 48px;max-width:620px;width:100%}.pay-section legend{font-size:26px;letter-spacing:-.6px;font-weight:600;margin-bottom:24px}.pay-field{margin-bottom:22px}
|
|
199
|
+
.pay-field span{font-size:14px;margin-bottom:8px;font-weight:500}.pay-field input,.pay-field select{background:#fff;border-color:#d8dee4;border-radius:6px;padding:10px 12px;box-shadow:0 1px 2px #00000005;color:#30313d}
|
|
200
|
+
.pay-field input:focus,.pay-field select:focus{outline:2px solid #a5a0ff;outline-offset:1px}
|
|
201
|
+
.pay-field:has(input[type=checkbox]){display:flex;align-items:flex-start;gap:10px}.pay-field:has(input[type=checkbox]) span{order:2;font-weight:400}.pay-field input[type=checkbox]{width:16px;height:16px;flex:none;margin-top:3px;accent-color:#635bff}
|
|
202
|
+
.pay-submit,.connect-next{background:#635bff;color:#fff;border:0;border-radius:6px;font-size:14px;font-weight:600;cursor:pointer;padding:12px 16px}
|
|
203
|
+
.connect-actions{display:flex;justify-content:space-between;gap:16px;margin-top:32px}.connect-back{color:#596171;background:transparent;border:0;cursor:pointer;padding:12px 0;font:inherit}.connect-next{min-width:140px;flex:1}
|
|
204
|
+
.pay-error{border:1px solid #f1c2c2;padding:12px;border-radius:6px;color:#b52e2e;background:#fff7f7;font-size:13px;white-space:normal;overflow-wrap:anywhere}
|
|
205
|
+
.connect-progress{height:3px;background:#ececf1;margin:0 0 36px;border-radius:2px;overflow:hidden}.connect-progress div{height:100%;background:#635bff;transition:width .15s}
|
|
206
|
+
.connect-context{position:absolute;bottom:40px;left:32px;color:#687385;font-size:12px}.connect-context strong{display:block;font-weight:500;color:#30313d}
|
|
207
|
+
.connect-ready .pay-section[hidden],.connect-ready .pay-submit[hidden],.connect-actions [hidden]{display:none}
|
|
208
|
+
@media(max-width:800px){.pay{grid-template-columns:1fr}.pay-summary{padding:24px}.pay-back{margin-bottom:20px}.pay-merchant{font-size:24px;margin-bottom:0}.pay-lines{display:none}.connect-context{position:static;padding:0 24px;background:#f7f7f9}.pay-form{padding:32px 24px;max-width:100%}.pay-section legend{font-size:24px}}
|
|
209
|
+
`;
|
|
210
|
+
// Progressive enhancement only: Back/Continue move through the existing form.
|
|
211
|
+
// No account state is written until its existing Submit operation succeeds.
|
|
212
|
+
const STEPS = `(function(){
|
|
213
|
+
var root=document.querySelector('.connect-onboarding'),form=root.querySelector('form'),groups=Array.from(form.querySelectorAll('.pay-section')),items=Array.from(root.querySelectorAll('.pay-line')),submit=form.querySelector('.pay-submit');
|
|
214
|
+
var progress=document.createElement('div');progress.className='connect-progress';progress.setAttribute('role','progressbar');progress.setAttribute('aria-label','Onboarding progress');progress.setAttribute('aria-valuemin','0');progress.setAttribute('aria-valuemax',String(groups.length));
|
|
215
|
+
var bar=document.createElement('div');progress.append(bar);form.before(progress);
|
|
216
|
+
var controls=document.createElement('div');controls.className='connect-actions';
|
|
217
|
+
var back=document.createElement('button');back.type='button';back.className='connect-back';back.textContent='Back';
|
|
218
|
+
var next=document.createElement('button');next.type='button';next.className='connect-next';next.textContent='Continue';
|
|
219
|
+
controls.append(back,next);form.append(controls);
|
|
220
|
+
var checked=JSON.parse(root.getAttribute('data-checked-fields')||'[]');
|
|
221
|
+
groups.forEach(function(group){group.querySelectorAll('input,select').forEach(function(input){input.required=true;if(input.type==='checkbox')input.checked=checked.includes(input.name);if(input.name==='ssn_last_4'){input.pattern='[0-9]{4}';input.inputMode='numeric';input.maxLength=4;}});});
|
|
222
|
+
var index=0;
|
|
223
|
+
function show(i){index=i;groups.forEach(function(group,n){group.hidden=n!==i;});items.forEach(function(item,n){if(n===i)item.setAttribute('aria-current','step');else item.removeAttribute('aria-current');item.classList.toggle('is-complete',n<i);});back.hidden=i===0;next.hidden=i===groups.length-1;submit.hidden=i!==groups.length-1;bar.style.width=((i+1)/groups.length*100)+'%';progress.setAttribute('aria-valuenow',String(i+1));progress.setAttribute('aria-valuetext',groups[i].querySelector('legend').textContent);root.classList.add('connect-ready');}
|
|
224
|
+
function invalid(group){return Array.from(group.querySelectorAll('input,select')).find(function(input){return !input.checkValidity();});}
|
|
225
|
+
back.addEventListener('click',function(){show(index-1);});
|
|
226
|
+
next.addEventListener('click',function(){var field=invalid(groups[index]);if(field){field.reportValidity();return;}show(index+1);groups[index].querySelector('input,select')?.focus();});
|
|
227
|
+
form.noValidate=true;
|
|
228
|
+
form.addEventListener('submit',function(event){for(var i=0;i<groups.length;i++){var field=invalid(groups[i]);if(field){event.preventDefault();show(i);field.reportValidity();return;}}});
|
|
229
|
+
if(form.querySelector('.pay-error')){var first=groups.findIndex(function(group){return invalid(group);});show(first<0?groups.length-1:first);}else show(0);
|
|
230
|
+
})();`;
|
|
231
|
+
function ConnectOnboarding(props) {
|
|
232
|
+
return _jsxs("div", { className: "connect-onboarding", "data-checked-fields": JSON.stringify(props.checked), children: [_jsx(Payment, { ...props }), _jsxs("div", { className: "connect-context", children: [_jsx("strong", { children: "Test mode" }), props.account] }), _jsx("script", { dangerouslySetInnerHTML: { __html: STEPS } })] });
|
|
233
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { flowPage } from '@volter/world-ui';
|
|
3
3
|
import { VL } from "../engine/radar.js";
|
|
4
4
|
import { nowUnix } from "../engine/stripe.js";
|
|
@@ -13,8 +13,32 @@ const PUBLIC_DETAILS_PATH = '/settings/public';
|
|
|
13
13
|
const MAX_NAME = 5000;
|
|
14
14
|
const DETAILS_CSS = `
|
|
15
15
|
* { box-sizing: border-box; }
|
|
16
|
-
body { margin: 0; background: #
|
|
17
|
-
|
|
16
|
+
body { margin: 0; background: #ffffff; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; font-size: 14px; line-height: 1.5; }
|
|
17
|
+
button,input,select { font: inherit; }
|
|
18
|
+
button,a,input,select { outline-offset: 3px; }
|
|
19
|
+
button { cursor: pointer; }
|
|
20
|
+
button:disabled { cursor: default; color: #697386; }
|
|
21
|
+
a { color: #635bff; text-decoration: none; }
|
|
22
|
+
.sd { min-height: 100vh; display: grid; grid-template-columns: 224px minmax(0,1fr); }
|
|
23
|
+
.sd-side { background: #f6f8fa; border-right: 1px solid #e3e8ee; padding: 20px 14px; }
|
|
24
|
+
.sd-account { font-weight: 600; padding: 6px 10px 20px; overflow-wrap: anywhere; }
|
|
25
|
+
.sd-mode { display: block; width: fit-content; font-size: 11px; font-weight: 500; background: #e3e8ee; border-radius: 4px; margin-top: 8px; padding: 2px 7px; }
|
|
26
|
+
.sd-nav { display: grid; gap: 3px; }
|
|
27
|
+
.sd-nav a,.sd-nav button { display: block; text-align: left; padding: 7px 10px; font: inherit; border: 0; border-radius: 6px; background: none; color: #697386; }
|
|
28
|
+
.sd-nav a { color: #1a1f36; }
|
|
29
|
+
.sd-nav a:hover { background: #e3e8ee; }
|
|
30
|
+
.sd-nav [aria-current=page] { color: #635bff; background: #ffffff; font-weight: 600; }
|
|
31
|
+
.sd-nav-title { margin: 23px 10px 7px; font-size: 12px; font-weight: 600; color: #697386; }
|
|
32
|
+
.sd-body { min-width: 0; }
|
|
33
|
+
.sd-top { min-height: 64px; border-bottom: 1px solid #e3e8ee; padding: 14px 32px; display: flex; align-items: center; gap: 16px; }
|
|
34
|
+
.sd-search { width: min(520px,100%); border: 1px solid #e3e8ee; border-radius: 7px; padding: 7px 12px; background: #f6f8fa; }
|
|
35
|
+
.sd-top a { margin-left: auto; }
|
|
36
|
+
.sd-top button { border: 0; background: none; }
|
|
37
|
+
.sd-content { padding: 28px 40px 60px; max-width: 1280px; }
|
|
38
|
+
.sd-crumbs { color: #697386; font-size: 13px; display: flex; gap: 10px; margin-bottom: 18px; }
|
|
39
|
+
.sd-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid #e3e8ee; padding-bottom: 20px; margin-bottom: 28px; }
|
|
40
|
+
.sd-title h1 { font-size: 28px; margin: 0; letter-spacing: -.5px; }
|
|
41
|
+
.pd { max-width: 860px; }
|
|
18
42
|
.pd h1 { font-size: 28px; margin: 16px 0 4px; }
|
|
19
43
|
.pd-lead { color: #697386; margin: 0 0 24px; }
|
|
20
44
|
.pd-card { background: #ffffff; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; }
|
|
@@ -22,19 +46,38 @@ body { margin: 0; background: #f6f8fa; color: #1a1f36; font-family: -apple-syste
|
|
|
22
46
|
.pd-card small { display: block; color: #697386; margin-bottom: 8px; }
|
|
23
47
|
.pd-card input { width: 100%; padding: 8px 10px; border: 1px solid #c1c9d2; border-radius: 6px; font: inherit; }
|
|
24
48
|
.pd-actions { margin-top: 20px; display: flex; justify-content: flex-end; }
|
|
25
|
-
.pd-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
49
|
+
.sd-primary,.pd-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
26
50
|
.pd-notice { padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; }
|
|
27
51
|
.pd-error { background: #fff0f3; color: #df1b41; }
|
|
28
52
|
.pd-saved { background: #e7f8ef; color: #0e6245; }
|
|
53
|
+
.sd-table { border-collapse: collapse; width: 100%; }
|
|
54
|
+
.sd-table th,.sd-table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid #e3e8ee; }
|
|
55
|
+
.sd-table th { font-size: 12px; font-weight: 600; color: #697386; background: #f6f8fa; }
|
|
56
|
+
.sd-empty { color: #697386; padding: 40px; text-align: center; }
|
|
57
|
+
.sd-code { font-family: ui-monospace,SFMono-Regular,monospace; font-size: 12px; }
|
|
58
|
+
.sd-dialog { width: min(480px,calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow: auto; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; color: #1a1f36; }
|
|
59
|
+
.sd-dialog::backdrop { background: rgba(26,31,54,.4); }
|
|
60
|
+
.sd-dialog h2 { font-size: 20px; margin: 0 0 20px; }
|
|
61
|
+
.sd-dialog label { display: block; font-weight: 600; margin: 14px 0; }
|
|
62
|
+
.sd-dialog input,.sd-dialog select { display: block; width: 100%; margin-top: 6px; border: 1px solid #c1c9d2; border-radius: 6px; padding: 8px 10px; background: #ffffff; }
|
|
63
|
+
.sd-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
|
|
64
|
+
.sd-cancel { border: 1px solid #c1c9d2; border-radius: 6px; padding: 8px 16px; background: #ffffff; color: #1a1f36; }
|
|
65
|
+
@media(max-width:760px) { .sd { grid-template-columns: 160px minmax(0,1fr); } .sd-content { padding: 24px 20px; } .sd-top { padding: 14px 20px; } .sd-top button { display: none; } .sd-table { font-size: 12px; } }
|
|
66
|
+
@media(max-width:520px) { .sd { display: block; } .sd-side { border-right: 0; border-bottom: 1px solid #e3e8ee; padding: 10px 16px; } .sd-account { padding: 0 0 8px; } .sd-mode { display: inline; margin-left: 8px; } .sd-nav { display: flex; flex-wrap: wrap; } .sd-nav button,.sd-nav-title { display: none; } .sd-content { padding: 20px 16px; } .sd-title h1 { font-size: 24px; } .sd-top { padding: 12px 16px; } }
|
|
29
67
|
`;
|
|
30
|
-
|
|
68
|
+
/** The documented dashboard navigation around only the operator pages this pack already serves. */
|
|
69
|
+
function dashboardPage(ctx, opts) {
|
|
70
|
+
const account = ctx.get('account', PLATFORM_ACCOUNT_ID);
|
|
71
|
+
const links = [['Public details', PUBLIC_DETAILS_PATH], ['Radar lists', '/radar/lists'], ['Issuing balance', '/test/issuing/balance']];
|
|
72
|
+
return flowPage({ title: `${opts.title} – Stripe`, css: [DETAILS_CSS], status: opts.status ?? 200, body: _jsxs("div", { className: "sd", children: [_jsxs("aside", { className: "sd-side", children: [_jsxs("div", { className: "sd-account", children: [publicBusinessName(account), _jsx("span", { className: "sd-mode", children: "Test mode" })] }), _jsxs("nav", { className: "sd-nav", "aria-label": "Dashboard", children: [['Home', 'Balances', 'Transactions', 'Customers', 'Product catalog'].map(label => _jsx("button", { type: "button", disabled: true, title: "Not available in this twin's Dashboard", children: label }, label)), _jsx("p", { className: "sd-nav-title", children: "Shortcuts" }), links.map(([label, path]) => _jsx("a", { href: `${ctx.publicBase}${path}`, "aria-current": opts.path === path ? 'page' : undefined, children: label }, path)), _jsx("p", { className: "sd-nav-title", children: "Products" }), ['Payments', 'Billing', 'Reporting', 'Connect', 'More'].map(label => _jsx("button", { type: "button", disabled: true, title: "Not available in this twin's Dashboard", children: label }, label))] })] }), _jsxs("div", { className: "sd-body", children: [_jsxs("header", { className: "sd-top", children: [_jsx("input", { className: "sd-search", "aria-label": "Search Dashboard", placeholder: "Search", disabled: true, title: "Dashboard search is not available in this twin" }), _jsx("button", { type: "button", disabled: true, title: "Not available in this twin's Dashboard", children: "Create" }), _jsx("button", { type: "button", disabled: true, children: "Help" }), _jsx("a", { href: `${ctx.publicBase}${PUBLIC_DETAILS_PATH}`, children: "Settings" })] }), _jsxs("main", { className: "sd-content", children: [_jsxs("nav", { className: "sd-crumbs", "aria-label": "Breadcrumb", children: [_jsx("span", { children: opts.section }), _jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("span", { children: opts.title })] }), _jsxs("div", { className: "sd-title", children: [_jsx("h1", { children: opts.title }), opts.action] }), opts.body] })] }), _jsx("script", { dangerouslySetInnerHTML: { __html: "document.querySelectorAll('[data-open-dialog]').forEach(b=>b.addEventListener('click',()=>document.getElementById(b.dataset.openDialog).showModal()));document.querySelectorAll('[data-close-dialog]').forEach(b=>b.addEventListener('click',()=>b.closest('dialog').close()));" } })] }) });
|
|
73
|
+
}
|
|
74
|
+
function detailsPage(ctx, account, opts = {}) {
|
|
31
75
|
const named = account?.business_profile?.name;
|
|
32
76
|
const value = opts.value ?? (typeof named === 'string' ? named : '');
|
|
33
|
-
return
|
|
34
|
-
title: 'Public details
|
|
35
|
-
css: [DETAILS_CSS],
|
|
77
|
+
return dashboardPage(ctx, {
|
|
78
|
+
title: 'Public details', section: 'Settings / Business', path: PUBLIC_DETAILS_PATH,
|
|
36
79
|
...(opts.error ? { status: 400 } : {}),
|
|
37
|
-
body: (_jsxs("
|
|
80
|
+
body: (_jsxs("div", { className: "pd", children: [_jsxs("p", { className: "pd-lead", children: ["What your customers see on Checkout, the customer portal, receipts and invoices. Currently shown: ", _jsx("strong", { "data-testid": "public-business-name", children: publicBusinessName(account) })] }), opts.error ? _jsx("p", { className: "pd-notice pd-error", role: "alert", children: opts.error }) : null, opts.saved ? _jsx("p", { className: "pd-notice pd-saved", role: "status", children: "Your public details were saved." }) : null, _jsxs("form", { className: "pd-card", method: "post", children: [_jsx("label", { htmlFor: "business_name", children: "Business name" }), _jsx("small", { children: "The name customers see when they pay you." }), _jsx("input", { id: "business_name", name: "business_name", autoComplete: "organization", defaultValue: value, required: true, maxLength: MAX_NAME }), _jsx("div", { className: "pd-actions", children: _jsx("button", { type: "submit", children: "Save" }) })] })] })),
|
|
38
81
|
});
|
|
39
82
|
}
|
|
40
83
|
/** Save the business name on the platform's own account: the stored record, made from Stripe's default the first
|
|
@@ -55,11 +98,11 @@ async function publicDetails(ctx) {
|
|
|
55
98
|
return notFound();
|
|
56
99
|
const form = request.method === 'POST' ? await formOf(request.clone()) : {};
|
|
57
100
|
if (request.method === 'GET')
|
|
58
|
-
return detailsPage(ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
|
|
101
|
+
return detailsPage(ctx, ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
|
|
59
102
|
const name = (form.business_name ?? '').trim();
|
|
60
103
|
const refused = nameRefused(name);
|
|
61
104
|
if (refused)
|
|
62
|
-
return detailsPage(ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
|
|
105
|
+
return detailsPage(ctx, ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
|
|
63
106
|
await save(ctx, name);
|
|
64
107
|
// a query-only reference: the page's own path, wherever it is mounted and with or without its trailing slash
|
|
65
108
|
return new Response(null, { status: 303, headers: { location: '?saved=1' } });
|
|
@@ -67,9 +110,8 @@ async function publicDetails(ctx) {
|
|
|
67
110
|
/** Dashboard settings, Radar list creation and test Issuing funding; other pages answer Stripe's 404. */
|
|
68
111
|
export async function screen(ctx) {
|
|
69
112
|
const path = new URL(ctx.call.request.url).pathname.replace(/\/+$/, '');
|
|
70
|
-
// The
|
|
71
|
-
|
|
72
|
-
if (path === '' && ['GET', 'HEAD'].includes(ctx.call.request.method))
|
|
113
|
+
// The declared workspace opens a served setting, not a fabricated analytics home.
|
|
114
|
+
if (!path && ctx.call.request.method === 'GET')
|
|
73
115
|
return redirect(`${ctx.publicBase}${PUBLIC_DETAILS_PATH}`);
|
|
74
116
|
// source: https://docs.stripe.com/radar/lists "Use the Dashboard or the API to create lists."
|
|
75
117
|
// source: https://docs.stripe.com/radar/lists "Enter a name for the list"
|
|
@@ -93,9 +135,14 @@ export async function screen(ctx) {
|
|
|
93
135
|
list_items: { object: 'list', data: [], has_more: false, url: '/v1/radar/value_list_items' },
|
|
94
136
|
});
|
|
95
137
|
await ctx.write(VL, String(list.id), { list_items: { object: 'list', data: [], has_more: false, url: `/v1/radar/value_list_items?value_list=${list.id}` } }, 'radar.value_list.updated');
|
|
96
|
-
return redirect(
|
|
138
|
+
return redirect(`${ctx.publicBase}/radar/lists/${list.id}`);
|
|
97
139
|
}
|
|
98
|
-
|
|
140
|
+
const lists = ctx.rows(VL);
|
|
141
|
+
const types = [['customer_id', 'Customer ID'], ['email', 'Email'], ['card_fingerprint', 'Card fingerprint'], ['card_bin', 'Card BIN'], ['ip_address', 'IP address'], ['string', 'String'], ['case_sensitive_string', 'Case-sensitive string'], ['country', 'Country'], ['sepa_debit_fingerprint', 'SEPA Direct Debit fingerprint'], ['us_bank_account_fingerprint', 'ACH Direct Debit fingerprint']];
|
|
142
|
+
const selected = lists.find(row => path === `/radar/lists/${row.id}`);
|
|
143
|
+
return dashboardPage(ctx, { title: selected ? String(selected.name) : 'Lists', section: 'Payments / Radar', path: '/radar/lists',
|
|
144
|
+
action: _jsx("button", { className: "sd-primary", type: "button", "data-open-dialog": "radar-new-list", children: "New list" }),
|
|
145
|
+
body: _jsxs(_Fragment, { children: [selected ? _jsxs("div", { className: "pd-card", children: [_jsx("a", { href: `${ctx.publicBase}/radar/lists`, children: "All lists" }), _jsx("h2", { children: String(selected.name) }), _jsxs("p", { children: ["Alias: ", _jsx("code", { children: String(selected.alias) })] }), _jsxs("p", { children: ["List type: ", types.find(([key]) => key === selected.item_type)?.[1] ?? String(selected.item_type)] })] }) : null, _jsx("p", { className: "pd-lead", children: "Lists of information to block, allow or review matching payments." }), _jsxs("table", { className: "sd-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Name" }), _jsx("th", { scope: "col", children: "Alias" }), _jsx("th", { scope: "col", children: "List type" })] }) }), _jsx("tbody", { children: lists.map(row => _jsxs("tr", { children: [_jsx("td", { children: _jsx("a", { href: `${ctx.publicBase}/radar/lists/${encodeURIComponent(String(row.id))}`, children: String(row.name) }) }), _jsx("td", { className: "sd-code", children: String(row.alias) }), _jsx("td", { children: types.find(([key]) => key === row.item_type)?.[1] ?? String(row.item_type) })] }, String(row.id))) })] }), !lists.length ? _jsx("p", { className: "sd-empty", children: "No lists yet. Create a list to group related values." }) : null, _jsxs("dialog", { className: "sd-dialog", id: "radar-new-list", "aria-labelledby": "radar-new-list-title", children: [_jsx("h2", { id: "radar-new-list-title", children: "New list" }), _jsxs("form", { method: "post", action: `${ctx.publicBase}/radar/lists`, children: [_jsx("label", { htmlFor: "radar-list-name", children: "Name" }), _jsx("input", { id: "radar-list-name", name: "name", required: true }), _jsx("label", { htmlFor: "radar-list-alias", children: "Alias" }), _jsx("input", { id: "radar-list-alias", name: "alias" }), _jsx("p", { className: "pd-lead", children: "Leave blank to generate an alias from the name." }), _jsx("label", { htmlFor: "radar-list-type", children: "List type" }), _jsx("select", { id: "radar-list-type", name: "item_type", children: types.map(([value, label]) => _jsx("option", { value: value, children: label }, value)) }), _jsxs("div", { className: "sd-dialog-actions", children: [_jsx("button", { className: "sd-cancel", type: "button", "data-close-dialog": true, children: "Cancel" }), _jsx("button", { className: "sd-primary", type: "submit", children: "Add" })] })] })] })] }) });
|
|
99
146
|
}
|
|
100
147
|
// source: https://docs.stripe.com/issuing/testing "Before you create test transactions, you must add test funds to the Issuing balance on your account."
|
|
101
148
|
// source: https://docs.stripe.com/issuing/testing "You can create test top-ups in the Dashboard, or with the Top-ups API"
|
|
@@ -112,9 +159,9 @@ export async function screen(ctx) {
|
|
|
112
159
|
if (!Number.isInteger(amount) || amount <= 0)
|
|
113
160
|
return invalid('Enter a positive amount in cents.');
|
|
114
161
|
await created(ctx, 'balance_transaction', {}, { amount, currency: 'usd', fee: 0, net: amount, type: 'topup', reporting_category: 'topup', source: null, status: 'available', balance_type: 'issuing', available_on: Number(nowUnix(ctx.occurredAt)), fee_details: [] });
|
|
115
|
-
return redirect(
|
|
162
|
+
return redirect(`${ctx.publicBase}/test/issuing/balance?funded=1`);
|
|
116
163
|
}
|
|
117
|
-
return
|
|
164
|
+
return dashboardPage(ctx, { title: 'Issuing balance', section: 'Issuing', path: '/test/issuing/balance', body: _jsxs("div", { className: "pd", children: [_jsx("p", { className: "pd-lead", children: "Add test funds before creating test Issuing transactions." }), new URL(ctx.call.request.url).searchParams.has('funded') ? _jsx("p", { className: "pd-notice pd-saved", role: "status", children: "Test funds were added to your Issuing balance." }) : null, _jsxs("form", { className: "pd-card", method: "post", action: `${ctx.publicBase}/test/issuing/balance`, children: [_jsx("label", { htmlFor: "issuing-amount", children: "Amount" }), _jsx("small", { children: "USD cents. These funds belong to this test account." }), _jsx("input", { id: "issuing-amount", name: "amount", type: "number", min: "1", step: "1", required: true }), _jsx("div", { className: "pd-actions", children: _jsx("button", { type: "submit", children: "Add test funds" }) })] })] }) });
|
|
118
165
|
}
|
|
119
166
|
return path === PUBLIC_DETAILS_PATH ? publicDetails(ctx) : notFound();
|
|
120
167
|
}
|
|
@@ -2183,7 +2183,11 @@
|
|
|
2183
2183
|
"expect": [
|
|
2184
2184
|
{
|
|
2185
2185
|
"path": "",
|
|
2186
|
-
"contains": "Radar
|
|
2186
|
+
"contains": "Payments / Radar"
|
|
2187
|
+
},
|
|
2188
|
+
{
|
|
2189
|
+
"path": "",
|
|
2190
|
+
"contains": "New list"
|
|
2187
2191
|
}
|
|
2188
2192
|
],
|
|
2189
2193
|
"evidence": "Stripe: https://docs.stripe.com/radar/lists"
|
|
@@ -2690,7 +2694,11 @@
|
|
|
2690
2694
|
"expect": [
|
|
2691
2695
|
{
|
|
2692
2696
|
"path": "",
|
|
2693
|
-
"contains": "
|
|
2697
|
+
"contains": "Issuing balance"
|
|
2698
|
+
},
|
|
2699
|
+
{
|
|
2700
|
+
"path": "",
|
|
2701
|
+
"contains": "Add test funds before creating test Issuing transactions."
|
|
2694
2702
|
}
|
|
2695
2703
|
],
|
|
2696
2704
|
"evidence": "Stripe: https://docs.stripe.com/issuing/testing"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-stripe",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"description": "Local Stripe customer, checkout and billing workflows with synthetic state and the real Stripe SDK. Supported operations and hosted-page limits are documented in the README.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
package/spec/SOURCE.md
CHANGED
|
@@ -16,3 +16,7 @@ The pack serves this document's version (`SERVED_VERSION` in `src/engine/version
|
|
|
16
16
|
publish (its testing page's cards, the payment lifecycle's states, each reference page's stated rule), each with its
|
|
17
17
|
page's URL and the page's words for what it does, read with twin-standard's `read-page`. **Read by:**
|
|
18
18
|
`bun scripts/vendor-examples.ts stripe`.
|
|
19
|
+
|
|
20
|
+
Screen references, read 2026-10-08: [Stripe's hosted-onboarding guide](https://docs.stripe.com/connect/hosted-onboarding), its hosted_onboarding_form.e59ba8300f563e43489953f06127f52c.png image, supplies the two-column layout, progress and sequential form. The image's example platform branding is configurable; this screen retains the Stripe skin. Back/Continue keep local form answers; only the existing final submit changes the connected account. The existing US requirements and refusal behavior remain the contract. No upstream page, image or script is shipped.
|
|
21
|
+
|
|
22
|
+
Dashboard references, read 2026-10-08: [the Web Dashboard guide](https://docs.stripe.com/dashboard/basics) supplies the primary sidebar categories, Shortcuts and Settings. The public [Stripe Apps testing guide](https://docs.stripe.com/stripe-apps/test-app), image `test-app-uninstall.710b89177767cff3a7f53de749558d88.png`, illustrates the earlier horizontal navigation, settings breadcrumbs, form density and type; its publication date is unspecified, and it is not a current Public details screenshot. The authored shell follows the current documented sidebar, while the controls remain the existing public business name, Radar list creation and test Issuing funding. The declared root opens Public details; other Dashboard products are unavailable, and there are no invented analytics. [Radar Lists](https://docs.stripe.com/radar/lists) supplies New, name, alias, type and Add. No upstream image, DOM, script or account sign-in is shipped.
|
package/src/engine/stripe.ts
CHANGED
|
@@ -712,6 +712,28 @@ export function disputeEvidence(): Record<string, unknown> {
|
|
|
712
712
|
type Params = Record<string, unknown>;
|
|
713
713
|
const subParams = (params: Params, k: string): Params => (params[k] && typeof params[k] === 'object' ? (params[k] as Params) : {});
|
|
714
714
|
|
|
715
|
+
// source: https://docs.stripe.com/testing
|
|
716
|
+
// The documented brand cards keep their brand and funding type when Checkout or the portal saves them.
|
|
717
|
+
// Where the documentation stops and the twin decides: other numbers retain the existing synthetic Visa default.
|
|
718
|
+
const TEST_CARD_PROFILES: Record<string, { brand: string; funding: string }> = {
|
|
719
|
+
'4000056655665556': { brand: 'visa', funding: 'debit' },
|
|
720
|
+
'5555555555554444': { brand: 'mastercard', funding: 'credit' },
|
|
721
|
+
'2223003122003222': { brand: 'mastercard', funding: 'credit' },
|
|
722
|
+
'5200828282828210': { brand: 'mastercard', funding: 'debit' },
|
|
723
|
+
'5105105105105100': { brand: 'mastercard', funding: 'prepaid' },
|
|
724
|
+
'378282246310005': { brand: 'amex', funding: 'credit' },
|
|
725
|
+
'371449635398431': { brand: 'amex', funding: 'credit' },
|
|
726
|
+
'6011111111111117': { brand: 'discover', funding: 'credit' },
|
|
727
|
+
'6011000990139424': { brand: 'discover', funding: 'credit' },
|
|
728
|
+
'6011981111111113': { brand: 'discover', funding: 'debit' },
|
|
729
|
+
'3056930009020004': { brand: 'diners', funding: 'credit' },
|
|
730
|
+
'36227206271667': { brand: 'diners', funding: 'credit' },
|
|
731
|
+
'3566002020360505': { brand: 'jcb', funding: 'credit' },
|
|
732
|
+
'6200000000000005': { brand: 'unionpay', funding: 'credit' },
|
|
733
|
+
'6200000000000047': { brand: 'unionpay', funding: 'debit' },
|
|
734
|
+
'6205500000000000004': { brand: 'unionpay', funding: 'credit' },
|
|
735
|
+
};
|
|
736
|
+
|
|
715
737
|
/** A card's own sub-object, from the number and expiry given. */
|
|
716
738
|
function cardSubObject(params: Params): Params {
|
|
717
739
|
const c = subParams(params, 'card');
|
|
@@ -720,15 +742,16 @@ function cardSubObject(params: Params): Params {
|
|
|
720
742
|
// produces its real last4 instead of silently falling back to the '4242' default.
|
|
721
743
|
const number = typeof c.number === 'string' ? c.number.replace(/\D/g, '')
|
|
722
744
|
: typeof c.number === 'number' ? String(c.number).replace(/\D/g, '') : '';
|
|
745
|
+
const profile = TEST_CARD_PROFILES[number] ?? { brand: 'visa', funding: 'credit' };
|
|
723
746
|
return { card: {
|
|
724
|
-
brand:
|
|
747
|
+
brand: profile.brand, last4: number ? number.slice(-4) : '4242',
|
|
725
748
|
exp_month: Number(c.exp_month) || 12, exp_year: Number(c.exp_year) || 2034,
|
|
726
|
-
funding:
|
|
749
|
+
funding: profile.funding, country: 'US',
|
|
727
750
|
// source: spec:/components/schemas/payment_method_card/properties/fingerprint/description "Uniquely identifies this particular card number."
|
|
728
751
|
// The synthetic fingerprint is stable for a number; Stripe's opaque fingerprint algorithm is not published.
|
|
729
752
|
fingerprint: digest('sha256', number || '4242424242424242').slice(0, 16),
|
|
730
753
|
checks: { address_line1_check: null, address_postal_code_check: null, cvc_check: 'pass' },
|
|
731
|
-
networks: { available: [
|
|
754
|
+
networks: { available: [profile.brand], preferred: null },
|
|
732
755
|
three_d_secure_usage: { supported: true }, wallet: null,
|
|
733
756
|
} };
|
|
734
757
|
}
|
|
@@ -81,8 +81,8 @@ function page(ctx: HandlerContext, id: string, session: Row, problem?: { error:
|
|
|
81
81
|
badge: s.status === 'trialing' ? 'Trial' : s.status === 'past_due' ? 'Past due' : ending ? 'Cancels' : undefined,
|
|
82
82
|
note: ending ? `Your plan will be canceled on ${day(s.current_period_end)}.` : s.status === 'trialing' ? `Trial ends ${day(s.trial_end)}.` : `Renews on ${day(s.current_period_end)}.`,
|
|
83
83
|
actions: ending
|
|
84
|
-
? [{ label: 'Renew plan', action:
|
|
85
|
-
: features.cancel.enabled ? [{ label: 'Cancel plan', action:
|
|
84
|
+
? [{ label: 'Renew plan', action: `${ctx.publicBase}/p/session/${id}/renew`, fields: { subscription: String(s.id) }, tone: 'primary' as const }]
|
|
85
|
+
: features.cancel.enabled ? [{ label: 'Cancel plan', action: `${ctx.publicBase}/p/session/${id}/cancel`, fields: { subscription: String(s.id) }, tone: 'danger' as const }] : [],
|
|
86
86
|
};
|
|
87
87
|
});
|
|
88
88
|
const defaultPm = ((customer.invoice_settings as Row | undefined)?.default_payment_method as string | undefined) ?? undefined;
|
|
@@ -97,7 +97,7 @@ function page(ctx: HandlerContext, id: string, session: Row, problem?: { error:
|
|
|
97
97
|
? ctx.rows('invoice').filter((i) => i.customer === session.customer && i.status !== 'draft').sort((a, b) => Number(b.created) - Number(a.created))
|
|
98
98
|
.map((i) => ({
|
|
99
99
|
title: day(i.created), detail: money(i.amount_due ?? i.total, i.currency), badge: String(i.status ?? '').replace(/^./, (c) => c.toUpperCase()),
|
|
100
|
-
...(i.status === 'open' && i.collection_method !== 'send_invoice' ? { actions: [{ label: 'Pay', action:
|
|
100
|
+
...(i.status === 'open' && i.collection_method !== 'send_invoice' ? { actions: [{ label: 'Pay', action: `${ctx.publicBase}/p/session/${id}/pay`, fields: { invoice: String(i.id) }, tone: 'primary' as const }] } : {}),
|
|
101
101
|
}))
|
|
102
102
|
: [];
|
|
103
103
|
const back = typeof session.return_url === 'string' ? session.return_url : undefined;
|
|
@@ -124,7 +124,7 @@ function page(ctx: HandlerContext, id: string, session: Row, problem?: { error:
|
|
|
124
124
|
<Portal
|
|
125
125
|
merchant={merchant}
|
|
126
126
|
{...(problem ? { notice: problem.error } : {})}
|
|
127
|
-
{...(features.paymentMethod ? { forms: [{ heading: pm ? 'Update payment method' : 'Add payment method', action:
|
|
127
|
+
{...(features.paymentMethod ? { forms: [{ heading: pm ? 'Update payment method' : 'Add payment method', action: `${ctx.publicBase}/p/session/${id}/payment_method`, fields: cardFields, submit: { label: pm ? 'Update' : 'Add' } }] } : {})}
|
|
128
128
|
{...(back ? { back: { href: back, label: `← Return to ${merchant}` } } : {})}
|
|
129
129
|
sections={[
|
|
130
130
|
{ heading: 'Current plan', items: plans, empty: 'You have no active plans.' },
|
|
@@ -141,7 +141,7 @@ function page(ctx: HandlerContext, id: string, session: Row, problem?: { error:
|
|
|
141
141
|
async function move(ctx: HandlerContext, id: string, session: Row, subscription: string, kind: 'cancel' | 'renew'): Promise<Response> {
|
|
142
142
|
const sub = resourceView(ctx, SUB, subscription);
|
|
143
143
|
if (!sub || sub.customer !== session.customer || sub.status === 'canceled') return gone();
|
|
144
|
-
const back = new Response(null, { status: 303, headers: { location:
|
|
144
|
+
const back = new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
|
|
145
145
|
if (kind === 'renew') {
|
|
146
146
|
await ctx.write(SUB, subscription, { cancel_at_period_end: false, cancel_at: null, canceled_at: null, cancellation_details: { comment: null, feedback: null, reason: null } }, 'subscription.update');
|
|
147
147
|
return back;
|
|
@@ -179,7 +179,7 @@ async function updatePaymentMethod(ctx: HandlerContext, id: string, session: Row
|
|
|
179
179
|
for (const sub of ctx.rows(SUB).map((s) => ctx.expand(SUB, s)).filter((s) => s.customer === customer && s.status !== 'canceled' && s.status !== 'incomplete_expired')) {
|
|
180
180
|
await ctx.write(SUB, String(sub.id), { default_payment_method: pm.id }, 'subscription.update');
|
|
181
181
|
}
|
|
182
|
-
return new Response(null, { status: 303, headers: { location:
|
|
182
|
+
return new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
/** The customer pays one of their open invoices with the card on file. */
|
|
@@ -192,7 +192,7 @@ async function payInvoice(ctx: HandlerContext, id: string, session: Row, invoice
|
|
|
192
192
|
if (!pm) return page(ctx, id, session, { error: 'Add a payment method to pay this invoice.', values: {} });
|
|
193
193
|
const declined = await payOpenInvoice(ctx, invoiceId, pm);
|
|
194
194
|
if (declined) return page(ctx, id, session, { error: declined.message, values: {} });
|
|
195
|
-
return new Response(null, { status: 303, headers: { location:
|
|
195
|
+
return new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/** billing.stripe.com's portal pages, or undefined for any other request. */
|
package/src/screens/checkout.tsx
CHANGED
|
@@ -94,7 +94,7 @@ function customerEmailOf(session: Row): string | undefined {
|
|
|
94
94
|
return typeof email === 'string' && EMAIL.test(email) ? email : undefined;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function page(session: Row, id: string, now: number, values: Record<string, string> = {}, error?: string): Response {
|
|
97
|
+
function page(publicBase: string, session: Row, id: string, now: number, values: Record<string, string> = {}, error?: string): Response {
|
|
98
98
|
const lines = (((session.line_items as { data?: Row[] } | undefined)?.data) ?? []).map((item) => ({
|
|
99
99
|
name: String(item.description ?? 'Item'),
|
|
100
100
|
amount: money(item.amount_total, item.currency ?? session.currency),
|
|
@@ -128,7 +128,7 @@ function page(session: Row, id: string, now: number, values: Record<string, stri
|
|
|
128
128
|
merchant={merchant}
|
|
129
129
|
lines={lines}
|
|
130
130
|
{...(trialText ? { total: trialText.lead, note: trialText.note } : session.amount_total !== null && session.amount_total !== undefined ? { total: { label: mode === 'subscription' ? 'Subscribe' : `Pay ${merchant}`, amount: money(session.amount_total, session.currency) } } : {})}
|
|
131
|
-
action={
|
|
131
|
+
action={`${publicBase}/c/pay/${id}`}
|
|
132
132
|
sections={[{ heading: 'Contact information', fields: contact }, { heading: 'Payment method', fields: card }]}
|
|
133
133
|
submit={{ label: submit, testId: 'hosted-payment-submit-button' }}
|
|
134
134
|
{...(typeof session.cancel_url === 'string' ? { back: { href: session.cancel_url, label: '← Back' } } : {})}
|
|
@@ -145,13 +145,13 @@ async function pay(ctx: HandlerContext, id: string, session: Row, v: Record<stri
|
|
|
145
145
|
const onFile = customerEmailOf(session);
|
|
146
146
|
const email = onFile ?? (typeof session.customer_email === 'string' ? session.customer_email : (v.email ?? '').trim());
|
|
147
147
|
if (session.mode !== 'setup' || !session.customer) {
|
|
148
|
-
if (!EMAIL.test(email)) return page(session, id, nowUnix(ctx.occurredAt), v, 'Your email address is incomplete.');
|
|
148
|
+
if (!EMAIL.test(email)) return page(ctx.publicBase, session, id, nowUnix(ctx.occurredAt), v, 'Your email address is incomplete.');
|
|
149
149
|
}
|
|
150
150
|
// a trial charges nothing now unless a one-time price is on the first invoice
|
|
151
151
|
const oneTimeDue = (((session.line_items as { data?: Row[] } | undefined)?.data) ?? []).some((it, i) => isOneTime(it, inlineOf(session, i)));
|
|
152
152
|
const chargesNow = session.mode === 'payment' || (session.mode === 'subscription' && (!trialOf(session, nowUnix(ctx.occurredAt)) || oneTimeDue));
|
|
153
153
|
const refused = cardAnswer(v, nowUnix(ctx.occurredAt), chargesNow);
|
|
154
|
-
if (refused) return page(session, id, nowUnix(ctx.occurredAt), v, refused);
|
|
154
|
+
if (refused) return page(ctx.publicBase, session, id, nowUnix(ctx.occurredAt), v, refused);
|
|
155
155
|
let existing = session;
|
|
156
156
|
// Checkout always makes a customer for a subscription; a payment makes one only when the session asks
|
|
157
157
|
if (!session.customer && (session.mode === 'subscription' || session.customer_creation === 'always')) {
|
|
@@ -199,5 +199,5 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
|
199
199
|
const stored = ctx.row(CS, id) ?? {};
|
|
200
200
|
const coupon = (stored._discount as Row | undefined)?.coupon;
|
|
201
201
|
const full = { ...session, _subscription_data: stored._subscription_data, _price_data: stored._price_data, _merchant: merchantOf(ctx, stored), _coupon: typeof coupon === 'string' ? ctx.get('coupon', coupon) : undefined, _customer: typeof session.customer === 'string' ? ctx.get('customer', session.customer) : undefined };
|
|
202
|
-
return request.method === 'GET' ? page(full, id, nowUnix(ctx.occurredAt)) : pay(ctx, id, full, values);
|
|
202
|
+
return request.method === 'GET' ? page(ctx.publicBase, full, id, nowUnix(ctx.occurredAt)) : pay(ctx, id, full, values);
|
|
203
203
|
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// single-use entry point: visiting it takes the owner to their onboarding session, and visiting it again, or after it
|
|
4
4
|
// expires, sends them to refresh_url, where the platform makes a new one (docs.stripe.com/connect/hosted-onboarding).
|
|
5
5
|
// At the session's own address they give Stripe what it requires, and Stripe sends them to return_url. Submitting is
|
|
6
|
-
// the owner's move, not an API call. Authored from @volter/world-ui's payment piece
|
|
6
|
+
// the owner's move, not an API call. Authored from @volter/world-ui's payment piece and Stripe's published visual
|
|
7
|
+
// example; the form moves through one section at a time, retaining answers until the final submit.
|
|
7
8
|
//
|
|
8
9
|
// The page asks for exactly what Stripe requires before a US Express account with card_payments and transfers can
|
|
9
10
|
// take card payments (docs.stripe.com/connect/required-verification-information; the set is the one Stripe's
|
|
@@ -23,8 +24,8 @@
|
|
|
23
24
|
// merchant category codes as its list gives them (./industries.ts), and an industry the page does not offer leaves
|
|
24
25
|
// business_profile.mcc due; the states are the fifty and the District of Columbia; a request with no forwarded address
|
|
25
26
|
// records 127.0.0.1. An update link (account_update) shows the same page.
|
|
26
|
-
import type
|
|
27
|
-
import { CONSENT_CSS, flowPage, Payment, PAYMENT_CSS, type PaymentField } from '@volter/world-ui';
|
|
27
|
+
import { twinVendorUrl, type HandlerContext } from '@volter/world-core';
|
|
28
|
+
import { CONSENT_CSS, flowPage, Payment, PAYMENT_CSS, type PaymentProps, type PaymentField } from '@volter/world-ui';
|
|
28
29
|
import type { Row } from '../engine/common.ts';
|
|
29
30
|
import { nowUnix } from '../engine/stripe.ts';
|
|
30
31
|
import { formOf, notFound, seeOther, STRIPE_CONSENT_SKIN } from './shared.tsx';
|
|
@@ -90,12 +91,14 @@ function page(ctx: HandlerContext, sessionUrl: string, link: Row, kind: Kind, va
|
|
|
90
91
|
: [{ heading: 'Business details', fields: business }, { heading: 'About you', fields: person }, { heading: 'Payout account', fields: payout }, { heading: 'Agreement', fields: agreement }];
|
|
91
92
|
return flowPage({
|
|
92
93
|
title: 'Stripe · Account onboarding',
|
|
93
|
-
css: [
|
|
94
|
+
css: [ONBOARDING_CSS],
|
|
94
95
|
...(error ? { status: 400 } : {}),
|
|
95
96
|
body: (
|
|
96
|
-
<
|
|
97
|
-
merchant=
|
|
98
|
-
|
|
97
|
+
<ConnectOnboarding
|
|
98
|
+
merchant="Stripe"
|
|
99
|
+
account={String(link.account)}
|
|
100
|
+
checked={['owners_provided', 'tos'].filter(name => v[name] === (name === 'tos' ? 'accepted' : 'provided'))}
|
|
101
|
+
lines={sections.map(section => ({ name: section.heading, amount: '' }))}
|
|
99
102
|
action={sessionUrl}
|
|
100
103
|
sections={sections}
|
|
101
104
|
submit={{ label: 'Submit' }}
|
|
@@ -167,7 +170,7 @@ async function visit(ctx: HandlerContext, id: string): Promise<Response> {
|
|
|
167
170
|
if (!usable(ctx, link)) return seeOther(String(link.refresh_url));
|
|
168
171
|
const session = `onbs_${id.replace(/^acctlink_/, '')}`;
|
|
169
172
|
await ctx.write(LINK, id, { used: true, _session: session }, 'account_link.used');
|
|
170
|
-
return seeOther(`${SESSIONS}${session}`);
|
|
173
|
+
return seeOther(twinVendorUrl(ctx.call.request, `${SESSIONS}${session}`));
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
/** connect.stripe.com's onboarding pages, or undefined for any other request. */
|
|
@@ -184,7 +187,7 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
|
184
187
|
const stored = ctx.rowsRaw(LINK).find((l) => l._session === sid && l._submitted !== true);
|
|
185
188
|
const link = stored ? ctx.get(LINK, String(stored.id)) : undefined;
|
|
186
189
|
if (!stored || !link) return gone();
|
|
187
|
-
const sessionUrl = `${SESSIONS}${sid}
|
|
190
|
+
const sessionUrl = twinVendorUrl(request, `${SESSIONS}${sid}`);
|
|
188
191
|
const account = ctx.get('account', String(link.account)) ?? {};
|
|
189
192
|
const kind = kindOf(account);
|
|
190
193
|
if (!kind) return unmodelled(account);
|
|
@@ -195,3 +198,58 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
|
195
198
|
await submit(ctx, link, kind, form, request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1');
|
|
196
199
|
return seeOther(String(link.return_url));
|
|
197
200
|
}
|
|
201
|
+
|
|
202
|
+
// Visual reference: Stripe's own hosted onboarding example, read 2026-10-08:
|
|
203
|
+
// https://docs.stripe.com/connect/hosted-onboarding
|
|
204
|
+
// hosted_onboarding_form.e59ba8300f563e43489953f06127f52c.png. Platform branding
|
|
205
|
+
// in that example is configurable; this twin uses its existing Stripe skin.
|
|
206
|
+
|
|
207
|
+
const ONBOARDING_CSS = PAYMENT_CSS + `
|
|
208
|
+
*{box-sizing:border-box}body{color:#30313d;background:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
209
|
+
.pay{grid-template-columns:minmax(280px,40%) minmax(0,60%)}.pay-summary{background:#f7f7f9;padding:48px max(32px,calc((100vw - 1000px)/2));padding-right:40px;position:relative}
|
|
210
|
+
.pay-back{color:#596171;font-size:13px;margin-bottom:40px}.pay-merchant{color:#635bff;font-size:30px;font-weight:700;letter-spacing:-1px;margin-bottom:32px}
|
|
211
|
+
.pay-lines{counter-reset:step;max-width:280px}.pay-line{counter-increment:step;display:block;position:relative;padding:12px 0 12px 38px;color:#687385;font-size:14px}
|
|
212
|
+
.pay-line::before{content:counter(step);position:absolute;left:0;top:11px;width:24px;height:24px;line-height:22px;text-align:center;border:1px solid #d8dee4;border-radius:50%;background:#fff;font-size:12px}
|
|
213
|
+
.pay-line[aria-current=step]{color:#30313d;font-weight:600}.pay-line[aria-current=step]::before{border-color:#635bff;color:#635bff}.pay-line.is-complete::before{background:#635bff;border-color:#635bff;color:#fff}
|
|
214
|
+
.pay-form{padding:56px 48px;max-width:620px;width:100%}.pay-section legend{font-size:26px;letter-spacing:-.6px;font-weight:600;margin-bottom:24px}.pay-field{margin-bottom:22px}
|
|
215
|
+
.pay-field span{font-size:14px;margin-bottom:8px;font-weight:500}.pay-field input,.pay-field select{background:#fff;border-color:#d8dee4;border-radius:6px;padding:10px 12px;box-shadow:0 1px 2px #00000005;color:#30313d}
|
|
216
|
+
.pay-field input:focus,.pay-field select:focus{outline:2px solid #a5a0ff;outline-offset:1px}
|
|
217
|
+
.pay-field:has(input[type=checkbox]){display:flex;align-items:flex-start;gap:10px}.pay-field:has(input[type=checkbox]) span{order:2;font-weight:400}.pay-field input[type=checkbox]{width:16px;height:16px;flex:none;margin-top:3px;accent-color:#635bff}
|
|
218
|
+
.pay-submit,.connect-next{background:#635bff;color:#fff;border:0;border-radius:6px;font-size:14px;font-weight:600;cursor:pointer;padding:12px 16px}
|
|
219
|
+
.connect-actions{display:flex;justify-content:space-between;gap:16px;margin-top:32px}.connect-back{color:#596171;background:transparent;border:0;cursor:pointer;padding:12px 0;font:inherit}.connect-next{min-width:140px;flex:1}
|
|
220
|
+
.pay-error{border:1px solid #f1c2c2;padding:12px;border-radius:6px;color:#b52e2e;background:#fff7f7;font-size:13px;white-space:normal;overflow-wrap:anywhere}
|
|
221
|
+
.connect-progress{height:3px;background:#ececf1;margin:0 0 36px;border-radius:2px;overflow:hidden}.connect-progress div{height:100%;background:#635bff;transition:width .15s}
|
|
222
|
+
.connect-context{position:absolute;bottom:40px;left:32px;color:#687385;font-size:12px}.connect-context strong{display:block;font-weight:500;color:#30313d}
|
|
223
|
+
.connect-ready .pay-section[hidden],.connect-ready .pay-submit[hidden],.connect-actions [hidden]{display:none}
|
|
224
|
+
@media(max-width:800px){.pay{grid-template-columns:1fr}.pay-summary{padding:24px}.pay-back{margin-bottom:20px}.pay-merchant{font-size:24px;margin-bottom:0}.pay-lines{display:none}.connect-context{position:static;padding:0 24px;background:#f7f7f9}.pay-form{padding:32px 24px;max-width:100%}.pay-section legend{font-size:24px}}
|
|
225
|
+
`;
|
|
226
|
+
|
|
227
|
+
// Progressive enhancement only: Back/Continue move through the existing form.
|
|
228
|
+
// No account state is written until its existing Submit operation succeeds.
|
|
229
|
+
const STEPS = `(function(){
|
|
230
|
+
var root=document.querySelector('.connect-onboarding'),form=root.querySelector('form'),groups=Array.from(form.querySelectorAll('.pay-section')),items=Array.from(root.querySelectorAll('.pay-line')),submit=form.querySelector('.pay-submit');
|
|
231
|
+
var progress=document.createElement('div');progress.className='connect-progress';progress.setAttribute('role','progressbar');progress.setAttribute('aria-label','Onboarding progress');progress.setAttribute('aria-valuemin','0');progress.setAttribute('aria-valuemax',String(groups.length));
|
|
232
|
+
var bar=document.createElement('div');progress.append(bar);form.before(progress);
|
|
233
|
+
var controls=document.createElement('div');controls.className='connect-actions';
|
|
234
|
+
var back=document.createElement('button');back.type='button';back.className='connect-back';back.textContent='Back';
|
|
235
|
+
var next=document.createElement('button');next.type='button';next.className='connect-next';next.textContent='Continue';
|
|
236
|
+
controls.append(back,next);form.append(controls);
|
|
237
|
+
var checked=JSON.parse(root.getAttribute('data-checked-fields')||'[]');
|
|
238
|
+
groups.forEach(function(group){group.querySelectorAll('input,select').forEach(function(input){input.required=true;if(input.type==='checkbox')input.checked=checked.includes(input.name);if(input.name==='ssn_last_4'){input.pattern='[0-9]{4}';input.inputMode='numeric';input.maxLength=4;}});});
|
|
239
|
+
var index=0;
|
|
240
|
+
function show(i){index=i;groups.forEach(function(group,n){group.hidden=n!==i;});items.forEach(function(item,n){if(n===i)item.setAttribute('aria-current','step');else item.removeAttribute('aria-current');item.classList.toggle('is-complete',n<i);});back.hidden=i===0;next.hidden=i===groups.length-1;submit.hidden=i!==groups.length-1;bar.style.width=((i+1)/groups.length*100)+'%';progress.setAttribute('aria-valuenow',String(i+1));progress.setAttribute('aria-valuetext',groups[i].querySelector('legend').textContent);root.classList.add('connect-ready');}
|
|
241
|
+
function invalid(group){return Array.from(group.querySelectorAll('input,select')).find(function(input){return !input.checkValidity();});}
|
|
242
|
+
back.addEventListener('click',function(){show(index-1);});
|
|
243
|
+
next.addEventListener('click',function(){var field=invalid(groups[index]);if(field){field.reportValidity();return;}show(index+1);groups[index].querySelector('input,select')?.focus();});
|
|
244
|
+
form.noValidate=true;
|
|
245
|
+
form.addEventListener('submit',function(event){for(var i=0;i<groups.length;i++){var field=invalid(groups[i]);if(field){event.preventDefault();show(i);field.reportValidity();return;}}});
|
|
246
|
+
if(form.querySelector('.pay-error')){var first=groups.findIndex(function(group){return invalid(group);});show(first<0?groups.length-1:first);}else show(0);
|
|
247
|
+
})();`;
|
|
248
|
+
|
|
249
|
+
function ConnectOnboarding(props: PaymentProps & { account: string; checked: string[] }) {
|
|
250
|
+
return <div className="connect-onboarding" data-checked-fields={JSON.stringify(props.checked)}>
|
|
251
|
+
<Payment {...props} />
|
|
252
|
+
<div className="connect-context"><strong>Test mode</strong>{props.account}</div>
|
|
253
|
+
<script dangerouslySetInnerHTML={{ __html: STEPS }} />
|
|
254
|
+
</div>;
|
|
255
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// THE DASHBOARD — dashboard.stripe.com's pages an operator sets what Stripe's API cannot (docs/contributing/architecture.md,
|
|
2
|
-
// "Screens": a workspace): Public details (/settings/public) and
|
|
3
|
-
// (/settings/connect/onboarding-options/oauth), Radar lists and test Issuing funding.
|
|
2
|
+
// "Screens": a workspace): Public details (/settings/public), Radar lists and test Issuing funding.
|
|
4
3
|
// The operator controls prepare the cited real application calls; other Dashboard pages answer Stripe's 404.
|
|
5
4
|
//
|
|
6
5
|
// THE DASHBOARD'S PUBLIC DETAILS — a workspace page (docs/contributing/architecture.md, "Screens") of the Dashboard
|
|
@@ -23,26 +22,6 @@
|
|
|
23
22
|
// and the save redirects to its own path. Saving is an update of an account that exists (the first save included, though
|
|
24
23
|
// the twin stores the platform's record only then), so every save is sent as account.updated.
|
|
25
24
|
//
|
|
26
|
-
// THE DASHBOARD'S CONNECT OAUTH SETTINGS — a workspace page (docs/contributing/architecture.md, "Screens") of the
|
|
27
|
-
// Dashboard (manifest screen `dashboard`), at dashboard.stripe.com/settings/connect/onboarding-options/oauth, where a
|
|
28
|
-
// platform starts an OAuth integration: "Enable onboarding accounts with OAuth", "Copy your client_id, a unique
|
|
29
|
-
// identifier for your platform that's generated by Stripe", and "Set your redirect_uri ... You must specify all redirect
|
|
30
|
-
// URLs in your platform settings. If you don't include the redirect_uri parameter in your request, Stripe defaults to
|
|
31
|
-
// using the first address you've configured" (docs.stripe.com/connect/oauth-standard-accounts). The test client_id
|
|
32
|
-
// "allows you to: Set your redirect_uri to a non-HTTPS URL; Set your redirect_uri to localhost"
|
|
33
|
-
// (docs.stripe.com/connect/testing#using-oauth). Stripe's API has no call that reads or changes these settings (GET
|
|
34
|
-
// /v1/account does not carry the client_id), so this page is the only way to them, and connect.stripe.com/oauth/authorize
|
|
35
|
-
// (./connect-oauth.tsx) reads what it saves. Authored from plain markup under Stripe's type; nothing of Stripe's page is
|
|
36
|
-
// copied.
|
|
37
|
-
//
|
|
38
|
-
// Where the documentation stops and the twin decides: the World has one platform, and its test client_id is
|
|
39
|
-
// `ca_twin_self` (PLATFORM_CLIENT_ID; the platform account is acct_twin_self), the same in every World, shown on the
|
|
40
|
-
// page in an element a runner reads (data-testid="connect-client-id"). OAuth starts disabled. The page is served at
|
|
41
|
-
// the docs' address, at /test/… (the docs' test-mode link) and at /settings/connect (the address Cal.com's setup
|
|
42
|
-
// guide gives). The redirect URIs are one form field, one per line (or comma-separated, as the reference describes
|
|
43
|
-
// them), saved whole: each an absolute http or https URL without a fragment; http and localhost are allowed, as the
|
|
44
|
-
// test client_id allows them. No sign-in guards the page. The form posts to the
|
|
45
|
-
// page's own address and the save redirects to it.
|
|
46
25
|
import type { HandlerContext } from '@volter/world-core';
|
|
47
26
|
import { flowPage } from '@volter/world-ui';
|
|
48
27
|
import { VL } from '../engine/radar.ts';
|
|
@@ -61,8 +40,32 @@ const MAX_NAME = 5000;
|
|
|
61
40
|
|
|
62
41
|
const DETAILS_CSS = `
|
|
63
42
|
* { box-sizing: border-box; }
|
|
64
|
-
body { margin: 0; background: #
|
|
65
|
-
|
|
43
|
+
body { margin: 0; background: #ffffff; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; font-size: 14px; line-height: 1.5; }
|
|
44
|
+
button,input,select { font: inherit; }
|
|
45
|
+
button,a,input,select { outline-offset: 3px; }
|
|
46
|
+
button { cursor: pointer; }
|
|
47
|
+
button:disabled { cursor: default; color: #697386; }
|
|
48
|
+
a { color: #635bff; text-decoration: none; }
|
|
49
|
+
.sd { min-height: 100vh; display: grid; grid-template-columns: 224px minmax(0,1fr); }
|
|
50
|
+
.sd-side { background: #f6f8fa; border-right: 1px solid #e3e8ee; padding: 20px 14px; }
|
|
51
|
+
.sd-account { font-weight: 600; padding: 6px 10px 20px; overflow-wrap: anywhere; }
|
|
52
|
+
.sd-mode { display: block; width: fit-content; font-size: 11px; font-weight: 500; background: #e3e8ee; border-radius: 4px; margin-top: 8px; padding: 2px 7px; }
|
|
53
|
+
.sd-nav { display: grid; gap: 3px; }
|
|
54
|
+
.sd-nav a,.sd-nav button { display: block; text-align: left; padding: 7px 10px; font: inherit; border: 0; border-radius: 6px; background: none; color: #697386; }
|
|
55
|
+
.sd-nav a { color: #1a1f36; }
|
|
56
|
+
.sd-nav a:hover { background: #e3e8ee; }
|
|
57
|
+
.sd-nav [aria-current=page] { color: #635bff; background: #ffffff; font-weight: 600; }
|
|
58
|
+
.sd-nav-title { margin: 23px 10px 7px; font-size: 12px; font-weight: 600; color: #697386; }
|
|
59
|
+
.sd-body { min-width: 0; }
|
|
60
|
+
.sd-top { min-height: 64px; border-bottom: 1px solid #e3e8ee; padding: 14px 32px; display: flex; align-items: center; gap: 16px; }
|
|
61
|
+
.sd-search { width: min(520px,100%); border: 1px solid #e3e8ee; border-radius: 7px; padding: 7px 12px; background: #f6f8fa; }
|
|
62
|
+
.sd-top a { margin-left: auto; }
|
|
63
|
+
.sd-top button { border: 0; background: none; }
|
|
64
|
+
.sd-content { padding: 28px 40px 60px; max-width: 1280px; }
|
|
65
|
+
.sd-crumbs { color: #697386; font-size: 13px; display: flex; gap: 10px; margin-bottom: 18px; }
|
|
66
|
+
.sd-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid #e3e8ee; padding-bottom: 20px; margin-bottom: 28px; }
|
|
67
|
+
.sd-title h1 { font-size: 28px; margin: 0; letter-spacing: -.5px; }
|
|
68
|
+
.pd { max-width: 860px; }
|
|
66
69
|
.pd h1 { font-size: 28px; margin: 16px 0 4px; }
|
|
67
70
|
.pd-lead { color: #697386; margin: 0 0 24px; }
|
|
68
71
|
.pd-card { background: #ffffff; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; }
|
|
@@ -70,22 +73,50 @@ body { margin: 0; background: #f6f8fa; color: #1a1f36; font-family: -apple-syste
|
|
|
70
73
|
.pd-card small { display: block; color: #697386; margin-bottom: 8px; }
|
|
71
74
|
.pd-card input { width: 100%; padding: 8px 10px; border: 1px solid #c1c9d2; border-radius: 6px; font: inherit; }
|
|
72
75
|
.pd-actions { margin-top: 20px; display: flex; justify-content: flex-end; }
|
|
73
|
-
.pd-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
76
|
+
.sd-primary,.pd-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
74
77
|
.pd-notice { padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; }
|
|
75
78
|
.pd-error { background: #fff0f3; color: #df1b41; }
|
|
76
79
|
.pd-saved { background: #e7f8ef; color: #0e6245; }
|
|
80
|
+
.sd-table { border-collapse: collapse; width: 100%; }
|
|
81
|
+
.sd-table th,.sd-table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid #e3e8ee; }
|
|
82
|
+
.sd-table th { font-size: 12px; font-weight: 600; color: #697386; background: #f6f8fa; }
|
|
83
|
+
.sd-empty { color: #697386; padding: 40px; text-align: center; }
|
|
84
|
+
.sd-code { font-family: ui-monospace,SFMono-Regular,monospace; font-size: 12px; }
|
|
85
|
+
.sd-dialog { width: min(480px,calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow: auto; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; color: #1a1f36; }
|
|
86
|
+
.sd-dialog::backdrop { background: rgba(26,31,54,.4); }
|
|
87
|
+
.sd-dialog h2 { font-size: 20px; margin: 0 0 20px; }
|
|
88
|
+
.sd-dialog label { display: block; font-weight: 600; margin: 14px 0; }
|
|
89
|
+
.sd-dialog input,.sd-dialog select { display: block; width: 100%; margin-top: 6px; border: 1px solid #c1c9d2; border-radius: 6px; padding: 8px 10px; background: #ffffff; }
|
|
90
|
+
.sd-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
|
|
91
|
+
.sd-cancel { border: 1px solid #c1c9d2; border-radius: 6px; padding: 8px 16px; background: #ffffff; color: #1a1f36; }
|
|
92
|
+
@media(max-width:760px) { .sd { grid-template-columns: 160px minmax(0,1fr); } .sd-content { padding: 24px 20px; } .sd-top { padding: 14px 20px; } .sd-top button { display: none; } .sd-table { font-size: 12px; } }
|
|
93
|
+
@media(max-width:520px) { .sd { display: block; } .sd-side { border-right: 0; border-bottom: 1px solid #e3e8ee; padding: 10px 16px; } .sd-account { padding: 0 0 8px; } .sd-mode { display: inline; margin-left: 8px; } .sd-nav { display: flex; flex-wrap: wrap; } .sd-nav button,.sd-nav-title { display: none; } .sd-content { padding: 20px 16px; } .sd-title h1 { font-size: 24px; } .sd-top { padding: 12px 16px; } }
|
|
77
94
|
`;
|
|
78
95
|
|
|
79
|
-
|
|
96
|
+
type DashboardPage = { title: string; section: string; path: string; body: Parameters<typeof flowPage>[0]['body']; action?: Parameters<typeof flowPage>[0]['body']; status?: number };
|
|
97
|
+
/** The documented dashboard navigation around only the operator pages this pack already serves. */
|
|
98
|
+
function dashboardPage(ctx: HandlerContext, opts: DashboardPage): Response {
|
|
99
|
+
const account = ctx.get('account', PLATFORM_ACCOUNT_ID);
|
|
100
|
+
const links = [['Public details', PUBLIC_DETAILS_PATH], ['Radar lists', '/radar/lists'], ['Issuing balance', '/test/issuing/balance']];
|
|
101
|
+
return flowPage({ title: `${opts.title} – Stripe`, css: [DETAILS_CSS], status: opts.status ?? 200, body: <div className="sd">
|
|
102
|
+
<aside className="sd-side"><div className="sd-account">{publicBusinessName(account)}<span className="sd-mode">Test mode</span></div><nav className="sd-nav" aria-label="Dashboard">
|
|
103
|
+
{['Home', 'Balances', 'Transactions', 'Customers', 'Product catalog'].map(label => <button key={label} type="button" disabled title="Not available in this twin's Dashboard">{label}</button>)}
|
|
104
|
+
<p className="sd-nav-title">Shortcuts</p>{links.map(([label,path]) => <a key={path} href={`${ctx.publicBase}${path}`} aria-current={opts.path === path ? 'page' : undefined}>{label}</a>)}
|
|
105
|
+
<p className="sd-nav-title">Products</p>{['Payments', 'Billing', 'Reporting', 'Connect', 'More'].map(label => <button key={label} type="button" disabled title="Not available in this twin's Dashboard">{label}</button>)}
|
|
106
|
+
</nav></aside><div className="sd-body"><header className="sd-top"><input className="sd-search" aria-label="Search Dashboard" placeholder="Search" disabled title="Dashboard search is not available in this twin"/><button type="button" disabled title="Not available in this twin's Dashboard">Create</button><button type="button" disabled>Help</button><a href={`${ctx.publicBase}${PUBLIC_DETAILS_PATH}`}>Settings</a></header>
|
|
107
|
+
<main className="sd-content"><nav className="sd-crumbs" aria-label="Breadcrumb"><span>{opts.section}</span><span aria-hidden="true">/</span><span>{opts.title}</span></nav><div className="sd-title"><h1>{opts.title}</h1>{opts.action}</div>{opts.body}</main></div>
|
|
108
|
+
<script dangerouslySetInnerHTML={{ __html: "document.querySelectorAll('[data-open-dialog]').forEach(b=>b.addEventListener('click',()=>document.getElementById(b.dataset.openDialog).showModal()));document.querySelectorAll('[data-close-dialog]').forEach(b=>b.addEventListener('click',()=>b.closest('dialog').close()));" }}/>
|
|
109
|
+
</div> });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function detailsPage(ctx: HandlerContext, account: Row | undefined, opts: { value?: string; error?: string; saved?: boolean } = {}): Response {
|
|
80
113
|
const named = (account?.business_profile as Row | undefined)?.name;
|
|
81
114
|
const value = opts.value ?? (typeof named === 'string' ? named : '');
|
|
82
|
-
return
|
|
83
|
-
title: 'Public details
|
|
84
|
-
css: [DETAILS_CSS],
|
|
115
|
+
return dashboardPage(ctx, {
|
|
116
|
+
title: 'Public details', section: 'Settings / Business', path: PUBLIC_DETAILS_PATH,
|
|
85
117
|
...(opts.error ? { status: 400 } : {}),
|
|
86
118
|
body: (
|
|
87
|
-
<
|
|
88
|
-
<h1>Public details</h1>
|
|
119
|
+
<div className="pd">
|
|
89
120
|
<p className="pd-lead">What your customers see on Checkout, the customer portal, receipts and invoices. Currently shown: <strong data-testid="public-business-name">{publicBusinessName(account)}</strong></p>
|
|
90
121
|
{opts.error ? <p className="pd-notice pd-error" role="alert">{opts.error}</p> : null}
|
|
91
122
|
{opts.saved ? <p className="pd-notice pd-saved" role="status">Your public details were saved.</p> : null}
|
|
@@ -95,7 +126,7 @@ function detailsPage(account: Row | undefined, opts: { value?: string; error?: s
|
|
|
95
126
|
<input id="business_name" name="business_name" autoComplete="organization" defaultValue={value} required maxLength={MAX_NAME} />
|
|
96
127
|
<div className="pd-actions"><button type="submit">Save</button></div>
|
|
97
128
|
</form>
|
|
98
|
-
</
|
|
129
|
+
</div>
|
|
99
130
|
),
|
|
100
131
|
});
|
|
101
132
|
}
|
|
@@ -118,10 +149,10 @@ async function publicDetails(ctx: HandlerContext): Promise<Response> {
|
|
|
118
149
|
const url = new URL(request.url);
|
|
119
150
|
if (request.method !== 'GET' && request.method !== 'POST') return notFound();
|
|
120
151
|
const form = request.method === 'POST' ? await formOf(request.clone()) : {};
|
|
121
|
-
if (request.method === 'GET') return detailsPage(ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
|
|
152
|
+
if (request.method === 'GET') return detailsPage(ctx, ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
|
|
122
153
|
const name = (form.business_name ?? '').trim();
|
|
123
154
|
const refused = nameRefused(name);
|
|
124
|
-
if (refused) return detailsPage(ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
|
|
155
|
+
if (refused) return detailsPage(ctx, ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
|
|
125
156
|
await save(ctx, name);
|
|
126
157
|
// a query-only reference: the page's own path, wherever it is mounted and with or without its trailing slash
|
|
127
158
|
return new Response(null, { status: 303, headers: { location: '?saved=1' } });
|
|
@@ -130,9 +161,8 @@ async function publicDetails(ctx: HandlerContext): Promise<Response> {
|
|
|
130
161
|
/** Dashboard settings, Radar list creation and test Issuing funding; other pages answer Stripe's 404. */
|
|
131
162
|
export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
132
163
|
const path = new URL(ctx.call.request.url).pathname.replace(/\/+$/, '');
|
|
133
|
-
// The
|
|
134
|
-
|
|
135
|
-
if (path === '' && ['GET', 'HEAD'].includes(ctx.call.request.method)) return redirect(`${ctx.publicBase}${PUBLIC_DETAILS_PATH}`);
|
|
164
|
+
// The declared workspace opens a served setting, not a fabricated analytics home.
|
|
165
|
+
if (!path && ctx.call.request.method === 'GET') return redirect(`${ctx.publicBase}${PUBLIC_DETAILS_PATH}`);
|
|
136
166
|
// source: https://docs.stripe.com/radar/lists "Use the Dashboard or the API to create lists."
|
|
137
167
|
// source: https://docs.stripe.com/radar/lists "Enter a name for the list"
|
|
138
168
|
// source: https://docs.stripe.com/radar/lists "Select the type of list to create."
|
|
@@ -154,9 +184,22 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
|
154
184
|
list_items: { object: 'list', data: [], has_more: false, url: '/v1/radar/value_list_items' },
|
|
155
185
|
});
|
|
156
186
|
await ctx.write(VL, String(list.id), { list_items: { object: 'list', data: [], has_more: false, url: `/v1/radar/value_list_items?value_list=${list.id}` } }, 'radar.value_list.updated');
|
|
157
|
-
return redirect(
|
|
187
|
+
return redirect(`${ctx.publicBase}/radar/lists/${list.id}`);
|
|
158
188
|
}
|
|
159
|
-
|
|
189
|
+
const lists = ctx.rows(VL);
|
|
190
|
+
const types = [['customer_id', 'Customer ID'], ['email', 'Email'], ['card_fingerprint', 'Card fingerprint'], ['card_bin', 'Card BIN'], ['ip_address', 'IP address'], ['string', 'String'], ['case_sensitive_string', 'Case-sensitive string'], ['country', 'Country'], ['sepa_debit_fingerprint', 'SEPA Direct Debit fingerprint'], ['us_bank_account_fingerprint', 'ACH Direct Debit fingerprint']];
|
|
191
|
+
const selected = lists.find(row => path === `/radar/lists/${row.id}`);
|
|
192
|
+
return dashboardPage(ctx, { title: selected ? String(selected.name) : 'Lists', section: 'Payments / Radar', path: '/radar/lists',
|
|
193
|
+
action: <button className="sd-primary" type="button" data-open-dialog="radar-new-list">New list</button>,
|
|
194
|
+
body: <>{selected ? <div className="pd-card"><a href={`${ctx.publicBase}/radar/lists`}>All lists</a><h2>{String(selected.name)}</h2><p>Alias: <code>{String(selected.alias)}</code></p><p>List type: {types.find(([key]) => key === selected.item_type)?.[1] ?? String(selected.item_type)}</p></div> : null}<p className="pd-lead">Lists of information to block, allow or review matching payments.</p>
|
|
195
|
+
<table className="sd-table"><thead><tr><th scope="col">Name</th><th scope="col">Alias</th><th scope="col">List type</th></tr></thead><tbody>{lists.map(row => <tr key={String(row.id)}><td><a href={`${ctx.publicBase}/radar/lists/${encodeURIComponent(String(row.id))}`}>{String(row.name)}</a></td><td className="sd-code">{String(row.alias)}</td><td>{types.find(([key]) => key === row.item_type)?.[1] ?? String(row.item_type)}</td></tr>)}</tbody></table>
|
|
196
|
+
{!lists.length ? <p className="sd-empty">No lists yet. Create a list to group related values.</p> : null}
|
|
197
|
+
<dialog className="sd-dialog" id="radar-new-list" aria-labelledby="radar-new-list-title"><h2 id="radar-new-list-title">New list</h2><form method="post" action={`${ctx.publicBase}/radar/lists`}>
|
|
198
|
+
<label htmlFor="radar-list-name">Name</label><input id="radar-list-name" name="name" required/>
|
|
199
|
+
<label htmlFor="radar-list-alias">Alias</label><input id="radar-list-alias" name="alias"/><p className="pd-lead">Leave blank to generate an alias from the name.</p>
|
|
200
|
+
<label htmlFor="radar-list-type">List type</label><select id="radar-list-type" name="item_type">{types.map(([value,label]) => <option key={value} value={value}>{label}</option>)}</select>
|
|
201
|
+
<div className="sd-dialog-actions"><button className="sd-cancel" type="button" data-close-dialog>Cancel</button><button className="sd-primary" type="submit">Add</button></div>
|
|
202
|
+
</form></dialog></> });
|
|
160
203
|
}
|
|
161
204
|
// source: https://docs.stripe.com/issuing/testing "Before you create test transactions, you must add test funds to the Issuing balance on your account."
|
|
162
205
|
// source: https://docs.stripe.com/issuing/testing "You can create test top-ups in the Dashboard, or with the Top-ups API"
|
|
@@ -172,9 +215,9 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
|
172
215
|
const amount = Number(form.amount);
|
|
173
216
|
if (!Number.isInteger(amount) || amount <= 0) return invalid('Enter a positive amount in cents.');
|
|
174
217
|
await created(ctx, 'balance_transaction', {}, { amount, currency: 'usd', fee: 0, net: amount, type: 'topup', reporting_category: 'topup', source: null, status: 'available', balance_type: 'issuing', available_on: Number(nowUnix(ctx.occurredAt)), fee_details: [] });
|
|
175
|
-
return redirect(
|
|
218
|
+
return redirect(`${ctx.publicBase}/test/issuing/balance?funded=1`);
|
|
176
219
|
}
|
|
177
|
-
return
|
|
220
|
+
return dashboardPage(ctx, { title: 'Issuing balance', section: 'Issuing', path: '/test/issuing/balance', body: <div className="pd"><p className="pd-lead">Add test funds before creating test Issuing transactions.</p>{new URL(ctx.call.request.url).searchParams.has('funded') ? <p className="pd-notice pd-saved" role="status">Test funds were added to your Issuing balance.</p> : null}<form className="pd-card" method="post" action={`${ctx.publicBase}/test/issuing/balance`}><label htmlFor="issuing-amount">Amount</label><small>USD cents. These funds belong to this test account.</small><input id="issuing-amount" name="amount" type="number" min="1" step="1" required/><div className="pd-actions"><button type="submit">Add test funds</button></div></form></div> });
|
|
178
221
|
}
|
|
179
222
|
return path === PUBLIC_DETAILS_PATH ? publicDetails(ctx) : notFound();
|
|
180
223
|
}
|