@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
package/src/screens/checkout.tsx
CHANGED
|
@@ -94,7 +94,7 @@ function customerEmailOf(session: Row): string | undefined {
|
|
|
94
94
|
return typeof email === 'string' && EMAIL.test(email) ? email : undefined;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function page(session: Row, id: string, now: number, values: Record<string, string> = {}, error?: string): Response {
|
|
97
|
+
function page(publicBase: string, session: Row, id: string, now: number, values: Record<string, string> = {}, error?: string): Response {
|
|
98
98
|
const lines = (((session.line_items as { data?: Row[] } | undefined)?.data) ?? []).map((item) => ({
|
|
99
99
|
name: String(item.description ?? 'Item'),
|
|
100
100
|
amount: money(item.amount_total, item.currency ?? session.currency),
|
|
@@ -128,7 +128,7 @@ function page(session: Row, id: string, now: number, values: Record<string, stri
|
|
|
128
128
|
merchant={merchant}
|
|
129
129
|
lines={lines}
|
|
130
130
|
{...(trialText ? { total: trialText.lead, note: trialText.note } : session.amount_total !== null && session.amount_total !== undefined ? { total: { label: mode === 'subscription' ? 'Subscribe' : `Pay ${merchant}`, amount: money(session.amount_total, session.currency) } } : {})}
|
|
131
|
-
action={
|
|
131
|
+
action={`${publicBase}/c/pay/${id}`}
|
|
132
132
|
sections={[{ heading: 'Contact information', fields: contact }, { heading: 'Payment method', fields: card }]}
|
|
133
133
|
submit={{ label: submit, testId: 'hosted-payment-submit-button' }}
|
|
134
134
|
{...(typeof session.cancel_url === 'string' ? { back: { href: session.cancel_url, label: '← Back' } } : {})}
|
|
@@ -145,13 +145,13 @@ async function pay(ctx: HandlerContext, id: string, session: Row, v: Record<stri
|
|
|
145
145
|
const onFile = customerEmailOf(session);
|
|
146
146
|
const email = onFile ?? (typeof session.customer_email === 'string' ? session.customer_email : (v.email ?? '').trim());
|
|
147
147
|
if (session.mode !== 'setup' || !session.customer) {
|
|
148
|
-
if (!EMAIL.test(email)) return page(session, id, nowUnix(ctx.occurredAt), v, 'Your email address is incomplete.');
|
|
148
|
+
if (!EMAIL.test(email)) return page(ctx.publicBase, session, id, nowUnix(ctx.occurredAt), v, 'Your email address is incomplete.');
|
|
149
149
|
}
|
|
150
150
|
// a trial charges nothing now unless a one-time price is on the first invoice
|
|
151
151
|
const oneTimeDue = (((session.line_items as { data?: Row[] } | undefined)?.data) ?? []).some((it, i) => isOneTime(it, inlineOf(session, i)));
|
|
152
152
|
const chargesNow = session.mode === 'payment' || (session.mode === 'subscription' && (!trialOf(session, nowUnix(ctx.occurredAt)) || oneTimeDue));
|
|
153
153
|
const refused = cardAnswer(v, nowUnix(ctx.occurredAt), chargesNow);
|
|
154
|
-
if (refused) return page(session, id, nowUnix(ctx.occurredAt), v, refused);
|
|
154
|
+
if (refused) return page(ctx.publicBase, session, id, nowUnix(ctx.occurredAt), v, refused);
|
|
155
155
|
let existing = session;
|
|
156
156
|
// Checkout always makes a customer for a subscription; a payment makes one only when the session asks
|
|
157
157
|
if (!session.customer && (session.mode === 'subscription' || session.customer_creation === 'always')) {
|
|
@@ -199,5 +199,5 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
|
199
199
|
const stored = ctx.row(CS, id) ?? {};
|
|
200
200
|
const coupon = (stored._discount as Row | undefined)?.coupon;
|
|
201
201
|
const full = { ...session, _subscription_data: stored._subscription_data, _price_data: stored._price_data, _merchant: merchantOf(ctx, stored), _coupon: typeof coupon === 'string' ? ctx.get('coupon', coupon) : undefined, _customer: typeof session.customer === 'string' ? ctx.get('customer', session.customer) : undefined };
|
|
202
|
-
return request.method === 'GET' ? page(full, id, nowUnix(ctx.occurredAt)) : pay(ctx, id, full, values);
|
|
202
|
+
return request.method === 'GET' ? page(ctx.publicBase, full, id, nowUnix(ctx.occurredAt)) : pay(ctx, id, full, values);
|
|
203
203
|
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// single-use entry point: visiting it takes the owner to their onboarding session, and visiting it again, or after it
|
|
4
4
|
// expires, sends them to refresh_url, where the platform makes a new one (docs.stripe.com/connect/hosted-onboarding).
|
|
5
5
|
// At the session's own address they give Stripe what it requires, and Stripe sends them to return_url. Submitting is
|
|
6
|
-
// the owner's move, not an API call. Authored from @volter/world-ui's payment piece
|
|
6
|
+
// the owner's move, not an API call. Authored from @volter/world-ui's payment piece and Stripe's published visual
|
|
7
|
+
// example; the form moves through one section at a time, retaining answers until the final submit.
|
|
7
8
|
//
|
|
8
9
|
// The page asks for exactly what Stripe requires before a US Express account with card_payments and transfers can
|
|
9
10
|
// take card payments (docs.stripe.com/connect/required-verification-information; the set is the one Stripe's
|
|
@@ -23,8 +24,8 @@
|
|
|
23
24
|
// merchant category codes as its list gives them (./industries.ts), and an industry the page does not offer leaves
|
|
24
25
|
// business_profile.mcc due; the states are the fifty and the District of Columbia; a request with no forwarded address
|
|
25
26
|
// records 127.0.0.1. An update link (account_update) shows the same page.
|
|
26
|
-
import type
|
|
27
|
-
import { CONSENT_CSS, flowPage, Payment, PAYMENT_CSS, type PaymentField } from '@volter/world-ui';
|
|
27
|
+
import { twinVendorUrl, type HandlerContext } from '@volter/world-core';
|
|
28
|
+
import { CONSENT_CSS, flowPage, Payment, PAYMENT_CSS, type PaymentProps, type PaymentField } from '@volter/world-ui';
|
|
28
29
|
import type { Row } from '../engine/common.ts';
|
|
29
30
|
import { nowUnix } from '../engine/stripe.ts';
|
|
30
31
|
import { formOf, notFound, seeOther, STRIPE_CONSENT_SKIN } from './shared.tsx';
|
|
@@ -90,12 +91,14 @@ function page(ctx: HandlerContext, sessionUrl: string, link: Row, kind: Kind, va
|
|
|
90
91
|
: [{ heading: 'Business details', fields: business }, { heading: 'About you', fields: person }, { heading: 'Payout account', fields: payout }, { heading: 'Agreement', fields: agreement }];
|
|
91
92
|
return flowPage({
|
|
92
93
|
title: 'Stripe · Account onboarding',
|
|
93
|
-
css: [
|
|
94
|
+
css: [ONBOARDING_CSS],
|
|
94
95
|
...(error ? { status: 400 } : {}),
|
|
95
96
|
body: (
|
|
96
|
-
<
|
|
97
|
-
merchant=
|
|
98
|
-
|
|
97
|
+
<ConnectOnboarding
|
|
98
|
+
merchant="Stripe"
|
|
99
|
+
account={String(link.account)}
|
|
100
|
+
checked={['owners_provided', 'tos'].filter(name => v[name] === (name === 'tos' ? 'accepted' : 'provided'))}
|
|
101
|
+
lines={sections.map(section => ({ name: section.heading, amount: '' }))}
|
|
99
102
|
action={sessionUrl}
|
|
100
103
|
sections={sections}
|
|
101
104
|
submit={{ label: 'Submit' }}
|
|
@@ -167,7 +170,7 @@ async function visit(ctx: HandlerContext, id: string): Promise<Response> {
|
|
|
167
170
|
if (!usable(ctx, link)) return seeOther(String(link.refresh_url));
|
|
168
171
|
const session = `onbs_${id.replace(/^acctlink_/, '')}`;
|
|
169
172
|
await ctx.write(LINK, id, { used: true, _session: session }, 'account_link.used');
|
|
170
|
-
return seeOther(`${SESSIONS}${session}`);
|
|
173
|
+
return seeOther(twinVendorUrl(ctx.call.request, `${SESSIONS}${session}`));
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
/** connect.stripe.com's onboarding pages, or undefined for any other request. */
|
|
@@ -184,7 +187,7 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
|
184
187
|
const stored = ctx.rowsRaw(LINK).find((l) => l._session === sid && l._submitted !== true);
|
|
185
188
|
const link = stored ? ctx.get(LINK, String(stored.id)) : undefined;
|
|
186
189
|
if (!stored || !link) return gone();
|
|
187
|
-
const sessionUrl = `${SESSIONS}${sid}
|
|
190
|
+
const sessionUrl = twinVendorUrl(request, `${SESSIONS}${sid}`);
|
|
188
191
|
const account = ctx.get('account', String(link.account)) ?? {};
|
|
189
192
|
const kind = kindOf(account);
|
|
190
193
|
if (!kind) return unmodelled(account);
|
|
@@ -195,3 +198,58 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
|
195
198
|
await submit(ctx, link, kind, form, request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1');
|
|
196
199
|
return seeOther(String(link.return_url));
|
|
197
200
|
}
|
|
201
|
+
|
|
202
|
+
// Visual reference: Stripe's own hosted onboarding example, read 2026-10-08:
|
|
203
|
+
// https://docs.stripe.com/connect/hosted-onboarding
|
|
204
|
+
// hosted_onboarding_form.e59ba8300f563e43489953f06127f52c.png. Platform branding
|
|
205
|
+
// in that example is configurable; this twin uses its existing Stripe skin.
|
|
206
|
+
|
|
207
|
+
const ONBOARDING_CSS = PAYMENT_CSS + `
|
|
208
|
+
*{box-sizing:border-box}body{color:#30313d;background:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
209
|
+
.pay{grid-template-columns:minmax(280px,40%) minmax(0,60%)}.pay-summary{background:#f7f7f9;padding:48px max(32px,calc((100vw - 1000px)/2));padding-right:40px;position:relative}
|
|
210
|
+
.pay-back{color:#596171;font-size:13px;margin-bottom:40px}.pay-merchant{color:#635bff;font-size:30px;font-weight:700;letter-spacing:-1px;margin-bottom:32px}
|
|
211
|
+
.pay-lines{counter-reset:step;max-width:280px}.pay-line{counter-increment:step;display:block;position:relative;padding:12px 0 12px 38px;color:#687385;font-size:14px}
|
|
212
|
+
.pay-line::before{content:counter(step);position:absolute;left:0;top:11px;width:24px;height:24px;line-height:22px;text-align:center;border:1px solid #d8dee4;border-radius:50%;background:#fff;font-size:12px}
|
|
213
|
+
.pay-line[aria-current=step]{color:#30313d;font-weight:600}.pay-line[aria-current=step]::before{border-color:#635bff;color:#635bff}.pay-line.is-complete::before{background:#635bff;border-color:#635bff;color:#fff}
|
|
214
|
+
.pay-form{padding:56px 48px;max-width:620px;width:100%}.pay-section legend{font-size:26px;letter-spacing:-.6px;font-weight:600;margin-bottom:24px}.pay-field{margin-bottom:22px}
|
|
215
|
+
.pay-field span{font-size:14px;margin-bottom:8px;font-weight:500}.pay-field input,.pay-field select{background:#fff;border-color:#d8dee4;border-radius:6px;padding:10px 12px;box-shadow:0 1px 2px #00000005;color:#30313d}
|
|
216
|
+
.pay-field input:focus,.pay-field select:focus{outline:2px solid #a5a0ff;outline-offset:1px}
|
|
217
|
+
.pay-field:has(input[type=checkbox]){display:flex;align-items:flex-start;gap:10px}.pay-field:has(input[type=checkbox]) span{order:2;font-weight:400}.pay-field input[type=checkbox]{width:16px;height:16px;flex:none;margin-top:3px;accent-color:#635bff}
|
|
218
|
+
.pay-submit,.connect-next{background:#635bff;color:#fff;border:0;border-radius:6px;font-size:14px;font-weight:600;cursor:pointer;padding:12px 16px}
|
|
219
|
+
.connect-actions{display:flex;justify-content:space-between;gap:16px;margin-top:32px}.connect-back{color:#596171;background:transparent;border:0;cursor:pointer;padding:12px 0;font:inherit}.connect-next{min-width:140px;flex:1}
|
|
220
|
+
.pay-error{border:1px solid #f1c2c2;padding:12px;border-radius:6px;color:#b52e2e;background:#fff7f7;font-size:13px;white-space:normal;overflow-wrap:anywhere}
|
|
221
|
+
.connect-progress{height:3px;background:#ececf1;margin:0 0 36px;border-radius:2px;overflow:hidden}.connect-progress div{height:100%;background:#635bff;transition:width .15s}
|
|
222
|
+
.connect-context{position:absolute;bottom:40px;left:32px;color:#687385;font-size:12px}.connect-context strong{display:block;font-weight:500;color:#30313d}
|
|
223
|
+
.connect-ready .pay-section[hidden],.connect-ready .pay-submit[hidden],.connect-actions [hidden]{display:none}
|
|
224
|
+
@media(max-width:800px){.pay{grid-template-columns:1fr}.pay-summary{padding:24px}.pay-back{margin-bottom:20px}.pay-merchant{font-size:24px;margin-bottom:0}.pay-lines{display:none}.connect-context{position:static;padding:0 24px;background:#f7f7f9}.pay-form{padding:32px 24px;max-width:100%}.pay-section legend{font-size:24px}}
|
|
225
|
+
`;
|
|
226
|
+
|
|
227
|
+
// Progressive enhancement only: Back/Continue move through the existing form.
|
|
228
|
+
// No account state is written until its existing Submit operation succeeds.
|
|
229
|
+
const STEPS = `(function(){
|
|
230
|
+
var root=document.querySelector('.connect-onboarding'),form=root.querySelector('form'),groups=Array.from(form.querySelectorAll('.pay-section')),items=Array.from(root.querySelectorAll('.pay-line')),submit=form.querySelector('.pay-submit');
|
|
231
|
+
var progress=document.createElement('div');progress.className='connect-progress';progress.setAttribute('role','progressbar');progress.setAttribute('aria-label','Onboarding progress');progress.setAttribute('aria-valuemin','0');progress.setAttribute('aria-valuemax',String(groups.length));
|
|
232
|
+
var bar=document.createElement('div');progress.append(bar);form.before(progress);
|
|
233
|
+
var controls=document.createElement('div');controls.className='connect-actions';
|
|
234
|
+
var back=document.createElement('button');back.type='button';back.className='connect-back';back.textContent='Back';
|
|
235
|
+
var next=document.createElement('button');next.type='button';next.className='connect-next';next.textContent='Continue';
|
|
236
|
+
controls.append(back,next);form.append(controls);
|
|
237
|
+
var checked=JSON.parse(root.getAttribute('data-checked-fields')||'[]');
|
|
238
|
+
groups.forEach(function(group){group.querySelectorAll('input,select').forEach(function(input){input.required=true;if(input.type==='checkbox')input.checked=checked.includes(input.name);if(input.name==='ssn_last_4'){input.pattern='[0-9]{4}';input.inputMode='numeric';input.maxLength=4;}});});
|
|
239
|
+
var index=0;
|
|
240
|
+
function show(i){index=i;groups.forEach(function(group,n){group.hidden=n!==i;});items.forEach(function(item,n){if(n===i)item.setAttribute('aria-current','step');else item.removeAttribute('aria-current');item.classList.toggle('is-complete',n<i);});back.hidden=i===0;next.hidden=i===groups.length-1;submit.hidden=i!==groups.length-1;bar.style.width=((i+1)/groups.length*100)+'%';progress.setAttribute('aria-valuenow',String(i+1));progress.setAttribute('aria-valuetext',groups[i].querySelector('legend').textContent);root.classList.add('connect-ready');}
|
|
241
|
+
function invalid(group){return Array.from(group.querySelectorAll('input,select')).find(function(input){return !input.checkValidity();});}
|
|
242
|
+
back.addEventListener('click',function(){show(index-1);});
|
|
243
|
+
next.addEventListener('click',function(){var field=invalid(groups[index]);if(field){field.reportValidity();return;}show(index+1);groups[index].querySelector('input,select')?.focus();});
|
|
244
|
+
form.noValidate=true;
|
|
245
|
+
form.addEventListener('submit',function(event){for(var i=0;i<groups.length;i++){var field=invalid(groups[i]);if(field){event.preventDefault();show(i);field.reportValidity();return;}}});
|
|
246
|
+
if(form.querySelector('.pay-error')){var first=groups.findIndex(function(group){return invalid(group);});show(first<0?groups.length-1:first);}else show(0);
|
|
247
|
+
})();`;
|
|
248
|
+
|
|
249
|
+
function ConnectOnboarding(props: PaymentProps & { account: string; checked: string[] }) {
|
|
250
|
+
return <div className="connect-onboarding" data-checked-fields={JSON.stringify(props.checked)}>
|
|
251
|
+
<Payment {...props} />
|
|
252
|
+
<div className="connect-context"><strong>Test mode</strong>{props.account}</div>
|
|
253
|
+
<script dangerouslySetInnerHTML={{ __html: STEPS }} />
|
|
254
|
+
</div>;
|
|
255
|
+
}
|
|
@@ -1,7 +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
|
-
//
|
|
4
|
-
// The operator controls prepare the cited real application calls; other Dashboard pages answer Stripe's 404.
|
|
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.
|
|
5
4
|
//
|
|
6
5
|
// THE DASHBOARD'S PUBLIC DETAILS — a workspace page (docs/contributing/architecture.md, "Screens") of the Dashboard
|
|
7
6
|
// (manifest screen `dashboard`), at dashboard.stripe.com/settings/public: Settings → Business → Public details, where
|
|
@@ -23,26 +22,6 @@
|
|
|
23
22
|
// and the save redirects to its own path. Saving is an update of an account that exists (the first save included, though
|
|
24
23
|
// the twin stores the platform's record only then), so every save is sent as account.updated.
|
|
25
24
|
//
|
|
26
|
-
// THE DASHBOARD'S CONNECT OAUTH SETTINGS — a workspace page (docs/contributing/architecture.md, "Screens") of the
|
|
27
|
-
// Dashboard (manifest screen `dashboard`), at dashboard.stripe.com/settings/connect/onboarding-options/oauth, where a
|
|
28
|
-
// platform starts an OAuth integration: "Enable onboarding accounts with OAuth", "Copy your client_id, a unique
|
|
29
|
-
// identifier for your platform that's generated by Stripe", and "Set your redirect_uri ... You must specify all redirect
|
|
30
|
-
// URLs in your platform settings. If you don't include the redirect_uri parameter in your request, Stripe defaults to
|
|
31
|
-
// using the first address you've configured" (docs.stripe.com/connect/oauth-standard-accounts). The test client_id
|
|
32
|
-
// "allows you to: Set your redirect_uri to a non-HTTPS URL; Set your redirect_uri to localhost"
|
|
33
|
-
// (docs.stripe.com/connect/testing#using-oauth). Stripe's API has no call that reads or changes these settings (GET
|
|
34
|
-
// /v1/account does not carry the client_id), so this page is the only way to them, and connect.stripe.com/oauth/authorize
|
|
35
|
-
// (./connect-oauth.tsx) reads what it saves. Authored from plain markup under Stripe's type; nothing of Stripe's page is
|
|
36
|
-
// copied.
|
|
37
|
-
//
|
|
38
|
-
// Where the documentation stops and the twin decides: the World has one platform, and its test client_id is
|
|
39
|
-
// `ca_twin_self` (PLATFORM_CLIENT_ID; the platform account is acct_twin_self), the same in every World, shown on the
|
|
40
|
-
// page in an element a runner reads (data-testid="connect-client-id"). OAuth starts disabled. The page is served at
|
|
41
|
-
// the docs' address, at /test/… (the docs' test-mode link) and at /settings/connect (the address Cal.com's setup
|
|
42
|
-
// guide gives). The redirect URIs are one form field, one per line (or comma-separated, as the reference describes
|
|
43
|
-
// them), saved whole: each an absolute http or https URL without a fragment; http and localhost are allowed, as the
|
|
44
|
-
// test client_id allows them. No sign-in guards the page. The form posts to the
|
|
45
|
-
// page's own address and the save redirects to it.
|
|
46
25
|
import type { HandlerContext } from '@volter/world-core';
|
|
47
26
|
import { flowPage } from '@volter/world-ui';
|
|
48
27
|
import { VL } from '../engine/radar.ts';
|
|
@@ -51,7 +30,7 @@ import type { Row } from '../engine/common.ts';
|
|
|
51
30
|
import { platformAccountDefault } from '../engine/connect.ts';
|
|
52
31
|
import { publicBusinessName } from '../engine/display.ts';
|
|
53
32
|
import { PLATFORM_ACCOUNT_ID } from '../engine/stripe.ts';
|
|
54
|
-
import { formOf, notFound } from './shared.tsx';
|
|
33
|
+
import { formOf, money, notFound } from './shared.tsx';
|
|
55
34
|
import { created } from '../semantics/shared.ts';
|
|
56
35
|
const redirect = (path: string): Response => new Response(null, { status: 303, headers: { location: path } });
|
|
57
36
|
const invalid = (message: string): Response => flowPage({ css: [], title: 'Stripe', status: 400, body: <main><p role="alert">{message}</p></main> });
|
|
@@ -61,8 +40,32 @@ const MAX_NAME = 5000;
|
|
|
61
40
|
|
|
62
41
|
const DETAILS_CSS = `
|
|
63
42
|
* { box-sizing: border-box; }
|
|
64
|
-
body { margin: 0; background: #
|
|
65
|
-
|
|
43
|
+
body { margin: 0; background: #ffffff; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; font-size: 14px; line-height: 1.5; }
|
|
44
|
+
button,input,select { font: inherit; }
|
|
45
|
+
button,a,input,select { outline-offset: 3px; }
|
|
46
|
+
button { cursor: pointer; }
|
|
47
|
+
button:disabled { cursor: default; color: #697386; }
|
|
48
|
+
a { color: #635bff; text-decoration: none; }
|
|
49
|
+
.sd { min-height: 100vh; display: grid; grid-template-columns: 224px minmax(0,1fr); }
|
|
50
|
+
.sd-side { background: #f6f8fa; border-right: 1px solid #e3e8ee; padding: 20px 14px; }
|
|
51
|
+
.sd-account { font-weight: 600; padding: 6px 10px 20px; overflow-wrap: anywhere; }
|
|
52
|
+
.sd-mode { display: block; width: fit-content; font-size: 11px; font-weight: 500; background: #e3e8ee; border-radius: 4px; margin-top: 8px; padding: 2px 7px; }
|
|
53
|
+
.sd-nav { display: grid; gap: 3px; }
|
|
54
|
+
.sd-nav a,.sd-nav button { display: block; text-align: left; padding: 7px 10px; font: inherit; border: 0; border-radius: 6px; background: none; color: #697386; }
|
|
55
|
+
.sd-nav a { color: #1a1f36; }
|
|
56
|
+
.sd-nav a:hover { background: #e3e8ee; }
|
|
57
|
+
.sd-nav [aria-current=page] { color: #635bff; background: #ffffff; font-weight: 600; }
|
|
58
|
+
.sd-nav-title { margin: 23px 10px 7px; font-size: 12px; font-weight: 600; color: #697386; }
|
|
59
|
+
.sd-body { min-width: 0; }
|
|
60
|
+
.sd-top { min-height: 64px; border-bottom: 1px solid #e3e8ee; padding: 14px 32px; display: flex; align-items: center; gap: 16px; }
|
|
61
|
+
.sd-search { width: min(520px,100%); border: 1px solid #e3e8ee; border-radius: 7px; padding: 7px 12px; background: #f6f8fa; }
|
|
62
|
+
.sd-top a { margin-left: auto; }
|
|
63
|
+
.sd-top button { border: 0; background: none; }
|
|
64
|
+
.sd-content { padding: 28px 40px 60px; max-width: 1280px; }
|
|
65
|
+
.sd-crumbs { color: #697386; font-size: 13px; display: flex; gap: 10px; margin-bottom: 18px; }
|
|
66
|
+
.sd-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid #e3e8ee; padding-bottom: 20px; margin-bottom: 28px; }
|
|
67
|
+
.sd-title h1 { font-size: 28px; margin: 0; letter-spacing: -.5px; }
|
|
68
|
+
.pd { max-width: 860px; }
|
|
66
69
|
.pd h1 { font-size: 28px; margin: 16px 0 4px; }
|
|
67
70
|
.pd-lead { color: #697386; margin: 0 0 24px; }
|
|
68
71
|
.pd-card { background: #ffffff; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; }
|
|
@@ -70,22 +73,115 @@ body { margin: 0; background: #f6f8fa; color: #1a1f36; font-family: -apple-syste
|
|
|
70
73
|
.pd-card small { display: block; color: #697386; margin-bottom: 8px; }
|
|
71
74
|
.pd-card input { width: 100%; padding: 8px 10px; border: 1px solid #c1c9d2; border-radius: 6px; font: inherit; }
|
|
72
75
|
.pd-actions { margin-top: 20px; display: flex; justify-content: flex-end; }
|
|
73
|
-
.pd-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
76
|
+
.sd-primary,.pd-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
74
77
|
.pd-notice { padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; }
|
|
75
78
|
.pd-error { background: #fff0f3; color: #df1b41; }
|
|
76
79
|
.pd-saved { background: #e7f8ef; color: #0e6245; }
|
|
80
|
+
.sd-table { border-collapse: collapse; width: 100%; }
|
|
81
|
+
.sd-table th,.sd-table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid #e3e8ee; }
|
|
82
|
+
.sd-table th { font-size: 12px; font-weight: 600; color: #697386; background: #f6f8fa; }
|
|
83
|
+
.sd-empty { color: #697386; padding: 40px; text-align: center; }
|
|
84
|
+
.sd-code { font-family: ui-monospace,SFMono-Regular,monospace; font-size: 12px; }
|
|
85
|
+
.sd-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; } }
|
|
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; }
|
|
99
|
+
.sd-dialog::backdrop { background: rgba(26,31,54,.4); }
|
|
100
|
+
.sd-dialog h2 { font-size: 20px; margin: 0 0 20px; }
|
|
101
|
+
.sd-dialog label { display: block; font-weight: 600; margin: 14px 0; }
|
|
102
|
+
.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; }
|
|
103
|
+
.sd-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
|
|
104
|
+
.sd-cancel { border: 1px solid #c1c9d2; border-radius: 6px; padding: 8px 16px; background: #ffffff; color: #1a1f36; }
|
|
105
|
+
@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; } }
|
|
106
|
+
@media(max-width:520px) { .sd { display: block; } .sd-side { border-right: 0; border-bottom: 1px solid #e3e8ee; padding: 10px 16px; } .sd-account { padding: 0 0 8px; } .sd-mode { display: inline; margin-left: 8px; } .sd-nav { display: flex; flex-wrap: wrap; } .sd-nav button,.sd-nav-title { display: none; } .sd-content { padding: 20px 16px; } .sd-title h1 { font-size: 24px; } .sd-top { padding: 12px 16px; } }
|
|
77
107
|
`;
|
|
78
108
|
|
|
79
|
-
|
|
109
|
+
type DashboardPage = { title: string; section: string; path: string; body: Parameters<typeof flowPage>[0]['body']; action?: Parameters<typeof flowPage>[0]['body']; status?: number };
|
|
110
|
+
/** The documented dashboard navigation around only the operator pages this pack already serves. */
|
|
111
|
+
function dashboardPage(ctx: HandlerContext, opts: DashboardPage): Response {
|
|
112
|
+
const account = ctx.get('account', PLATFORM_ACCOUNT_ID);
|
|
113
|
+
const links = [['Public details', PUBLIC_DETAILS_PATH], ['Radar lists', '/radar/lists'], ['Issuing balance', '/test/issuing/balance']];
|
|
114
|
+
return flowPage({ title: `${opts.title} – Stripe`, css: [DETAILS_CSS], status: opts.status ?? 200, body: <div className="sd">
|
|
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">
|
|
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>)}
|
|
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>)}
|
|
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>)}
|
|
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>
|
|
122
|
+
<main className="sd-content"><nav className="sd-crumbs" aria-label="Breadcrumb"><span>{opts.section}</span><span aria-hidden="true">/</span><span>{opts.title}</span></nav><div className="sd-title"><h1>{opts.title}</h1>{opts.action}</div>{opts.body}</main></div>
|
|
123
|
+
<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()));" }}/>
|
|
124
|
+
</div> });
|
|
125
|
+
}
|
|
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
|
+
|
|
177
|
+
function detailsPage(ctx: HandlerContext, account: Row | undefined, opts: { value?: string; error?: string; saved?: boolean } = {}): Response {
|
|
80
178
|
const named = (account?.business_profile as Row | undefined)?.name;
|
|
81
179
|
const value = opts.value ?? (typeof named === 'string' ? named : '');
|
|
82
|
-
return
|
|
83
|
-
title: 'Public details
|
|
84
|
-
css: [DETAILS_CSS],
|
|
180
|
+
return dashboardPage(ctx, {
|
|
181
|
+
title: 'Public details', section: 'Settings / Business', path: PUBLIC_DETAILS_PATH,
|
|
85
182
|
...(opts.error ? { status: 400 } : {}),
|
|
86
183
|
body: (
|
|
87
|
-
<
|
|
88
|
-
<h1>Public details</h1>
|
|
184
|
+
<div className="pd">
|
|
89
185
|
<p className="pd-lead">What your customers see on Checkout, the customer portal, receipts and invoices. Currently shown: <strong data-testid="public-business-name">{publicBusinessName(account)}</strong></p>
|
|
90
186
|
{opts.error ? <p className="pd-notice pd-error" role="alert">{opts.error}</p> : null}
|
|
91
187
|
{opts.saved ? <p className="pd-notice pd-saved" role="status">Your public details were saved.</p> : null}
|
|
@@ -95,7 +191,7 @@ function detailsPage(account: Row | undefined, opts: { value?: string; error?: s
|
|
|
95
191
|
<input id="business_name" name="business_name" autoComplete="organization" defaultValue={value} required maxLength={MAX_NAME} />
|
|
96
192
|
<div className="pd-actions"><button type="submit">Save</button></div>
|
|
97
193
|
</form>
|
|
98
|
-
</
|
|
194
|
+
</div>
|
|
99
195
|
),
|
|
100
196
|
});
|
|
101
197
|
}
|
|
@@ -118,21 +214,21 @@ async function publicDetails(ctx: HandlerContext): Promise<Response> {
|
|
|
118
214
|
const url = new URL(request.url);
|
|
119
215
|
if (request.method !== 'GET' && request.method !== 'POST') return notFound();
|
|
120
216
|
const form = request.method === 'POST' ? await formOf(request.clone()) : {};
|
|
121
|
-
if (request.method === 'GET') return detailsPage(ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
|
|
217
|
+
if (request.method === 'GET') return detailsPage(ctx, ctx.get('account', PLATFORM_ACCOUNT_ID), { saved: url.searchParams.has('saved') });
|
|
122
218
|
const name = (form.business_name ?? '').trim();
|
|
123
219
|
const refused = nameRefused(name);
|
|
124
|
-
if (refused) return detailsPage(ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
|
|
220
|
+
if (refused) return detailsPage(ctx, ctx.get('account', PLATFORM_ACCOUNT_ID), { value: form.business_name ?? '', error: refused });
|
|
125
221
|
await save(ctx, name);
|
|
126
222
|
// a query-only reference: the page's own path, wherever it is mounted and with or without its trailing slash
|
|
127
223
|
return new Response(null, { status: 303, headers: { location: '?saved=1' } });
|
|
128
224
|
}
|
|
129
225
|
|
|
130
|
-
/** Dashboard settings, Radar
|
|
226
|
+
/** Dashboard customer inspection, settings, Radar lists and test Issuing funding. */
|
|
131
227
|
export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
132
228
|
const path = new URL(ctx.call.request.url).pathname.replace(/\/+$/, '');
|
|
133
|
-
// The
|
|
134
|
-
|
|
135
|
-
if (path === ''
|
|
229
|
+
// The declared workspace opens a served setting, not a fabricated analytics home.
|
|
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);
|
|
136
232
|
// source: https://docs.stripe.com/radar/lists "Use the Dashboard or the API to create lists."
|
|
137
233
|
// source: https://docs.stripe.com/radar/lists "Enter a name for the list"
|
|
138
234
|
// source: https://docs.stripe.com/radar/lists "Select the type of list to create."
|
|
@@ -154,9 +250,22 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
|
154
250
|
list_items: { object: 'list', data: [], has_more: false, url: '/v1/radar/value_list_items' },
|
|
155
251
|
});
|
|
156
252
|
await ctx.write(VL, String(list.id), { list_items: { object: 'list', data: [], has_more: false, url: `/v1/radar/value_list_items?value_list=${list.id}` } }, 'radar.value_list.updated');
|
|
157
|
-
return redirect(
|
|
253
|
+
return redirect(`${ctx.publicBase}/radar/lists/${list.id}`);
|
|
158
254
|
}
|
|
159
|
-
|
|
255
|
+
const lists = ctx.rows(VL);
|
|
256
|
+
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']];
|
|
257
|
+
const selected = lists.find(row => path === `/radar/lists/${row.id}`);
|
|
258
|
+
return dashboardPage(ctx, { title: selected ? String(selected.name) : 'Lists', section: 'Payments / Radar', path: '/radar/lists',
|
|
259
|
+
action: <button className="sd-primary" type="button" data-open-dialog="radar-new-list">New list</button>,
|
|
260
|
+
body: <>{selected ? <div className="pd-card"><a href={`${ctx.publicBase}/radar/lists`}>All lists</a><h2>{String(selected.name)}</h2><p>Alias: <code>{String(selected.alias)}</code></p><p>List type: {types.find(([key]) => key === selected.item_type)?.[1] ?? String(selected.item_type)}</p></div> : null}<p className="pd-lead">Lists of information to block, allow or review matching payments.</p>
|
|
261
|
+
<table className="sd-table"><thead><tr><th scope="col">Name</th><th scope="col">Alias</th><th scope="col">List type</th></tr></thead><tbody>{lists.map(row => <tr key={String(row.id)}><td><a href={`${ctx.publicBase}/radar/lists/${encodeURIComponent(String(row.id))}`}>{String(row.name)}</a></td><td className="sd-code">{String(row.alias)}</td><td>{types.find(([key]) => key === row.item_type)?.[1] ?? String(row.item_type)}</td></tr>)}</tbody></table>
|
|
262
|
+
{!lists.length ? <p className="sd-empty">No lists yet. Create a list to group related values.</p> : null}
|
|
263
|
+
<dialog className="sd-dialog" id="radar-new-list" aria-labelledby="radar-new-list-title"><h2 id="radar-new-list-title">New list</h2><form method="post" action={`${ctx.publicBase}/radar/lists`}>
|
|
264
|
+
<label htmlFor="radar-list-name">Name</label><input id="radar-list-name" name="name" required/>
|
|
265
|
+
<label htmlFor="radar-list-alias">Alias</label><input id="radar-list-alias" name="alias"/><p className="pd-lead">Leave blank to generate an alias from the name.</p>
|
|
266
|
+
<label htmlFor="radar-list-type">List type</label><select id="radar-list-type" name="item_type">{types.map(([value,label]) => <option key={value} value={value}>{label}</option>)}</select>
|
|
267
|
+
<div className="sd-dialog-actions"><button className="sd-cancel" type="button" data-close-dialog>Cancel</button><button className="sd-primary" type="submit">Add</button></div>
|
|
268
|
+
</form></dialog></> });
|
|
160
269
|
}
|
|
161
270
|
// source: https://docs.stripe.com/issuing/testing "Before you create test transactions, you must add test funds to the Issuing balance on your account."
|
|
162
271
|
// source: https://docs.stripe.com/issuing/testing "You can create test top-ups in the Dashboard, or with the Top-ups API"
|
|
@@ -172,9 +281,9 @@ export async function screen(ctx: HandlerContext): Promise<Response> {
|
|
|
172
281
|
const amount = Number(form.amount);
|
|
173
282
|
if (!Number.isInteger(amount) || amount <= 0) return invalid('Enter a positive amount in cents.');
|
|
174
283
|
await created(ctx, 'balance_transaction', {}, { amount, currency: 'usd', fee: 0, net: amount, type: 'topup', reporting_category: 'topup', source: null, status: 'available', balance_type: 'issuing', available_on: Number(nowUnix(ctx.occurredAt)), fee_details: [] });
|
|
175
|
-
return redirect(
|
|
284
|
+
return redirect(`${ctx.publicBase}/test/issuing/balance?funded=1`);
|
|
176
285
|
}
|
|
177
|
-
return
|
|
286
|
+
return dashboardPage(ctx, { title: 'Issuing balance', section: 'Issuing', path: '/test/issuing/balance', body: <div className="pd"><p className="pd-lead">Add test funds before creating test Issuing transactions.</p>{new URL(ctx.call.request.url).searchParams.has('funded') ? <p className="pd-notice pd-saved" role="status">Test funds were added to your Issuing balance.</p> : null}<form className="pd-card" method="post" action={`${ctx.publicBase}/test/issuing/balance`}><label htmlFor="issuing-amount">Amount</label><small>USD cents. These funds belong to this test account.</small><input id="issuing-amount" name="amount" type="number" min="1" step="1" required/><div className="pd-actions"><button type="submit">Add test funds</button></div></form></div> });
|
|
178
287
|
}
|
|
179
288
|
return path === PUBLIC_DETAILS_PATH ? publicDetails(ctx) : notFound();
|
|
180
289
|
}
|