@volter/twin-stripe 3.0.3 → 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 +2 -2
- package/dist/journeys/customer-life.json +45 -0
- 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 +9 -0
- package/dist/spec/sources.json +8 -0
- package/dist/src/manifest.js +3 -3
- package/dist/src/screens/dashboard.d.ts +1 -1
- package/dist/src/screens/dashboard.js +61 -3
- package/generated/pack-facts.json +1 -1
- package/journeys/customer-life.json +45 -0
- 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 +9 -0
- package/spec/sources.json +8 -0
- package/src/manifest.ts +3 -3
- package/src/screens/dashboard.tsx +71 -5
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Use Node 22.6 or newer.
|
|
|
10
10
|
In an app that already uses this vendor, install this exact release and the product CLI:
|
|
11
11
|
|
|
12
12
|
```console
|
|
13
|
-
npm install --save-dev --save-exact @volter/world@3.0.147 @volter/twin-stripe@3.0.
|
|
13
|
+
npm install --save-dev --save-exact @volter/world@3.0.147 @volter/twin-stripe@3.0.4
|
|
14
14
|
./node_modules/.bin/volter world init --name my-app --twins stripe --source stripe=@volter/twin-stripe
|
|
15
15
|
```
|
|
16
16
|
|
|
@@ -116,7 +116,7 @@ Authored pages and content at the declared vendor URLs (`src/screens/`):
|
|
|
116
116
|
- **Connect onboarding** (`connect.stripe.com/setup/…`): the Account Link's business, identity, payout and agreement form.
|
|
117
117
|
- **Stripe.js** (`js.stripe.com/v3/`, `/v3/stripe.js`, `/<release train>/stripe.js`): `redirectToCheckout({ sessionId })` opens the hosted session. Unsupported members refuse.
|
|
118
118
|
- **Checkout client wire** (`api.stripe.com/v1/payment_pages/{session}`): the authored session operations used by Stripe.js CheckoutProvider.
|
|
119
|
-
- **Dashboard** (`dashboard.stripe.com/settings/public`, `/radar/lists`, `/test/issuing/balance`): public business name, Radar
|
|
119
|
+
- **Dashboard** (`dashboard.stripe.com/customers`, `/customers/{customer}`, `/settings/public`, `/radar/lists`, `/test/issuing/balance`): inspect SDK-created customers and their stored subscriptions, payments, payment methods, invoices and metadata; set the public business name, create Radar lists and fund test Issuing. The workspace opens Public details; choose Customers in its sidebar to inspect a record. Customer pages are read-only. Search matches name, email, id or description; dates are UTC. Dashboard customer creation/editing, exports, guest-customer grouping and global search are unavailable.
|
|
120
120
|
|
|
121
121
|
Page actions use the same stored resources as the API. Public screen references and authored choices are in [spec/SOURCE.md](./spec/SOURCE.md).
|
|
122
122
|
|
|
@@ -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",
|
|
@@ -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
|
|
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/dist/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/dist/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/dist/src/manifest.js
CHANGED
|
@@ -359,7 +359,7 @@ export const manifest = {
|
|
|
359
359
|
hosts: [
|
|
360
360
|
{ host: 'api.stripe.com' }, { host: 'checkout.stripe.com', pathPattern: '^/c/pay/' }, { host: 'billing.stripe.com', pathPattern: '^/p/session/' },
|
|
361
361
|
{ host: 'connect.stripe.com', pathPattern: '^/setup/' },
|
|
362
|
-
{ host: 'dashboard.stripe.com', pathPattern: '^/radar/lists(?:/[^/]+)?/?$|^/test/issuing/balance/?$|^/settings/public/?$' },
|
|
362
|
+
{ host: 'dashboard.stripe.com', pathPattern: '^/customers(?:/[^/]+)?/?$|^/radar/lists(?:/[^/]+)?/?$|^/test/issuing/balance/?$|^/settings/public/?$' },
|
|
363
363
|
{ host: 'verify.stripe.com', pathPattern: '^/start/' },
|
|
364
364
|
{ host: 'js.stripe.com', pathPattern: '^/v3/?$|^/(v3|[a-z]+)/stripe\\.js$' },
|
|
365
365
|
],
|
|
@@ -405,8 +405,8 @@ export const manifest = {
|
|
|
405
405
|
},
|
|
406
406
|
{
|
|
407
407
|
id: 'dashboard', kind: 'workspace', host: 'dashboard.stripe.com', path: '/', status: 'done',
|
|
408
|
-
demand: '
|
|
409
|
-
controls: ['Business name', 'Save', 'New list', 'Name', 'Alias', 'List type', 'Add', 'Add test funds', 'Amount'], source: 'https://docs.stripe.com/dashboard/basics',
|
|
408
|
+
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',
|
|
409
|
+
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',
|
|
410
410
|
},
|
|
411
411
|
],
|
|
412
412
|
resources: {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { HandlerContext } from '@volter/world-core';
|
|
2
|
-
/** Dashboard settings, Radar
|
|
2
|
+
/** Dashboard customer inspection, settings, Radar lists and test Issuing funding. */
|
|
3
3
|
export declare function screen(ctx: HandlerContext): Promise<Response>;
|
|
@@ -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 =>
|
|
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
|
|
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",
|
|
@@ -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`.
|
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/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: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// THE DASHBOARD — dashboard.stripe.com's pages an operator sets what Stripe's API cannot (docs/contributing/architecture.md,
|
|
2
|
-
// "Screens": a workspace): Public details
|
|
3
|
-
//
|
|
2
|
+
// "Screens": a workspace): Customers, Public details, Radar lists and test Issuing funding.
|
|
3
|
+
// Customer inspection reads the same stored resources as the SDK. Other pages answer Stripe's 404.
|
|
4
4
|
//
|
|
5
5
|
// THE DASHBOARD'S PUBLIC DETAILS — a workspace page (docs/contributing/architecture.md, "Screens") of the Dashboard
|
|
6
6
|
// (manifest screen `dashboard`), at dashboard.stripe.com/settings/public: Settings → Business → Public details, where
|
|
@@ -30,7 +30,7 @@ import type { Row } from '../engine/common.ts';
|
|
|
30
30
|
import { platformAccountDefault } from '../engine/connect.ts';
|
|
31
31
|
import { publicBusinessName } from '../engine/display.ts';
|
|
32
32
|
import { PLATFORM_ACCOUNT_ID } from '../engine/stripe.ts';
|
|
33
|
-
import { formOf, notFound } from './shared.tsx';
|
|
33
|
+
import { formOf, money, notFound } from './shared.tsx';
|
|
34
34
|
import { created } from '../semantics/shared.ts';
|
|
35
35
|
const redirect = (path: string): Response => new Response(null, { status: 303, headers: { location: path } });
|
|
36
36
|
const invalid = (message: string): Response => flowPage({ css: [], title: 'Stripe', status: 400, body: <main><p role="alert">{message}</p></main> });
|
|
@@ -82,6 +82,19 @@ a { color: #635bff; text-decoration: none; }
|
|
|
82
82
|
.sd-table th { font-size: 12px; font-weight: 600; color: #697386; background: #f6f8fa; }
|
|
83
83
|
.sd-empty { color: #697386; padding: 40px; text-align: center; }
|
|
84
84
|
.sd-code { font-family: ui-monospace,SFMono-Regular,monospace; font-size: 12px; }
|
|
85
|
+
.sd-customer-grid { display: grid; grid-template-columns: minmax(0,1fr) 300px; gap: 32px; align-items: start; }
|
|
86
|
+
.sd-customer-grid > * { min-width: 0; }
|
|
87
|
+
.sd-module { border-bottom: 1px solid #e3e8ee; padding: 0 0 24px; margin-bottom: 24px; overflow-x: auto; }
|
|
88
|
+
.sd-module h2 { font-size: 18px; margin: 0 0 16px; }
|
|
89
|
+
.sd-customer-details { border-left: 1px solid #e3e8ee; padding-left: 24px; overflow-wrap: anywhere; }
|
|
90
|
+
.sd-customer-details dl { margin: 0; }
|
|
91
|
+
.sd-customer-details dt { color: #697386; font-size: 12px; margin-top: 16px; }
|
|
92
|
+
.sd-customer-details dd { margin: 3px 0 0; }
|
|
93
|
+
.sd-customer-filter { display: flex; gap: 8px; margin-bottom: 20px; }
|
|
94
|
+
.sd-customer-filter input { min-width: 0; width: 320px; border: 1px solid #c1c9d2; border-radius: 6px; padding: 8px 10px; }
|
|
95
|
+
.sd-customer-tabs { display: flex; border-bottom: 1px solid #e3e8ee; margin-bottom: 28px; }
|
|
96
|
+
.sd-customer-tabs span { border-bottom: 2px solid #635bff; padding: 0 0 12px; color: #635bff; font-weight: 600; }
|
|
97
|
+
@media(max-width:1000px) { .sd-customer-grid { grid-template-columns: minmax(0,1fr); } .sd-customer-details { border-left: 0; padding: 0; } }
|
|
85
98
|
.sd-dialog { width: min(480px,calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow: auto; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; color: #1a1f36; }
|
|
86
99
|
.sd-dialog::backdrop { background: rgba(26,31,54,.4); }
|
|
87
100
|
.sd-dialog h2 { font-size: 20px; margin: 0 0 20px; }
|
|
@@ -100,7 +113,9 @@ function dashboardPage(ctx: HandlerContext, opts: DashboardPage): Response {
|
|
|
100
113
|
const links = [['Public details', PUBLIC_DETAILS_PATH], ['Radar lists', '/radar/lists'], ['Issuing balance', '/test/issuing/balance']];
|
|
101
114
|
return flowPage({ title: `${opts.title} – Stripe`, css: [DETAILS_CSS], status: opts.status ?? 200, body: <div className="sd">
|
|
102
115
|
<aside className="sd-side"><div className="sd-account">{publicBusinessName(account)}<span className="sd-mode">Test mode</span></div><nav className="sd-nav" aria-label="Dashboard">
|
|
103
|
-
{['Home', 'Balances', 'Transactions', 'Customers', 'Product catalog'].map(label =>
|
|
116
|
+
{['Home', 'Balances', 'Transactions', 'Customers', 'Product catalog'].map(label => label === 'Customers'
|
|
117
|
+
? <a key={label} href={`${ctx.publicBase}/customers`} aria-current={opts.path === '/customers' ? 'page' : undefined}>Customers</a>
|
|
118
|
+
: <button key={label} type="button" disabled title="Not available in this twin's Dashboard">{label}</button>)}
|
|
104
119
|
<p className="sd-nav-title">Shortcuts</p>{links.map(([label,path]) => <a key={path} href={`${ctx.publicBase}${path}`} aria-current={opts.path === path ? 'page' : undefined}>{label}</a>)}
|
|
105
120
|
<p className="sd-nav-title">Products</p>{['Payments', 'Billing', 'Reporting', 'Connect', 'More'].map(label => <button key={label} type="button" disabled title="Not available in this twin's Dashboard">{label}</button>)}
|
|
106
121
|
</nav></aside><div className="sd-body"><header className="sd-top"><input className="sd-search" aria-label="Search Dashboard" placeholder="Search" disabled title="Dashboard search is not available in this twin"/><button type="button" disabled title="Not available in this twin's Dashboard">Create</button><button type="button" disabled>Help</button><a href={`${ctx.publicBase}${PUBLIC_DETAILS_PATH}`}>Settings</a></header>
|
|
@@ -109,6 +124,56 @@ function dashboardPage(ctx: HandlerContext, opts: DashboardPage): Response {
|
|
|
109
124
|
</div> });
|
|
110
125
|
}
|
|
111
126
|
|
|
127
|
+
const customerName = (row: Row): string => String(row.name || row.email || row.id);
|
|
128
|
+
const resourceId = (value: unknown): string => typeof value === 'string' ? value : value && typeof value === 'object' ? String((value as Row).id ?? '') : '';
|
|
129
|
+
const newest = (rows: Row[]): Row[] => rows.slice().reverse().sort((a,b) => Number(b.created ?? 0) - Number(a.created ?? 0));
|
|
130
|
+
const customerDate = (value: unknown): string => typeof value === 'number' && Number.isFinite(value)
|
|
131
|
+
? new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(value * 1000)) : '—';
|
|
132
|
+
|
|
133
|
+
// source: https://support.stripe.com/questions/export-customer-data-without-the-payment-details
|
|
134
|
+
// source: https://support.stripe.com/questions/updates-to-the-customer-detail-page
|
|
135
|
+
// These public support articles render their body in JavaScript; the source reader gets their shell.
|
|
136
|
+
// Their public rendered body supplies Customers and the dynamic-left/static-right layout (spec/SOURCE.md).
|
|
137
|
+
// Where public references stop: q is an authored name/email/id/description substring filter, not Stripe's
|
|
138
|
+
// private Dashboard search wire. Dates are shown in UTC; browser actions here are read-only. The declared
|
|
139
|
+
// page addresses preserve the customer's returned API id. There is no invented payment history or total.
|
|
140
|
+
function customersPage(ctx: HandlerContext, path: string): Response {
|
|
141
|
+
if (ctx.call.request.method !== 'GET') return notFound();
|
|
142
|
+
if (path === '/customers') {
|
|
143
|
+
const query = new URL(ctx.call.request.url).searchParams.get('q') ?? '';
|
|
144
|
+
const needle = query.trim().toLowerCase();
|
|
145
|
+
const customers = newest(ctx.rows('customer')).filter(row => !needle ||
|
|
146
|
+
[row.name, row.email, row.id, row.description].some(value => typeof value === 'string' && value.toLowerCase().includes(needle)));
|
|
147
|
+
return dashboardPage(ctx, { title: 'Customers', section: 'Customers', path: '/customers',
|
|
148
|
+
body: <><form className="sd-customer-filter" method="get" action={`${ctx.publicBase}/customers`}>
|
|
149
|
+
<input name="q" aria-label="Search customers" placeholder="Search customers" defaultValue={query}/><button className="sd-cancel" type="submit">Search</button>
|
|
150
|
+
{query ? <a href={`${ctx.publicBase}/customers`}>Clear</a> : null}
|
|
151
|
+
</form><table className="sd-table"><thead><tr><th scope="col">Name</th><th scope="col">Email</th><th scope="col">Description</th><th scope="col">Created (UTC)</th></tr></thead>
|
|
152
|
+
<tbody>{customers.map(row => <tr key={String(row.id)}><td><a href={`${ctx.publicBase}/customers/${encodeURIComponent(String(row.id))}`}>{customerName(row)}</a></td><td>{String(row.email ?? '—')}</td><td>{String(row.description ?? '—')}</td><td>{customerDate(row.created)}</td></tr>)}</tbody></table>
|
|
153
|
+
{!customers.length ? <p className="sd-empty">{needle ? 'No customers match your search.' : 'No customers yet.'}</p> : null}</> });
|
|
154
|
+
}
|
|
155
|
+
let id: string;
|
|
156
|
+
try { id = decodeURIComponent(path.slice('/customers/'.length)); } catch { return notFound(); }
|
|
157
|
+
const customer = ctx.get('customer', id);
|
|
158
|
+
if (!customer || customer.deleted === true) return notFound();
|
|
159
|
+
const related = (type: string): Row[] => newest(ctx.rows(type)).filter(row => resourceId(row.customer) === id);
|
|
160
|
+
const payments = related('charge'), subscriptions = related('subscription'), invoices = related('invoice'), methods = related('payment_method');
|
|
161
|
+
const metadata = customer.metadata && typeof customer.metadata === 'object' ? Object.entries(customer.metadata as Row) : [];
|
|
162
|
+
const address = customer.address && typeof customer.address === 'object' ? customer.address as Row : undefined;
|
|
163
|
+
const addressText = address ? ['line1','line2','city','state','postal_code','country'].map(key => address[key]).filter(Boolean).join(', ') : '—';
|
|
164
|
+
return dashboardPage(ctx, { title: customerName(customer), section: 'Customers', path: '/customers',
|
|
165
|
+
body: <><a href={`${ctx.publicBase}/customers`}>← Customers</a><p className="pd-lead sd-code">{id}</p><div className="sd-customer-tabs"><span>Overview</span></div>
|
|
166
|
+
<div className="sd-customer-grid"><div>
|
|
167
|
+
<section className="sd-module"><h2>Subscriptions</h2>{subscriptions.length ? <table className="sd-table"><thead><tr><th scope="col">Subscription</th><th scope="col">Status</th><th scope="col">Created (UTC)</th></tr></thead><tbody>{subscriptions.map(row => <tr key={String(row.id)}><td className="sd-code">{String(row.id)}</td><td>{String(row.status ?? '—')}</td><td>{customerDate(row.created)}</td></tr>)}</tbody></table> : <p className="pd-lead">No subscriptions</p>}</section>
|
|
168
|
+
<section className="sd-module"><h2>Payments</h2>{payments.length ? <table className="sd-table"><thead><tr><th scope="col">Amount</th><th scope="col">Status</th><th scope="col">Description</th><th scope="col">Created (UTC)</th></tr></thead><tbody>{payments.map(row => <tr key={String(row.id)}><td>{money(row.amount, row.currency)}</td><td>{row.refunded === true ? 'Refunded' : String(row.status ?? '—')}</td><td>{String(row.description ?? row.id)}</td><td>{customerDate(row.created)}</td></tr>)}</tbody></table> : <p className="pd-lead">No payments</p>}</section>
|
|
169
|
+
<section className="sd-module"><h2>Payment methods</h2>{methods.length ? <ul>{methods.map(row => { const card = row.card && typeof row.card === 'object' ? row.card as Row : undefined; return <li key={String(row.id)}>{card ? `${String(card.brand ?? 'Card')} •••• ${String(card.last4 ?? '')}` : String(row.type ?? 'Payment method')} <span className="sd-code">{String(row.id)}</span></li>; })}</ul> : <p className="pd-lead">No payment methods</p>}</section>
|
|
170
|
+
<section className="sd-module"><h2>Invoices</h2>{invoices.length ? <table className="sd-table"><thead><tr><th scope="col">Invoice</th><th scope="col">Amount due</th><th scope="col">Status</th></tr></thead><tbody>{invoices.map(row => <tr key={String(row.id)}><td>{String(row.number ?? row.id)}</td><td>{money(row.amount_due, row.currency)}</td><td>{String(row.status ?? '—')}</td></tr>)}</tbody></table> : <p className="pd-lead">No invoices</p>}</section>
|
|
171
|
+
</div><aside className="sd-customer-details" aria-label="Customer details">
|
|
172
|
+
<section className="sd-module"><h2>Details</h2><dl><dt>Customer ID</dt><dd className="sd-code">{id}</dd><dt>Name</dt><dd>{String(customer.name ?? '—')}</dd><dt>Email</dt><dd>{String(customer.email ?? '—')}</dd><dt>Phone</dt><dd>{String(customer.phone ?? '—')}</dd><dt>Description</dt><dd>{String(customer.description ?? '—')}</dd><dt>Billing address</dt><dd>{addressText}</dd><dt>Created (UTC)</dt><dd>{customerDate(customer.created)}</dd></dl></section>
|
|
173
|
+
<section className="sd-module"><h2>Metadata</h2>{metadata.length ? <dl>{metadata.map(([key,value]) => <div key={key}><dt>{key}</dt><dd>{typeof value === 'string' ? value : JSON.stringify(value)}</dd></div>)}</dl> : <p className="pd-lead">No metadata</p>}</section>
|
|
174
|
+
</aside></div></> });
|
|
175
|
+
}
|
|
176
|
+
|
|
112
177
|
function detailsPage(ctx: HandlerContext, account: Row | undefined, opts: { value?: string; error?: string; saved?: boolean } = {}): Response {
|
|
113
178
|
const named = (account?.business_profile as Row | undefined)?.name;
|
|
114
179
|
const value = opts.value ?? (typeof named === 'string' ? named : '');
|
|
@@ -158,11 +223,12 @@ async function publicDetails(ctx: HandlerContext): Promise<Response> {
|
|
|
158
223
|
return new Response(null, { status: 303, headers: { location: '?saved=1' } });
|
|
159
224
|
}
|
|
160
225
|
|
|
161
|
-
/** Dashboard settings, Radar
|
|
226
|
+
/** Dashboard customer inspection, settings, Radar lists and test Issuing funding. */
|
|
162
227
|
export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
163
228
|
const path = new URL(ctx.call.request.url).pathname.replace(/\/+$/, '');
|
|
164
229
|
// The declared workspace opens a served setting, not a fabricated analytics home.
|
|
165
230
|
if (!path && ctx.call.request.method === 'GET') return redirect(`${ctx.publicBase}${PUBLIC_DETAILS_PATH}`);
|
|
231
|
+
if (path === '/customers' || /^\/customers\/[^/]+$/.test(path)) return customersPage(ctx, path);
|
|
166
232
|
// source: https://docs.stripe.com/radar/lists "Use the Dashboard or the API to create lists."
|
|
167
233
|
// source: https://docs.stripe.com/radar/lists "Enter a name for the list"
|
|
168
234
|
// source: https://docs.stripe.com/radar/lists "Select the type of list to create."
|