@volter/twin-stripe 3.0.2 → 3.0.4

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.
@@ -1,11 +1,11 @@
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";
5
5
  import { platformAccountDefault } from "../engine/connect.js";
6
6
  import { publicBusinessName } from "../engine/display.js";
7
7
  import { PLATFORM_ACCOUNT_ID } from "../engine/stripe.js";
8
- import { formOf, notFound } from "./shared.js";
8
+ import { formOf, money, notFound } from "./shared.js";
9
9
  import { created } from "../semantics/shared.js";
10
10
  const redirect = (path) => new Response(null, { status: 303, headers: { location: path } });
11
11
  const invalid = (message) => flowPage({ css: [], title: 'Stripe', status: 400, body: _jsx("main", { children: _jsx("p", { role: "alert", children: message }) }) });
@@ -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: #f6f8fa; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; font-size: 14px; }
17
- .pd { max-width: 640px; margin: 48px auto; padding: 0 16px; }
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,94 @@ 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-customer-grid { display: grid; grid-template-columns: minmax(0,1fr) 300px; gap: 32px; align-items: start; }
59
+ .sd-customer-grid > * { min-width: 0; }
60
+ .sd-module { border-bottom: 1px solid #e3e8ee; padding: 0 0 24px; margin-bottom: 24px; overflow-x: auto; }
61
+ .sd-module h2 { font-size: 18px; margin: 0 0 16px; }
62
+ .sd-customer-details { border-left: 1px solid #e3e8ee; padding-left: 24px; overflow-wrap: anywhere; }
63
+ .sd-customer-details dl { margin: 0; }
64
+ .sd-customer-details dt { color: #697386; font-size: 12px; margin-top: 16px; }
65
+ .sd-customer-details dd { margin: 3px 0 0; }
66
+ .sd-customer-filter { display: flex; gap: 8px; margin-bottom: 20px; }
67
+ .sd-customer-filter input { min-width: 0; width: 320px; border: 1px solid #c1c9d2; border-radius: 6px; padding: 8px 10px; }
68
+ .sd-customer-tabs { display: flex; border-bottom: 1px solid #e3e8ee; margin-bottom: 28px; }
69
+ .sd-customer-tabs span { border-bottom: 2px solid #635bff; padding: 0 0 12px; color: #635bff; font-weight: 600; }
70
+ @media(max-width:1000px) { .sd-customer-grid { grid-template-columns: minmax(0,1fr); } .sd-customer-details { border-left: 0; padding: 0; } }
71
+ .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; }
72
+ .sd-dialog::backdrop { background: rgba(26,31,54,.4); }
73
+ .sd-dialog h2 { font-size: 20px; margin: 0 0 20px; }
74
+ .sd-dialog label { display: block; font-weight: 600; margin: 14px 0; }
75
+ .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; }
76
+ .sd-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
77
+ .sd-cancel { border: 1px solid #c1c9d2; border-radius: 6px; padding: 8px 16px; background: #ffffff; color: #1a1f36; }
78
+ @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; } }
79
+ @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
80
  `;
30
- function detailsPage(account, opts = {}) {
81
+ /** The documented dashboard navigation around only the operator pages this pack already serves. */
82
+ function dashboardPage(ctx, opts) {
83
+ const account = ctx.get('account', PLATFORM_ACCOUNT_ID);
84
+ const links = [['Public details', PUBLIC_DETAILS_PATH], ['Radar lists', '/radar/lists'], ['Issuing balance', '/test/issuing/balance']];
85
+ 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 => label === 'Customers'
86
+ ? _jsx("a", { href: `${ctx.publicBase}/customers`, "aria-current": opts.path === '/customers' ? 'page' : undefined, children: "Customers" }, label)
87
+ : _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()));" } })] }) });
88
+ }
89
+ const customerName = (row) => String(row.name || row.email || row.id);
90
+ const resourceId = (value) => typeof value === 'string' ? value : value && typeof value === 'object' ? String(value.id ?? '') : '';
91
+ const newest = (rows) => rows.slice().reverse().sort((a, b) => Number(b.created ?? 0) - Number(a.created ?? 0));
92
+ const customerDate = (value) => typeof value === 'number' && Number.isFinite(value)
93
+ ? new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(value * 1000)) : '—';
94
+ // source: https://support.stripe.com/questions/export-customer-data-without-the-payment-details
95
+ // source: https://support.stripe.com/questions/updates-to-the-customer-detail-page
96
+ // These public support articles render their body in JavaScript; the source reader gets their shell.
97
+ // Their public rendered body supplies Customers and the dynamic-left/static-right layout (spec/SOURCE.md).
98
+ // Where public references stop: q is an authored name/email/id/description substring filter, not Stripe's
99
+ // private Dashboard search wire. Dates are shown in UTC; browser actions here are read-only. The declared
100
+ // page addresses preserve the customer's returned API id. There is no invented payment history or total.
101
+ function customersPage(ctx, path) {
102
+ if (ctx.call.request.method !== 'GET')
103
+ return notFound();
104
+ if (path === '/customers') {
105
+ const query = new URL(ctx.call.request.url).searchParams.get('q') ?? '';
106
+ const needle = query.trim().toLowerCase();
107
+ const customers = newest(ctx.rows('customer')).filter(row => !needle ||
108
+ [row.name, row.email, row.id, row.description].some(value => typeof value === 'string' && value.toLowerCase().includes(needle)));
109
+ return dashboardPage(ctx, { title: 'Customers', section: 'Customers', path: '/customers',
110
+ body: _jsxs(_Fragment, { children: [_jsxs("form", { className: "sd-customer-filter", method: "get", action: `${ctx.publicBase}/customers`, children: [_jsx("input", { name: "q", "aria-label": "Search customers", placeholder: "Search customers", defaultValue: query }), _jsx("button", { className: "sd-cancel", type: "submit", children: "Search" }), query ? _jsx("a", { href: `${ctx.publicBase}/customers`, children: "Clear" }) : null] }), _jsxs("table", { className: "sd-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Name" }), _jsx("th", { scope: "col", children: "Email" }), _jsx("th", { scope: "col", children: "Description" }), _jsx("th", { scope: "col", children: "Created (UTC)" })] }) }), _jsx("tbody", { children: customers.map(row => _jsxs("tr", { children: [_jsx("td", { children: _jsx("a", { href: `${ctx.publicBase}/customers/${encodeURIComponent(String(row.id))}`, children: customerName(row) }) }), _jsx("td", { children: String(row.email ?? '—') }), _jsx("td", { children: String(row.description ?? '—') }), _jsx("td", { children: customerDate(row.created) })] }, String(row.id))) })] }), !customers.length ? _jsx("p", { className: "sd-empty", children: needle ? 'No customers match your search.' : 'No customers yet.' }) : null] }) });
111
+ }
112
+ let id;
113
+ try {
114
+ id = decodeURIComponent(path.slice('/customers/'.length));
115
+ }
116
+ catch {
117
+ return notFound();
118
+ }
119
+ const customer = ctx.get('customer', id);
120
+ if (!customer || customer.deleted === true)
121
+ return notFound();
122
+ const related = (type) => newest(ctx.rows(type)).filter(row => resourceId(row.customer) === id);
123
+ const payments = related('charge'), subscriptions = related('subscription'), invoices = related('invoice'), methods = related('payment_method');
124
+ const metadata = customer.metadata && typeof customer.metadata === 'object' ? Object.entries(customer.metadata) : [];
125
+ const address = customer.address && typeof customer.address === 'object' ? customer.address : undefined;
126
+ const addressText = address ? ['line1', 'line2', 'city', 'state', 'postal_code', 'country'].map(key => address[key]).filter(Boolean).join(', ') : '—';
127
+ return dashboardPage(ctx, { title: customerName(customer), section: 'Customers', path: '/customers',
128
+ body: _jsxs(_Fragment, { children: [_jsx("a", { href: `${ctx.publicBase}/customers`, children: "\u2190 Customers" }), _jsx("p", { className: "pd-lead sd-code", children: id }), _jsx("div", { className: "sd-customer-tabs", children: _jsx("span", { children: "Overview" }) }), _jsxs("div", { className: "sd-customer-grid", children: [_jsxs("div", { children: [_jsxs("section", { className: "sd-module", children: [_jsx("h2", { children: "Subscriptions" }), subscriptions.length ? _jsxs("table", { className: "sd-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Subscription" }), _jsx("th", { scope: "col", children: "Status" }), _jsx("th", { scope: "col", children: "Created (UTC)" })] }) }), _jsx("tbody", { children: subscriptions.map(row => _jsxs("tr", { children: [_jsx("td", { className: "sd-code", children: String(row.id) }), _jsx("td", { children: String(row.status ?? '—') }), _jsx("td", { children: customerDate(row.created) })] }, String(row.id))) })] }) : _jsx("p", { className: "pd-lead", children: "No subscriptions" })] }), _jsxs("section", { className: "sd-module", children: [_jsx("h2", { children: "Payments" }), payments.length ? _jsxs("table", { className: "sd-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Amount" }), _jsx("th", { scope: "col", children: "Status" }), _jsx("th", { scope: "col", children: "Description" }), _jsx("th", { scope: "col", children: "Created (UTC)" })] }) }), _jsx("tbody", { children: payments.map(row => _jsxs("tr", { children: [_jsx("td", { children: money(row.amount, row.currency) }), _jsx("td", { children: row.refunded === true ? 'Refunded' : String(row.status ?? '—') }), _jsx("td", { children: String(row.description ?? row.id) }), _jsx("td", { children: customerDate(row.created) })] }, String(row.id))) })] }) : _jsx("p", { className: "pd-lead", children: "No payments" })] }), _jsxs("section", { className: "sd-module", children: [_jsx("h2", { children: "Payment methods" }), methods.length ? _jsx("ul", { children: methods.map(row => { const card = row.card && typeof row.card === 'object' ? row.card : undefined; return _jsxs("li", { children: [card ? `${String(card.brand ?? 'Card')} •••• ${String(card.last4 ?? '')}` : String(row.type ?? 'Payment method'), " ", _jsx("span", { className: "sd-code", children: String(row.id) })] }, String(row.id)); }) }) : _jsx("p", { className: "pd-lead", children: "No payment methods" })] }), _jsxs("section", { className: "sd-module", children: [_jsx("h2", { children: "Invoices" }), invoices.length ? _jsxs("table", { className: "sd-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Invoice" }), _jsx("th", { scope: "col", children: "Amount due" }), _jsx("th", { scope: "col", children: "Status" })] }) }), _jsx("tbody", { children: invoices.map(row => _jsxs("tr", { children: [_jsx("td", { children: String(row.number ?? row.id) }), _jsx("td", { children: money(row.amount_due, row.currency) }), _jsx("td", { children: String(row.status ?? '—') })] }, String(row.id))) })] }) : _jsx("p", { className: "pd-lead", children: "No invoices" })] })] }), _jsxs("aside", { className: "sd-customer-details", "aria-label": "Customer details", children: [_jsxs("section", { className: "sd-module", children: [_jsx("h2", { children: "Details" }), _jsxs("dl", { children: [_jsx("dt", { children: "Customer ID" }), _jsx("dd", { className: "sd-code", children: id }), _jsx("dt", { children: "Name" }), _jsx("dd", { children: String(customer.name ?? '—') }), _jsx("dt", { children: "Email" }), _jsx("dd", { children: String(customer.email ?? '—') }), _jsx("dt", { children: "Phone" }), _jsx("dd", { children: String(customer.phone ?? '—') }), _jsx("dt", { children: "Description" }), _jsx("dd", { children: String(customer.description ?? '—') }), _jsx("dt", { children: "Billing address" }), _jsx("dd", { children: addressText }), _jsx("dt", { children: "Created (UTC)" }), _jsx("dd", { children: customerDate(customer.created) })] })] }), _jsxs("section", { className: "sd-module", children: [_jsx("h2", { children: "Metadata" }), metadata.length ? _jsx("dl", { children: metadata.map(([key, value]) => _jsxs("div", { children: [_jsx("dt", { children: key }), _jsx("dd", { children: typeof value === 'string' ? value : JSON.stringify(value) })] }, key)) }) : _jsx("p", { className: "pd-lead", children: "No metadata" })] })] })] })] }) });
129
+ }
130
+ function detailsPage(ctx, account, opts = {}) {
31
131
  const named = account?.business_profile?.name;
32
132
  const value = opts.value ?? (typeof named === 'string' ? named : '');
33
- return flowPage({
34
- title: 'Public details – Stripe',
35
- css: [DETAILS_CSS],
133
+ return dashboardPage(ctx, {
134
+ title: 'Public details', section: 'Settings / Business', path: PUBLIC_DETAILS_PATH,
36
135
  ...(opts.error ? { status: 400 } : {}),
37
- body: (_jsxs("main", { className: "pd", children: [_jsx("h1", { children: "Public details" }), _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" }) })] })] })),
136
+ 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
137
  });
39
138
  }
40
139
  /** Save the business name on the platform's own account: the stored record, made from Stripe's default the first
@@ -55,22 +154,23 @@ async function publicDetails(ctx) {
55
154
  return notFound();
56
155
  const form = request.method === 'POST' ? await formOf(request.clone()) : {};
57
156
  if (request.method === 'GET')
58
- return detailsPage(ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
157
+ return detailsPage(ctx, ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
59
158
  const name = (form.business_name ?? '').trim();
60
159
  const refused = nameRefused(name);
61
160
  if (refused)
62
- return detailsPage(ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
161
+ return detailsPage(ctx, ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
63
162
  await save(ctx, name);
64
163
  // a query-only reference: the page's own path, wherever it is mounted and with or without its trailing slash
65
164
  return new Response(null, { status: 303, headers: { location: '?saved=1' } });
66
165
  }
67
- /** Dashboard settings, Radar list creation and test Issuing funding; other pages answer Stripe's 404. */
166
+ /** Dashboard customer inspection, settings, Radar lists and test Issuing funding. */
68
167
  export async function screen(ctx) {
69
168
  const path = new URL(ctx.call.request.url).pathname.replace(/\/+$/, '');
70
- // The twin's declared workspace entry opens its implemented Public details page; this authored choice does not
71
- // reproduce Stripe's Dashboard home. The request's public base retains the World mount when the console frames it.
72
- if (path === '' && ['GET', 'HEAD'].includes(ctx.call.request.method))
169
+ // The declared workspace opens a served setting, not a fabricated analytics home.
170
+ if (!path && ctx.call.request.method === 'GET')
73
171
  return redirect(`${ctx.publicBase}${PUBLIC_DETAILS_PATH}`);
172
+ if (path === '/customers' || /^\/customers\/[^/]+$/.test(path))
173
+ return customersPage(ctx, path);
74
174
  // source: https://docs.stripe.com/radar/lists "Use the Dashboard or the API to create lists."
75
175
  // source: https://docs.stripe.com/radar/lists "Enter a name for the list"
76
176
  // source: https://docs.stripe.com/radar/lists "Select the type of list to create."
@@ -93,9 +193,14 @@ export async function screen(ctx) {
93
193
  list_items: { object: 'list', data: [], has_more: false, url: '/v1/radar/value_list_items' },
94
194
  });
95
195
  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(`/radar/lists/${list.id}`);
196
+ return redirect(`${ctx.publicBase}/radar/lists/${list.id}`);
97
197
  }
98
- return flowPage({ css: [], title: 'Lists – Radar – Stripe', body: _jsxs("main", { children: [_jsx("h1", { children: "Radar Lists" }), _jsxs("form", { method: "post", action: "/radar/lists", children: [_jsx("h2", { children: "New list" }), _jsxs("label", { children: ["Name", _jsx("input", { name: "name", required: true })] }), _jsxs("label", { children: ["Alias", _jsx("input", { name: "alias" })] }), _jsxs("label", { children: ["List type", _jsxs("select", { name: "item_type", children: [_jsx("option", { value: "customer_id", children: "Customer ID" }), _jsx("option", { value: "email", children: "Email" }), _jsx("option", { value: "card_fingerprint", children: "Card fingerprint" }), _jsx("option", { value: "ip_address", children: "IP address" }), _jsx("option", { value: "string", children: "String" })] })] }), _jsx("button", { children: "Add" })] }), _jsx("ul", { children: ctx.rows(VL).map(row => _jsxs("li", { children: [String(row.name), " (", String(row.alias), ")"] }, String(row.id))) })] }) });
198
+ const lists = ctx.rows(VL);
199
+ 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']];
200
+ const selected = lists.find(row => path === `/radar/lists/${row.id}`);
201
+ return dashboardPage(ctx, { title: selected ? String(selected.name) : 'Lists', section: 'Payments / Radar', path: '/radar/lists',
202
+ action: _jsx("button", { className: "sd-primary", type: "button", "data-open-dialog": "radar-new-list", children: "New list" }),
203
+ 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
204
  }
100
205
  // 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
206
  // source: https://docs.stripe.com/issuing/testing "You can create test top-ups in the Dashboard, or with the Top-ups API"
@@ -112,9 +217,9 @@ export async function screen(ctx) {
112
217
  if (!Number.isInteger(amount) || amount <= 0)
113
218
  return invalid('Enter a positive amount in cents.');
114
219
  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('/test/issuing/balance');
220
+ return redirect(`${ctx.publicBase}/test/issuing/balance?funded=1`);
116
221
  }
117
- return flowPage({ css: [], title: 'Test Issuing balance – Stripe', body: _jsxs("main", { children: [_jsx("h1", { children: "Test Issuing balance" }), _jsxs("form", { method: "post", children: [_jsxs("label", { children: ["Amount", _jsx("input", { name: "amount", type: "number", min: "1", required: true })] }), _jsx("button", { children: "Add test funds" })] })] }) });
222
+ 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
223
  }
119
224
  return path === PUBLIC_DETAILS_PATH ? publicDetails(ctx) : notFound();
120
225
  }
@@ -32,7 +32,7 @@
32
32
  },
33
33
  {
34
34
  "host": "dashboard.stripe.com",
35
- "pathPattern": "^/radar/lists(?:/[^/]+)?/?$|^/test/issuing/balance/?$|^/settings/public/?$"
35
+ "pathPattern": "^/customers(?:/[^/]+)?/?$|^/radar/lists(?:/[^/]+)?/?$|^/test/issuing/balance/?$|^/settings/public/?$"
36
36
  },
37
37
  {
38
38
  "host": "verify.stripe.com",
@@ -646,6 +646,51 @@
646
646
  ],
647
647
  "evidence": "Dub: apps/web/app/api/workspaces/[idOrSlug]/billing/payment-methods/route.ts:66"
648
648
  },
649
+ {
650
+ "name": "Femke finds the SDK-created customer in the Dashboard",
651
+ "method": "GET",
652
+ "path": "https://dashboard.stripe.com/customers?q=billing%40ravel.test",
653
+ "status": 200,
654
+ "expect": [
655
+ {
656
+ "path": "",
657
+ "contains": "billing@ravel.test"
658
+ },
659
+ {
660
+ "path": "",
661
+ "contains": "/customers/{ravel}"
662
+ }
663
+ ],
664
+ "evidence": "World first-use customer inspection; https://support.stripe.com/questions/export-customer-data-without-the-payment-details"
665
+ },
666
+ {
667
+ "name": "Femke opens Ravel's stored customer profile",
668
+ "method": "GET",
669
+ "path": "https://dashboard.stripe.com/customers/{ravel}",
670
+ "status": 200,
671
+ "expect": [
672
+ {
673
+ "path": "",
674
+ "contains": "billing@ravel.test"
675
+ },
676
+ {
677
+ "path": "",
678
+ "contains": "Subscriptions"
679
+ },
680
+ {
681
+ "path": "",
682
+ "contains": "Metadata"
683
+ }
684
+ ],
685
+ "evidence": "World first-use customer inspection; https://support.stripe.com/questions/updates-to-the-customer-detail-page"
686
+ },
687
+ {
688
+ "name": "A missing customer profile stays missing",
689
+ "method": "GET",
690
+ "path": "https://dashboard.stripe.com/customers/cus_missing",
691
+ "status": 404,
692
+ "evidence": "The Dashboard reads an existing customer only"
693
+ },
649
694
  {
650
695
  "name": "Twenty\u2019s app health check reads its Stripe account with the user-supplied key",
651
696
  "method": "GET",
@@ -2183,7 +2228,11 @@
2183
2228
  "expect": [
2184
2229
  {
2185
2230
  "path": "",
2186
- "contains": "Radar Lists"
2231
+ "contains": "Payments / Radar"
2232
+ },
2233
+ {
2234
+ "path": "",
2235
+ "contains": "New list"
2187
2236
  }
2188
2237
  ],
2189
2238
  "evidence": "Stripe: https://docs.stripe.com/radar/lists"
@@ -2690,7 +2739,11 @@
2690
2739
  "expect": [
2691
2740
  {
2692
2741
  "path": "",
2693
- "contains": "Test Issuing balance"
2742
+ "contains": "Issuing balance"
2743
+ },
2744
+ {
2745
+ "path": "",
2746
+ "contains": "Add test funds before creating test Issuing transactions."
2694
2747
  }
2695
2748
  ],
2696
2749
  "evidence": "Stripe: https://docs.stripe.com/issuing/testing"
@@ -55,6 +55,9 @@ Elapsed: 120m.
55
55
  - and every customer — GET /v1/customers?limit=100. Volter Harness: container/retake/stack/app/src/app.py:116
56
56
  - The sample shop's seeder looks its first customer up by email before seeding — GET /v1/customers?email=billing%40ravel.test. Twin: apps/platform/src/sample.ts:40
57
57
  - Dub reads Ravel’s billing customer — GET /v1/customers/{ravel}. Dub: apps/web/app/api/workspaces/[idOrSlug]/billing/payment-methods/route.ts:66
58
+ - Femke opens Customers to inspect the SDK-created billing customer — GET https://dashboard.stripe.com/customers?q=billing%40ravel.test. World first-use customer inspection; Stripe: https://support.stripe.com/questions/export-customer-data-without-the-payment-details
59
+ - Femke opens Ravel's profile and sees the stored subscriptions and billing details — GET https://dashboard.stripe.com/customers/{ravel}. World first-use customer inspection; Stripe: https://support.stripe.com/questions/updates-to-the-customer-detail-page
60
+ - A missing customer profile is not an invented customer — GET https://dashboard.stripe.com/customers/cus_missing. The profile reads the existing customer view only.
58
61
  - Twenty’s app health check reads its Stripe account with the user-supplied key — GET /v1/account. Twenty: packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx:850 (GET, Authorization only)
59
62
  - Twenty’s invoice workflow creates the quote invoice for Ravel — POST /v1/invoices. Twenty: packages/twenty-docs/user-guide/workflows/how-tos/connect-to-other-tools/generate-quote-or-invoice-from-twenty.mdx:101; packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts:28
60
63
  - The invoice workflow reads the saved draft — GET /v1/invoices/{workflow_invoice}. Postiz: libraries/nestjs-libraries/src/services/stripe.service.ts; Stripe: pinned API and Stripe.js Checkout contract
@@ -1,5 +1,5 @@
1
1
  {
2
- "about": "Stripe operations served for the application calls at the cited lines in demand.json and for the resource refresh reads declared in src/manifest.ts. All other operations answer the gap. Each decision names its caller or refresh and its implementation.",
2
+ "about": "Stripe operations served for the application calls at the cited lines in demand.json and for the resource refresh reads declared in src/manifest.ts. The Dashboard customer pages read those same resource views, as declared in src/manifest.ts and customer-life.outline.md. All other operations answer the gap. Each decision names its caller or refresh and its implementation.",
3
3
  "decisions": [
4
4
  {
5
5
  "operation": "DeleteAccountsAccount",
@@ -1,5 +1,5 @@
1
1
  {
2
- "measured": "Applications are read at the scanned commits below. Demand includes SDK methods, delegated Stripe.js calls, raw URLs and API-path strings, and shipped guides that use a shipped feature. Credentials authorize a feature; they do not opt into it. Postiz ships Stripe as its web billing provider. Twenty workflow HTTP actions and enterprise website billing are independent of the server billing flag (which defaults false). Cal payment/OAuth needs an app-store install; its metadata installed flag makes the catalog entry available, while the user install requires a credential created by the install callback. All products in generated/DEMAND.md’s Volter column were searched, including their own World scenarios and seeds; none calls a removed operation. The launch does not name Stripe. Separately invoked Rallly billing CLI and Dub maintenance scripts are outside the running products. Dub’s raw GET /v1/mandates, POST /v1/fx_quotes and v2 calls remain outside the pinned v1 document and answer the vendor gap; their demand is retained, not classified optional.",
2
+ "measured": "Applications are read at the scanned commits below. Demand includes SDK methods, delegated Stripe.js calls, raw URLs and API-path strings, and shipped guides that use a shipped feature. Credentials authorize a feature; they do not opt into it. Postiz ships Stripe as its web billing provider. Twenty workflow HTTP actions and enterprise website billing are independent of the server billing flag (which defaults false). Cal payment/OAuth needs an app-store install; its metadata installed flag makes the catalog entry available, while the user install requires a credential created by the install callback. All products in generated/DEMAND.md\u2019s Volter column were searched, including their own World scenarios and seeds; none calls a removed operation. The launch does not name Stripe. Separately invoked Rallly billing CLI and Dub maintenance scripts are outside the running products. Dub\u2019s raw GET /v1/mandates, POST /v1/fx_quotes and v2 calls remain outside the pinned v1 document and answer the vendor gap; their demand is retained, not classified optional.",
3
3
  "applications": [
4
4
  {
5
5
  "name": "Open Autonomy",
@@ -65,7 +65,7 @@
65
65
  "client": "fetch (the platform's sample World seeder)",
66
66
  "backend": [
67
67
  {
68
- "call": "call('stripe', '/v1/customers?email=…')",
68
+ "call": "call('stripe', '/v1/customers?email=\u2026')",
69
69
  "operation": "GetCustomers",
70
70
  "evidence": "apps/platform/src/sample.ts:40"
71
71
  },
@@ -687,6 +687,18 @@
687
687
  }
688
688
  ],
689
689
  "gate": "The shipped website enterprise checkout directly uses Stripe independently of the server IS_BILLING_ENABLED=false switch (packages/twenty-website/src/app/api/enterprise/checkout/route.ts:7-19,51)."
690
+ },
691
+ {
692
+ "name": "World first-use",
693
+ "commit": "7638d09b23a3bb36067a607e0fd19898b0a5b624",
694
+ "client": "Real Stripe SDK customer workflow and normal browser inspection",
695
+ "frontend": [
696
+ {
697
+ "call": "Customers sidebar \u2192 SDK-created customer profile",
698
+ "operation": "GET https://dashboard.stripe.com/customers; GET https://dashboard.stripe.com/customers/{customer}",
699
+ "evidence": "twin-world/docs/contributing/first-use.md:39; twin-world/docs/getting-started.md:158"
700
+ }
701
+ ]
690
702
  }
691
703
  ],
692
704
  "optional": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-stripe",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
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
@@ -8,6 +8,15 @@
8
8
 
9
9
  The pack serves this document's version (`SERVED_VERSION` in `src/engine/version.ts`).
10
10
 
11
+ Customer Dashboard references, read 2026-10-09: [Export customer data](https://support.stripe.com/questions/export-customer-data-without-the-payment-details)
12
+ establishes the Customers section at `/customers`. [Updates to the Customer details page](https://support.stripe.com/questions/updates-to-the-customer-detail-page)
13
+ establishes dynamic payments, invoices and subscriptions in the primary column, with details and metadata on the right.
14
+ [Manage customer information](https://support.stripe.com/questions/manage-a-customer-s-information-and-payments)
15
+ names the customer profile and payment-method modules; its older left-details layout is superseded by the update article.
16
+ The pages publish no version date. The authored list and read-only detail use stored API views, UTC dates and an explicitly
17
+ limited name/email/id/description search. Dashboard create/edit actions, exports and guest-customer grouping are outside this scope.
18
+ Only public documentation was read; no vendor sign-in, upstream DOM, script or image is shipped.
19
+
11
20
  - **Published examples:** `fixtures3.json.gz`, gzip of Stripe's `openapi/fixtures3.json` (one sample object per resource,
12
21
  the objects Stripe's API reference shows), from commit `0d98342fc985dc7710a44be25e8528dd8e099bc2` (2026-09-23).
13
22
  SHA-256 of the uncompressed file: `e6822f7ee6fd71bdde6228537368aafcfb7191109340fe119d9aca27c6e9396d`.
@@ -16,3 +25,7 @@ The pack serves this document's version (`SERVED_VERSION` in `src/engine/version
16
25
  publish (its testing page's cards, the payment lifecycle's states, each reference page's stated rule), each with its
17
26
  page's URL and the page's words for what it does, read with twin-standard's `read-page`. **Read by:**
18
27
  `bun scripts/vendor-examples.ts stripe`.
28
+
29
+ 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.
30
+
31
+ 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/spec/sources.json CHANGED
@@ -302,5 +302,13 @@
302
302
  "quotes": {
303
303
  "Unrecognized request URL (GET: /v1/test).": true
304
304
  }
305
+ },
306
+ "https://support.stripe.com/questions/export-customer-data-without-the-payment-details": {
307
+ "status": 200,
308
+ "checked": "2026-10-09"
309
+ },
310
+ "https://support.stripe.com/questions/updates-to-the-customer-detail-page": {
311
+ "status": 200,
312
+ "checked": "2026-10-09"
305
313
  }
306
314
  }
@@ -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: 'visa', last4: number ? number.slice(-4) : '4242',
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: 'credit', country: 'US',
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: ['visa'], preferred: null },
754
+ networks: { available: [profile.brand], preferred: null },
732
755
  three_d_secure_usage: { supported: true }, wallet: null,
733
756
  } };
