@volter/twin-stripe 3.0.3 → 3.0.5

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.
@@ -5,7 +5,7 @@ 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 }) }) });
@@ -55,6 +55,19 @@ a { color: #635bff; text-decoration: none; }
55
55
  .sd-table th { font-size: 12px; font-weight: 600; color: #697386; background: #f6f8fa; }
56
56
  .sd-empty { color: #697386; padding: 40px; text-align: center; }
57
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; } }
58
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; }
59
72
  .sd-dialog::backdrop { background: rgba(26,31,54,.4); }
60
73
  .sd-dialog h2 { font-size: 20px; margin: 0 0 20px; }
@@ -69,7 +82,50 @@ a { color: #635bff; text-decoration: none; }
69
82
  function dashboardPage(ctx, opts) {
70
83
  const account = ctx.get('account', PLATFORM_ACCOUNT_ID);
71
84
  const links = [['Public details', PUBLIC_DETAILS_PATH], ['Radar lists', '/radar/lists'], ['Issuing balance', '/test/issuing/balance']];
72
- return flowPage({ title: `${opts.title} – Stripe`, css: [DETAILS_CSS], status: opts.status ?? 200, body: _jsxs("div", { className: "sd", children: [_jsxs("aside", { className: "sd-side", children: [_jsxs("div", { className: "sd-account", children: [publicBusinessName(account), _jsx("span", { className: "sd-mode", children: "Test mode" })] }), _jsxs("nav", { className: "sd-nav", "aria-label": "Dashboard", children: [['Home', 'Balances', 'Transactions', 'Customers', 'Product catalog'].map(label => _jsx("button", { type: "button", disabled: true, title: "Not available in this twin's Dashboard", children: label }, label)), _jsx("p", { className: "sd-nav-title", children: "Shortcuts" }), links.map(([label, path]) => _jsx("a", { href: `${ctx.publicBase}${path}`, "aria-current": opts.path === path ? 'page' : undefined, children: label }, path)), _jsx("p", { className: "sd-nav-title", children: "Products" }), ['Payments', 'Billing', 'Reporting', 'Connect', 'More'].map(label => _jsx("button", { type: "button", disabled: true, title: "Not available in this twin's Dashboard", children: label }, label))] })] }), _jsxs("div", { className: "sd-body", children: [_jsxs("header", { className: "sd-top", children: [_jsx("input", { className: "sd-search", "aria-label": "Search Dashboard", placeholder: "Search", disabled: true, title: "Dashboard search is not available in this twin" }), _jsx("button", { type: "button", disabled: true, title: "Not available in this twin's Dashboard", children: "Create" }), _jsx("button", { type: "button", disabled: true, children: "Help" }), _jsx("a", { href: `${ctx.publicBase}${PUBLIC_DETAILS_PATH}`, children: "Settings" })] }), _jsxs("main", { className: "sd-content", children: [_jsxs("nav", { className: "sd-crumbs", "aria-label": "Breadcrumb", children: [_jsx("span", { children: opts.section }), _jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("span", { children: opts.title })] }), _jsxs("div", { className: "sd-title", children: [_jsx("h1", { children: opts.title }), opts.action] }), opts.body] })] }), _jsx("script", { dangerouslySetInnerHTML: { __html: "document.querySelectorAll('[data-open-dialog]').forEach(b=>b.addEventListener('click',()=>document.getElementById(b.dataset.openDialog).showModal()));document.querySelectorAll('[data-close-dialog]').forEach(b=>b.addEventListener('click',()=>b.closest('dialog').close()));" } })] }) });
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" })] })] })] })] }) });
73
129
  }
74
130
  function detailsPage(ctx, account, opts = {}) {
75
131
  const named = account?.business_profile?.name;
@@ -107,12 +163,14 @@ async function publicDetails(ctx) {
107
163
  // a query-only reference: the page's own path, wherever it is mounted and with or without its trailing slash
108
164
  return new Response(null, { status: 303, headers: { location: '?saved=1' } });
109
165
  }
110
- /** 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. */
111
167
  export async function screen(ctx) {
112
168
  const path = new URL(ctx.call.request.url).pathname.replace(/\/+$/, '');
113
169
  // The declared workspace opens a served setting, not a fabricated analytics home.
114
170
  if (!path && ctx.call.request.method === 'GET')
115
171
  return redirect(`${ctx.publicBase}${PUBLIC_DETAILS_PATH}`);
172
+ if (path === '/customers' || /^\/customers\/[^/]+$/.test(path))
173
+ return customersPage(ctx, path);
116
174
  // source: https://docs.stripe.com/radar/lists "Use the Dashboard or the API to create lists."
117
175
  // source: https://docs.stripe.com/radar/lists "Enter a name for the list"
118
176
  // source: https://docs.stripe.com/radar/lists "Select the type of list to create."
@@ -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",
@@ -3583,6 +3628,53 @@
3583
3628
  },
3584
3629
  "evidence": "Stripe: spec:GetCheckoutSessionsSession"
3585
3630
  },
3631
+ {
3632
+ "name": "The app reads the saved lifetime payment with its secret key",
3633
+ "method": "GET",
3634
+ "path": "/v1/payment_intents/{lifetime_pi}",
3635
+ "headers": {
3636
+ "authorization": "Bearer {sk}"
3637
+ },
3638
+ "status": 200,
3639
+ "expect": [
3640
+ {
3641
+ "path": "id",
3642
+ "equals": "{lifetime_pi}"
3643
+ },
3644
+ {
3645
+ "path": "object",
3646
+ "equals": "payment_intent"
3647
+ },
3648
+ {
3649
+ "path": "status",
3650
+ "equals": "succeeded"
3651
+ },
3652
+ {
3653
+ "path": "amount",
3654
+ "equals": 14900
3655
+ },
3656
+ {
3657
+ "path": "amount_received",
3658
+ "equals": 14900
3659
+ },
3660
+ {
3661
+ "path": "currency",
3662
+ "equals": "usd"
3663
+ },
3664
+ {
3665
+ "path": "customer",
3666
+ "equals": "{ravel}"
3667
+ },
3668
+ {
3669
+ "path": "latest_charge",
3670
+ "exists": true
3671
+ }
3672
+ ],
3673
+ "capture": {
3674
+ "lifetime_charge": "latest_charge"
3675
+ },
3676
+ "evidence": "Stripe: spec:GetPaymentIntentsIntent; spec:/components/schemas/payment_intent"
3677
+ },
3586
3678
  {
3587
3679
  "name": "Femke reopens the launch promotion for an alumni webinar",
3588
3680
  "method": "POST",
@@ -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
@@ -236,6 +239,7 @@ Elapsed: 32d.
236
239
  - Ravel buys a lifetime upgrade in Checkout — POST /v1/checkout/sessions. Stripe: spec:PostCheckoutSessions
237
240
  - The subscriber enters a card — POST {lifetime_cs_url}. Stripe hosted continuation: https://docs.stripe.com/payments/checkout/how-checkout-works
238
241
  - The completed lifetime checkout names its payment — GET /v1/checkout/sessions/{lifetime_cs}. Stripe: spec:GetCheckoutSessionsSession
242
+ - The app reads the saved lifetime payment with its secret key — GET /v1/payment_intents/{lifetime_pi}. Stripe: spec:GetPaymentIntentsIntent
239
243
  - Femke reopens the launch promotion for an alumni webinar — POST /v1/promotion_codes/{promo}. Dub: apps/web/lib/discounts/discount-provider-stripe.ts:294
240
244
  - Ravel buys a third workspace with the reopened promotion — POST /v1/checkout/sessions. Stripe: spec:PostCheckoutSessions
241
245
  - The subscriber enters a card — POST {promo_cs_url}. Stripe hosted continuation: https://docs.stripe.com/payments/checkout/how-checkout-works
@@ -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, for the resource refresh reads declared in src/manifest.ts, and for the completed Checkout payment readback in customer-life.json. 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",
@@ -247,6 +247,12 @@
247
247
  "mechanism": "core",
248
248
  "why": "the derived core's list of payment_intent: a plain list the manifest's declaration serves"
249
249
  },
250
+ {
251
+ "operation": "GetPaymentIntentsIntent",
252
+ "demand": "Completed Checkout payment readback (journeys/customer-life.json)",
253
+ "mechanism": "core",
254
+ "why": "Stripe SDK paymentIntents.retrieve reads the PaymentIntent named by the completed Checkout session: id, succeeded status, collected amount, currency and latest_charge come from the existing stored payment_intent through the derived core, as spec:GetPaymentIntentsIntent retrieves a PaymentIntent previously created. This life uses the secret key; client-side publishable-key retrieval remains outside this act and the existing credential refusal is unchanged."
255
+ },
250
256
  {
251
257
  "operation": "GetPaymentMethods",
252
258
  "demand": "Dub, Postiz (journeys/demand.json)",
@@ -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.3",
3
+ "version": "3.0.5",
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",
@@ -46,12 +46,12 @@
46
46
  "world-stripe": "dist/src/cli.js"
47
47
  },
48
48
  "dependencies": {
49
- "@volter/world-ui": "^1.0.65",
49
+ "@volter/world-ui": "^1.0.155",
50
50
  "react": "^19.2.7",
51
51
  "react-dom": "^19.2.7"
52
52
  },
53
53
  "peerDependencies": {
54
- "@volter/world-core": "^3.0.68"
54
+ "@volter/world-core": "^3.0.155"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/bun": "^1.2.20",
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`.
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
  }