@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.
- package/README.md +28 -31
- package/dist/journeys/customer-life.json +55 -2
- package/dist/journeys/customer-life.outline.md +3 -0
- package/dist/journeys/decisions.json +1 -1
- package/dist/journeys/demand.json +14 -2
- package/dist/spec/SOURCE.md +13 -0
- package/dist/spec/sources.json +8 -0
- package/dist/src/engine/stripe.js +25 -3
- package/dist/src/manifest.js +3 -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.d.ts +1 -1
- package/dist/src/screens/dashboard.js +125 -20
- package/generated/pack-facts.json +1 -1
- package/journeys/customer-life.json +55 -2
- package/journeys/customer-life.outline.md +3 -0
- package/journeys/decisions.json +1 -1
- package/journeys/demand.json +14 -2
- package/package.json +1 -1
- package/spec/SOURCE.md +13 -0
- package/spec/sources.json +8 -0
- package/src/engine/stripe.ts +26 -3
- package/src/manifest.ts +3 -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 +153 -44
|
@@ -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: #
|
|
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,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
|
-
|
|
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
|
|
34
|
-
title: 'Public details
|
|
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("
|
|
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
|
|
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
|
|
71
|
-
|
|
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(
|
|
196
|
+
return redirect(`${ctx.publicBase}/radar/lists/${list.id}`);
|
|
97
197
|
}
|
|
98
|
-
|
|
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(
|
|
220
|
+
return redirect(`${ctx.publicBase}/test/issuing/balance?funded=1`);
|
|
116
221
|
}
|
|
117
|
-
return
|
|
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
|
|
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": "
|
|
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
|
package/journeys/decisions.json
CHANGED
|
@@ -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",
|
package/journeys/demand.json
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
}
|
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
|
}
|
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: '
|
|
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:
|
|
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. */
|