734
757
  }
package/src/manifest.ts CHANGED
@@ -365,7 +365,7 @@ export const manifest: DerivedManifest = {
365
365
  hosts: [
366
366
  { host: 'api.stripe.com' }, { host: 'checkout.stripe.com', pathPattern: '^/c/pay/' }, { host: 'billing.stripe.com', pathPattern: '^/p/session/' },
367
367
  { host: 'connect.stripe.com', pathPattern: '^/setup/' },
368
- { host: 'dashboard.stripe.com', pathPattern: '^/radar/lists(?:/[^/]+)?/?$|^/test/issuing/balance/?$|^/settings/public/?$' },
368
+ { host: 'dashboard.stripe.com', pathPattern: '^/customers(?:/[^/]+)?/?$|^/radar/lists(?:/[^/]+)?/?$|^/test/issuing/balance/?$|^/settings/public/?$' },
369
369
  { host: 'verify.stripe.com', pathPattern: '^/start/' },
370
370
  { host: 'js.stripe.com', pathPattern: '^/v3/?$|^/(v3|[a-z]+)/stripe\\.js$' },
371
371
  ],
@@ -411,8 +411,8 @@ export const manifest: DerivedManifest = {
411
411
  },
412
412
  {
413
413
  id: 'dashboard', kind: 'workspace', host: 'dashboard.stripe.com', path: '/', status: 'done',
414
- demand: 'Operator setup for the cited application calls: Public details; Radar lists for Dub add-to-stripe-fraud-value-lists.ts:3-7; sandbox funding for Open Autonomy world/model/scenario.ts:60,62',
415
- controls: ['Business name', 'Save', 'New list', 'Name', 'Alias', 'List type', 'Add', 'Add test funds', 'Amount'], source: 'https://docs.stripe.com/dashboard/basics',
414
+ demand: 'World first-use customer inspection (twin-world/docs/contributing/first-use.md); operator setup: Public details; Radar lists for Dub add-to-stripe-fraud-value-lists.ts:3-7; sandbox funding for Open Autonomy world/model/scenario.ts:60,62',
415
+ controls: ['Customers', 'Search customers', 'Search', 'Business name', 'Save', 'New list', 'Name', 'Alias', 'List type', 'Add', 'Add test funds', 'Amount'], source: 'https://docs.stripe.com/dashboard/basics',
416
416
  },
417
417
  ],
418
418
  resources: {
@@ -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: `/p/session/${id}/renew`, fields: { subscription: String(s.id) }, tone: 'primary' as const }]
85
- : features.cancel.enabled ? [{ label: 'Cancel plan', action: `/p/session/${id}/cancel`, fields: { subscription: String(s.id) }, tone: 'danger' as const }] : [],
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: `/p/session/${id}/pay`, fields: { invoice: String(i.id) }, tone: 'primary' as const }] } : {}),
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: `/p/session/${id}/payment_method`, fields: cardFields, submit: { label: pm ? 'Update' : 'Add' } }] } : {})}
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: `/p/session/${id}` } });
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: `/p/session/${id}` } });
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: `/p/session/${id}` } });
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. */