@volter/twin-stripe 2.0.0 → 2.0.2

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.
Files changed (67) hide show
  1. package/README.md +33 -1
  2. package/dist/src/index.js +6 -4
  3. package/dist/src/manifest.js +8 -3
  4. package/dist/src/screens/checkout.js +20 -6
  5. package/dist/src/screens/connect-oauth.d.ts +27 -0
  6. package/dist/src/screens/connect-oauth.js +414 -0
  7. package/dist/src/screens/connect-settings.d.ts +22 -0
  8. package/dist/src/screens/connect-settings.js +103 -0
  9. package/dist/src/screens/portal.js +2 -0
  10. package/dist/src/semantics/after-payment.d.ts +1 -1
  11. package/dist/src/semantics/after-payment.js +6 -0
  12. package/dist/src/semantics/charges.js +10 -2
  13. package/dist/src/semantics/checkout.js +19 -6
  14. package/dist/src/semantics/connect.js +20 -3
  15. package/dist/src/semantics/invoices.js +4 -0
  16. package/dist/src/semantics/issuing.js +7 -2
  17. package/dist/src/semantics/ledger.d.ts +11 -6
  18. package/dist/src/semantics/ledger.js +40 -21
  19. package/dist/src/semantics/payment-methods.js +2 -0
  20. package/dist/src/semantics/shared.d.ts +5 -1
  21. package/dist/src/semantics/shared.js +14 -3
  22. package/dist/src/semantics/test-cards.d.ts +4 -0
  23. package/dist/src/semantics/test-cards.js +7 -0
  24. package/dist/src/semantics/transfers.js +1 -1
  25. package/dist/src/stripe-capabilities.js +829 -186
  26. package/dist/src/stripe-conformance.d.ts +2 -0
  27. package/dist/src/stripe-conformance.js +11 -2
  28. package/dist/src/stripe-emit.js +2 -2
  29. package/dist/src/stripe-events.js +14 -10
  30. package/dist/src/stripe-mirror-ui.js +3 -3
  31. package/dist/src/stripe-server.js +97 -24
  32. package/dist/src/stripe-shared.d.ts +3 -0
  33. package/dist/src/stripe-shared.js +3 -0
  34. package/dist/src/stripe-twin.js +7 -1
  35. package/dist/src/stripe-version.d.ts +2 -0
  36. package/dist/src/stripe-version.js +2 -0
  37. package/dist/test-fixtures/stripe-known-deviations.json +6 -1
  38. package/dist/test-fixtures/stripe-schemas.json +85 -12
  39. package/package.json +4 -4
  40. package/src/index.ts +6 -4
  41. package/src/manifest.ts +8 -3
  42. package/src/screens/checkout.tsx +21 -6
  43. package/src/screens/connect-oauth.tsx +400 -0
  44. package/src/screens/connect-settings.tsx +121 -0
  45. package/src/screens/portal.tsx +2 -0
  46. package/src/semantics/after-payment.ts +6 -1
  47. package/src/semantics/charges.ts +11 -2
  48. package/src/semantics/checkout.ts +19 -6
  49. package/src/semantics/connect.ts +19 -3
  50. package/src/semantics/invoices.ts +4 -0
  51. package/src/semantics/issuing.ts +7 -2
  52. package/src/semantics/ledger.ts +60 -23
  53. package/src/semantics/payment-methods.ts +2 -0
  54. package/src/semantics/shared.ts +14 -3
  55. package/src/semantics/test-cards.ts +7 -0
  56. package/src/semantics/transfers.ts +1 -1
  57. package/src/stripe-capabilities.ts +826 -182
  58. package/src/stripe-conformance.ts +13 -2
  59. package/src/stripe-emit.ts +2 -2
  60. package/src/stripe-events.ts +14 -10
  61. package/src/stripe-mirror-ui.ts +3 -3
  62. package/src/stripe-server.ts +85 -24
  63. package/src/stripe-shared.ts +3 -0
  64. package/src/stripe-twin.ts +6 -1
  65. package/src/stripe-version.ts +3 -0
  66. package/test-fixtures/stripe-known-deviations.json +6 -1
  67. package/test-fixtures/stripe-schemas.json +85 -12
@@ -0,0 +1,400 @@
1
+ // STRIPE CONNECT OAUTH (Standard accounts) — a hosted flow (docs/contributing/architecture.md, "Screens") and the two
2
+ // connect.stripe.com endpoints behind it, as docs.stripe.com/connect/oauth-reference and
3
+ // docs.stripe.com/connect/oauth-standard-accounts describe them. A platform sends a person to
4
+ // GET connect.stripe.com/oauth/authorize with its client_id; the person connects (in test mode Stripe lets them "Force-skip
5
+ // the account form", docs.stripe.com/connect/testing#using-oauth) or denies; Stripe sends them back to the redirect_uri
6
+ // with a `code`, the `scope` granted and the `state` (or `error=access_denied`); the platform's server turns the code into
7
+ // the connection with POST connect.stripe.com/oauth/token, authenticated by its secret key, and learns the account id
8
+ // (`stripe_user_id`) it then acts as with the Stripe-Account header. POST connect.stripe.com/oauth/deauthorize revokes the
9
+ // connection. The page is @volter/world-ui's consent piece under Stripe's skin; nothing of Stripe's page is copied.
10
+ //
11
+ // What the documentation says and the twin keeps:
12
+ // - authorize: `client_id` is the platform's (its Connect OAuth settings, ./connect-settings.tsx, where OAuth is enabled
13
+ // and the redirect URIs are registered); `response_type` "The only option at the moment is code"; `redirect_uri`, "If
14
+ // provided, this must exactly match one of the ... redirect_uri values in your application settings" and "Defaults to
15
+ // the redirect_uri in your application settings" ("the first URI configured"); `scope` read_write or read_only,
16
+ // "Defaults to read_only"; `state` passed back. `stripe_user[...]` prefills the new account, and "Any parameters with
17
+ // invalid values are silently ignored". Errors: "the user's browser won't be redirected except in the case of
18
+ // access_denied. Instead, errors will be returned in a JSON dictionary" of error, error_description and state, with
19
+ // the codes invalid_scope, invalid_redirect_uri, invalid_request (Missing response_type) and unsupported_response_type.
20
+ // Denied: `error=access_denied&error_description=The%20user%20denied%20your%20request`. Success: code, scope, state.
21
+ // - token: the code "can only be used once and expires in 5 minutes"; "Consuming an authorization code more than once
22
+ // revokes the account connection"; grant_type authorization_code or refresh_token; a refresh `scope` of "equal or lesser
23
+ // scope" and "Any existing access token with the same scope and mode ... is revoked"; the response's scope,
24
+ // stripe_user_id, livemode, token_type "bearer", access_token, stripe_publishable_key and refresh_token; errors
25
+ // invalid_request, invalid_grant ("Authorization code does not exist: {AUTHORIZATION_CODE}", verbatim from the guide),
26
+ // unsupported_grant_type and invalid_scope as `{ error, error_description }`.
27
+ // - deauthorize: client_id and stripe_user_id, answered `{ stripe_user_id }`; errors invalid_request and invalid_client
28
+ // ("stripe_user_id doesn't exist or isn't connected to your application"); afterwards "the account can't be accessed by
29
+ // your platform ... through the API" (stripe-server.ts refuses the Stripe-Account header for it, account_invalid).
30
+ // - events: account.application.authorized when the person connects and account.application.deauthorized when the
31
+ // connection is revoked, each the connected account's (top-level `account`, Connect endpoints), carrying the
32
+ // `application` object ({ id: the client_id, object: 'application', name }).
33
+ //
34
+ // Where the documentation stops and the twin decides:
35
+ // - the twin is a test-mode Stripe: its client_id is a test one (./connect-settings.tsx PLATFORM_CLIENT_ID), redirect
36
+ // URIs may be http and localhost ("Your test client_id allows you to ... Set your redirect_uri to a non-HTTPS URL /
37
+ // to localhost"), a code, token and connection are livemode false, and a live secret key (sk_live_) is refused as the
38
+ // documented mode mismatch (invalid_grant at token, invalid_client at deauthorize).
39
+ // - the only way through the page is the test-mode skip: it creates a NEW Standard account from the prefill and connects
40
+ // it; logging in to connect an existing Stripe account is not modelled. A skipped test account can take payments at
41
+ // once (charges and payouts enabled, card_payments and transfers active, nothing due), and controls itself (controller
42
+ // type `account`), since the platform did not create it.
43
+ // - an unknown client_id answers invalid_client "No application matches the supplied client identifier", and a
44
+ // platform that has not enabled OAuth invalid_client naming its settings page; a missing client_id is invalid_request.
45
+ // The JSON errors are HTTP 400; the order of the checks is client_id, OAuth enabled, redirect_uri, response_type,
46
+ // scope. With no redirect URI registered and none given, invalid_redirect_uri. read_only is accepted from any platform
47
+ // (the docs limit it to extensions; the twin does not tell a platform from an extension).
48
+ // - the secret key may come as Bearer, as Basic's user, or as the `client_secret` parameter (the reference's curl uses
49
+ // `-u`, stripe-node a Bearer header); without one the answer is 401 invalid_request, and a key that is not a secret
50
+ // key (sk_…/rk_…) 401 invalid_request too. The token and deauthorize wording other than the one quoted above is the
51
+ // twin's. An expired code is invalid_grant "Authorization code expired: {code}"; a reused one invalid_grant "This
52
+ // authorization code has already been used. All tokens issued with this code have been revoked." and revokes.
53
+ // - tokens are deterministic, not secret: access_token `sk_test_oauth_{account}_{n}`, refresh_token `rt_twin_{n}`,
54
+ // stripe_publishable_key `pk_test_oauth_{account}`; a refresh answers the same refresh_token (`rt_twin_{n}` of the code
55
+ // `ac_twin_{n}` that made the connection). The access_token and publishable key are deprecated by Stripe (use the
56
+ // Stripe-Account header) and act as the account (stripe-server.ts actingAsOAuthKey); a read_only one is not held to
57
+ // reads (manifest todo `stripe.connect.oauth_scope_enforcement`).
58
+ // - concurrency: a code's number is taken with its write in one move; a code redeemed twice at once lets one exchange
59
+ // win, and whichever of the two finishes last revokes the connection, so a double exchange always ends revoked.
60
+ import { semanticsContext, type SemanticsContext } from '@volter/world-core';
61
+ import { Consent, CONSENT_CSS, flowPage } from '@volter/world-ui';
62
+ import surface from '../generated/surface.gen.json' with { type: 'json' };
63
+ import { manifest } from '../manifest.ts';
64
+ import { created, type Row } from '../semantics/shared.ts';
65
+ import { OAUTH_CONNECTIONS, publicBusinessName } from '../stripe-shared.ts';
66
+ import { accountSettings, afterStripeWrite, PLATFORM_ACCOUNT_ID } from '../stripe-twin.ts';
67
+ import { formOf, seeOther, STRIPE_CONSENT_SKIN } from './consent-skin.ts';
68
+ import { connectOAuthSettings, PLATFORM_CLIENT_ID, SETTINGS_URL } from './connect-settings.tsx';
69
+
70
+ const OPERATION = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'PostAccountsAccount')!;
71
+ /** A code "expires in 5 minutes". */
72
+ export const CODE_TTL_SECONDS = 300;
73
+ const CODES = '_oauth_code';
74
+ /** One connection per connected account (the twin's one platform): its scope, tokens, and whether it was revoked. */
75
+ export const CONNECTIONS = OAUTH_CONNECTIONS;
76
+ const SCOPES = ['read_write', 'read_only'] as const;
77
+ type Scope = (typeof SCOPES)[number];
78
+
79
+ type Scoped = { root?: string; clock?: () => string };
80
+ const json = (body: Row, status = 200): Response => Response.json(body, { status, headers: { 'request-id': 'req_twin', 'cache-control': 'no-store' } });
81
+ const oauthError = (error: string, description: string, status = 400, extra: Row = {}): Response => json({ error, error_description: description, ...extra }, status);
82
+
83
+ /** Whether a connected account's OAuth connection to the platform was revoked (deauthorized, or its code reused). */
84
+ export function connectionRevoked(ctx: SemanticsContext, account: string): boolean {
85
+ return ctx.rowsRaw(CONNECTIONS).some((c) => c.id === account && c.revoked === true);
86
+ }
87
+
88
+ /** The connected account an OAuth key acts as — the connection's current access_token (sk_test_oauth_…) or its
89
+ * stripe_publishable_key (pk_test_oauth_…) — with the scope it was issued; undefined for any other key, and null for an
90
+ * OAuth-shaped key no live connection holds (revoked, replaced by a refresh, or never issued). */
91
+ export function oauthKeyAccount(ctx: SemanticsContext, key: string): { account: string; scope: string } | null | undefined {
92
+ if (!/^(sk|pk)_test_oauth_/.test(key)) return undefined;
93
+ const held = ctx.rowsRaw(CONNECTIONS).find((c) => c.revoked !== true && (c.access_token === key || c.publishable_key === key));
94
+ return held ? { account: String(held.id), scope: String(key.startsWith('pk_') ? held.scope : held.access_scope ?? held.scope) } : null;
95
+ }
96
+
97
+ // ── authorize ──
98
+
99
+ /** A query parameter, or undefined when absent (an empty value is absent too, as a browser form sends one). */
100
+ const param = (q: URLSearchParams, k: string): string | undefined => { const v = q.get(k); return v === null || v === '' ? undefined : v; };
101
+
102
+ type Authorize = { redirect: string; scope: Scope; state: string | undefined };
103
+
104
+ /** The authorize request checked as Stripe checks it, or the JSON error it answers. */
105
+ function checked(ctx: SemanticsContext, q: URLSearchParams): Authorize | Response {
106
+ const state = param(q, 'state');
107
+ const fail = (error: string, description: string) => oauthError(error, description, 400, state !== undefined ? { state } : {});
108
+ const client = param(q, 'client_id');
109
+ if (!client) return fail('invalid_request', 'No client_id provided.');
110
+ if (client !== PLATFORM_CLIENT_ID) return fail('invalid_client', 'No application matches the supplied client identifier');
111
+ const settings = connectOAuthSettings(ctx);
112
+ if (!settings.oauth_enabled) return fail('invalid_client', `OAuth is not enabled for this application. Enable it in your Connect OAuth settings (${SETTINGS_URL}).`);
113
+ const given = q.get('redirect_uri');
114
+ let redirect: string | undefined;
115
+ if (given !== null) {
116
+ if (!settings.redirect_uris.includes(given)) return fail('invalid_redirect_uri', `Invalid redirect URI '${given}'. Ensure this uri exactly matches one of the uris specified in your application settings`);
117
+ redirect = given;
118
+ } else {
119
+ redirect = settings.redirect_uris[0];
120
+ if (!redirect) return fail('invalid_redirect_uri', 'No redirect URI is configured in your application settings');
121
+ }
122
+ const type = q.get('response_type');
123
+ if (type === null || type === '') return fail('invalid_request', 'Missing response_type parameter.');
124
+ if (type !== 'code') return fail('unsupported_response_type', `Unsupported response_type parameter: ${type}. Currently the only supported response_type is code.`);
125
+ const scope = param(q, 'scope') ?? 'read_only';
126
+ if (!(SCOPES as readonly string[]).includes(scope)) return fail('invalid_scope', `Invalid scope parameter provided: '${scope}'. Accepted scopes are 'read_write' or 'read_only'.`);
127
+ return { redirect, scope: scope as Scope, state };
128
+ }
129
+
130
+ /** The redirect_uri with these parameters added, each percent-encoded (a space as %20, as Stripe's examples show). */
131
+ function withParams(uri: string, params: Array<[string, string | undefined]>): string {
132
+ const [base, fragment] = uri.split('#', 2) as [string, string | undefined];
133
+ const added = params.filter((p): p is [string, string] => p[1] !== undefined).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
134
+ return `${base}${base.includes('?') ? (base.endsWith('?') || base.endsWith('&') ? '' : '&') : '?'}${added}${fragment !== undefined ? `#${fragment}` : ''}`;
135
+ }
136
+
137
+ /** The prefill the request carries, invalid values silently dropped (docs.stripe.com/connect/oauth-reference). Only
138
+ * what the new account's object shows the platform, or the page shows the person, is read: the person's birth date and
139
+ * the business address are the account application's, which the twin does not model (the test-mode skip). */
140
+ export function prefill(q: URLSearchParams): Record<string, string> {
141
+ const v = (k: string) => param(q, `stripe_user[${k}]`)?.trim() || undefined;
142
+ const out: Record<string, string> = {};
143
+ const keep = (k: string, ok: (s: string) => boolean = () => true) => { const s = v(k); if (s !== undefined && ok(s)) out[k] = s; };
144
+ keep('email', (s) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s));
145
+ keep('url', (s) => /^https?:\/\/\S+$/i.test(s));
146
+ keep('country', (s) => /^[A-Z]{2}$/.test(s));
147
+ keep('phone_number', (s) => /^\d{10}$/.test(s) && out.country !== undefined);
148
+ keep('business_name');
149
+ keep('business_type', (s) => ['sole_prop', 'corporation', 'non_profit', 'partnership', 'llc'].includes(s));
150
+ keep('first_name');
151
+ keep('last_name');
152
+ keep('product_description');
153
+ keep('currency', (s) => /^[a-z]{3}$/.test(s) && out.country !== undefined);
154
+ return out;
155
+ }
156
+
157
+ // the default currency of an account in a country, where the prefill names none (docs.stripe.com/connect/currencies:
158
+ // the account's default is its country's currency); a country outside this list is given usd
159
+ const COUNTRY_CURRENCY: Record<string, string> = { US: 'usd', CA: 'cad', GB: 'gbp', AU: 'aud', NZ: 'nzd', JP: 'jpy', SG: 'sgd', HK: 'hkd', CH: 'chf', SE: 'sek', NO: 'nok', DK: 'dkk', PL: 'pln', MX: 'mxn', BR: 'brl', IN: 'inr', DE: 'eur', FR: 'eur', ES: 'eur', IT: 'eur', NL: 'eur', IE: 'eur', BE: 'eur', AT: 'eur', PT: 'eur', FI: 'eur' };
160
+ const BUSINESS_TYPE: Record<string, string> = { sole_prop: 'individual', corporation: 'company', llc: 'company', partnership: 'company', non_profit: 'non_profit' };
161
+
162
+ /** The Standard account the skipped form creates, from the prefill. */
163
+ async function createAccount(ctx: SemanticsContext, p: Record<string, string>): Promise<string> {
164
+ const country = p.country ?? 'US';
165
+ const id = ctx.mint('account');
166
+ const none = { alternatives: [], current_deadline: null, currently_due: [], disabled_reason: null, errors: [], eventually_due: [], past_due: [], pending_verification: [] };
167
+ await created(ctx, 'account', { id }, {
168
+ type: 'standard', country, default_currency: p.currency ?? COUNTRY_CURRENCY[country] ?? 'usd', email: p.email ?? null,
169
+ business_type: p.business_type ? BUSINESS_TYPE[p.business_type] ?? null : null,
170
+ business_profile: { name: p.business_name ?? null, url: p.url ?? null, product_description: p.product_description ?? null, support_phone: p.phone_number ?? null, mcc: null },
171
+ charges_enabled: true, payouts_enabled: true, details_submitted: true,
172
+ capabilities: { card_payments: 'active', transfers: 'active' },
173
+ controller: { type: 'account' },
174
+ requirements: none, future_requirements: none,
175
+ settings: accountSettings(undefined, p.business_name ? { dashboard: { display_name: p.business_name, timezone: 'Etc/UTC' } } : undefined),
176
+ external_accounts: { object: 'list', data: [], has_more: false, total_count: 0, url: `/v1/accounts/${id}/external_accounts` },
177
+ tos_acceptance: { date: null, ip: null, user_agent: null }, metadata: {}, livemode: false,
178
+ }, { operation: 'account.oauth_connect' });
179
+ return id;
180
+ }
181
+
182
+ /** The platform's application as the account.application.* events carry it. */
183
+ function application(ctx: SemanticsContext): Row {
184
+ return { id: PLATFORM_CLIENT_ID, object: 'application', name: publicBusinessName(ctx.get('account', PLATFORM_ACCOUNT_ID)) };
185
+ }
186
+
187
+ /** Send one of the account.application.* events as the connected account's. */
188
+ async function applicationEvent(ctx: SemanticsContext, type: 'account.application.authorized' | 'account.application.deauthorized', account: string): Promise<void> {
189
+ await afterStripeWrite('application', type, application(ctx), ctx.root, ctx.occurredAt, undefined, account);
190
+ }
191
+
192
+ /** A code's number: `ac_twin_{n}`, the refresh token its exchange mints being `rt_twin_{n}`, so the one follows the
193
+ * other and neither is counted separately. */
194
+ const codeNumber = (code: string): string => code.replace(/^ac_twin_/, '');
195
+
196
+ function page(ctx: SemanticsContext, action: string, a: Authorize, p: Record<string, string>): Response {
197
+ const name = publicBusinessName(ctx.get('account', PLATFORM_ACCOUNT_ID));
198
+ const who = [p.email, [p.first_name, p.last_name].filter(Boolean).join(' '), p.business_name].filter((s) => s).join(' · ');
199
+ return flowPage({
200
+ title: `Connect to ${name} – Stripe`,
201
+ css: [CONSENT_CSS, STRIPE_CONSENT_SKIN],
202
+ body: (
203
+ <Consent
204
+ heading={`Connect your Stripe account to ${name}`}
205
+ app={{ name }}
206
+ request="wants to connect to your Stripe account · test mode"
207
+ permissions={[
208
+ a.scope === 'read_write'
209
+ ? { title: 'Read and write access', detail: 'Create payments, customers and other data on your account, and read its data' }
210
+ : { title: 'Read-only access', detail: 'Read your account’s data' },
211
+ { title: 'A new test account', detail: who || 'No details prefilled' },
212
+ ]}
213
+ action={action}
214
+ fields={{}}
215
+ deny={{ name: 'decision', value: 'deny', label: 'Deny access' }}
216
+ allow={{ name: 'decision', value: 'skip', label: 'Skip this form' }}
217
+ note={`Test mode: skipping the form creates a new test account and connects it. You will be returned to ${new URL(a.redirect).host}.`}
218
+ />
219
+ ),
220
+ });
221
+ }
222
+
223
+ /** The person connects: a new Standard account, its connection's code, the authorized event, and the redirect. */
224
+ async function connect(ctx: SemanticsContext, a: Authorize, p: Record<string, string>): Promise<Response> {
225
+ const account = await createAccount(ctx, p);
226
+ // the code's number is taken and its row written in one move, so two people connecting at once get two codes
227
+ const fields = { client_id: PLATFORM_CLIENT_ID, account, scope: a.scope, redirect_uri: a.redirect, expires_at: Number(ctx.now()) + CODE_TTL_SECONDS, used: false, livemode: false };
228
+ const code = await ctx.atomically((rows) => {
229
+ const id = `ac_twin_${rows(CODES).length + 1}`;
230
+ return { value: id, write: { resource: CODES, id, fields, operation: 'oauth_code.issue' } };
231
+ });
232
+ await applicationEvent(ctx, 'account.application.authorized', account);
233
+ return seeOther(withParams(a.redirect, [['scope', a.scope], ['code', code], ['state', a.state]]));
234
+ }
235
+
236
+ async function authorize(request: Request, scope: Scoped): Promise<Response> {
237
+ const url = new URL(request.url);
238
+ const form = request.method === 'POST' ? await formOf(request.clone()) : {};
239
+ const ctx = await semanticsContext(manifest, request, OPERATION, scope);
240
+ const a = checked(ctx, url.searchParams);
241
+ if (a instanceof Response) return a;
242
+ const p = prefill(url.searchParams);
243
+ if (request.method === 'GET') return page(ctx, `${url.pathname}${url.search}`, a, p);
244
+ if (form.decision === 'deny') return seeOther(withParams(a.redirect, [['error', 'access_denied'], ['error_description', 'The user denied your request'], ['state', a.state]]));
245
+ if (form.decision === 'skip') return connect(ctx, a, p);
246
+ return page(ctx, `${url.pathname}${url.search}`, a, p);
247
+ }
248
+
249
+ // ── token and deauthorize ──
250
+
251
+ /** The request's parameters: a form, as stripe-node and curl send them, or JSON. */
252
+ async function bodyOf(request: Request): Promise<Record<string, string>> {
253
+ const text = await request.clone().text();
254
+ if ((request.headers.get('content-type') ?? '').includes('json') || text.trim().startsWith('{')) {
255
+ try { return Object.fromEntries(Object.entries(JSON.parse(text) as Row).map(([k, v]) => [k, String(v)])); } catch { return {}; }
256
+ }
257
+ return Object.fromEntries(new URLSearchParams(text));
258
+ }
259
+
260
+ /** The secret key a request carries: Bearer, Basic's user, or the client_secret parameter. */
261
+ function secretKey(request: Request, params: Record<string, string>): string | undefined {
262
+ const header = request.headers.get('authorization') ?? '';
263
+ const bearer = /^bearer\s+(\S+)/i.exec(header)?.[1];
264
+ if (bearer) return bearer;
265
+ const basic = /^basic\s+(\S+)/i.exec(header)?.[1];
266
+ if (basic) { try { const user = atob(basic).split(':')[0]; if (user) return user; } catch { /* not base64 */ } }
267
+ return params.client_secret || undefined;
268
+ }
269
+
270
+ /** A key shown the way Stripe's errors show one: its prefix and its last four. */
271
+ export const redactKey = (key: string): string => {
272
+ const prefix = /^(sk|rk|pk)_(test|live)_/.exec(key)?.[0] ?? '';
273
+ const rest = key.slice(prefix.length);
274
+ return rest.length > 4 ? `${prefix}${'*'.repeat(rest.length - 4)}${rest.slice(-4)}` : `${prefix}${'*'.repeat(rest.length)}`;
275
+ };
276
+
277
+ /** The refusal of a request without a usable secret key, or undefined when it has one (answered with whether it is live). */
278
+ function keyRefused(key: string | undefined): Response | undefined {
279
+ if (!key) return oauthError('invalid_request', 'No API key provided. Provide your secret key in the Authorization header, using Bearer auth (e.g. \'Authorization: Bearer YOUR_SECRET_KEY\').', 401);
280
+ if (!/^(sk|rk)_(test|live)_/.test(key)) return oauthError('invalid_request', `Invalid API Key provided: ${redactKey(key)}. This endpoint requires a secret key.`, 401);
281
+ // a connected account's access_token is that account's key, never the platform's ("Make this call using your secret API
282
+ // key", the reference): it neither exchanges codes nor refreshes nor deauthorizes
283
+ if (/^sk_test_oauth_/.test(key)) return oauthError('invalid_request', `Invalid API Key provided: ${redactKey(key)}. This endpoint requires your platform's secret key.`, 401);
284
+ return undefined;
285
+ }
286
+ const live = (key: string): boolean => /^(sk|rk)_live_/.test(key);
287
+
288
+ /** A new access token for a connection: its generation counted from the ones it had. */
289
+ const accessToken = (account: string, n: number): string => `sk_test_oauth_${account}_${n}`;
290
+
291
+ /** Revoke an account's connection, once: the check and the write are one move, and only the move that revoked it sends
292
+ * account.application.deauthorized. Answers whether this call revoked it. */
293
+ async function revoke(ctx: SemanticsContext, account: string): Promise<boolean> {
294
+ const revoked = await ctx.atomically((rows) => {
295
+ const held = rows(CONNECTIONS).find((c) => c.id === account);
296
+ if (!held || held.revoked === true) return { value: false };
297
+ return { value: true, write: { resource: CONNECTIONS, id: account, fields: { revoked: true, access_token: null, revoked_at: Number(ctx.now()) }, operation: 'oauth_connection.revoke' } };
298
+ });
299
+ if (revoked) await applicationEvent(ctx, 'account.application.deauthorized', account);
300
+ return revoked;
301
+ }
302
+
303
+ async function token(request: Request, scope: Scoped): Promise<Response> {
304
+ const params = await bodyOf(request);
305
+ const key = secretKey(request, params);
306
+ const refused = keyRefused(key);
307
+ if (refused) return refused;
308
+ const ctx = await semanticsContext(manifest, request, OPERATION, scope);
309
+ const grant = params.grant_type;
310
+ if (!grant) return oauthError('invalid_request', 'No grant type specified');
311
+ if (grant !== 'authorization_code' && grant !== 'refresh_token') return oauthError('unsupported_grant_type', `Unsupported grant type: ${grant}. The only currently supported types are authorization_code and refresh_token.`);
312
+ if (grant === 'authorization_code') {
313
+ const code = params.code;
314
+ if (!code) return oauthError('invalid_request', 'No authorization code provided');
315
+ if (live(key!)) return oauthError('invalid_grant', `Authorization code provided does not belong to the livemode of the API key: ${code} is a test mode code, but a live mode key was used.`);
316
+ // a code is spent once: the read and the spend are one move, so two exchanges at once cannot both succeed
317
+ type Redeemed = { outcome: 'missing' } | { outcome: 'used' | 'expired' | 'ok'; row: Row };
318
+ const found = await ctx.atomically<Redeemed>((rows) => {
319
+ const row = rows(CODES).find((r) => r.id === code);
320
+ if (!row) return { value: { outcome: 'missing' } };
321
+ // a second use is marked in the same move, so the exchange that spent it sees it after writing its connection
322
+ if (row.used === true) return { value: { outcome: 'used', row }, write: { resource: CODES, id: code, fields: { reused: true }, operation: 'oauth_code.reuse' } };
323
+ if (Number(ctx.now()) >= Number(row.expires_at)) return { value: { outcome: 'expired', row } };
324
+ return { value: { outcome: 'ok', row }, write: { resource: CODES, id: code, fields: { used: true, used_at: Number(ctx.now()) }, operation: 'oauth_code.redeem' } };
325
+ });
326
+ if (found.outcome === 'missing') return oauthError('invalid_grant', `Authorization code does not exist: ${code}`);
327
+ const account = String(found.row.account);
328
+ if (found.outcome === 'used') {
329
+ // "Consuming an authorization code more than once revokes the account connection"
330
+ await revoke(ctx, account);
331
+ return oauthError('invalid_grant', 'This authorization code has already been used. All tokens issued with this code have been revoked.');
332
+ }
333
+ if (found.outcome === 'expired') return oauthError('invalid_grant', `Authorization code expired: ${code}`);
334
+ const granted = String(found.row.scope) as Scope;
335
+ const refresh = `rt_twin_${codeNumber(code)}`;
336
+ const connection = { client_id: PLATFORM_CLIENT_ID, account, scope: granted, code, refresh_token: refresh, access_token: accessToken(account, 1), access_scope: granted, generation: 1, publishable_key: `pk_test_oauth_${account}`, revoked: false, livemode: false };
337
+ await ctx.record(CONNECTIONS, connection, account);
338
+ // the code used again while this exchange was writing: that use found no connection to revoke, so this one does
339
+ if (ctx.rowsRaw(CODES).some((r) => r.id === code && r.reused === true)) {
340
+ await revoke(ctx, account);
341
+ return oauthError('invalid_grant', 'This authorization code has already been used. All tokens issued with this code have been revoked.');
342
+ }
343
+ return json(tokenBody(connection, granted));
344
+ }
345
+ const refresh = params.refresh_token;
346
+ if (!refresh) return oauthError('invalid_request', 'No refresh token provided');
347
+ const held = ctx.rowsRaw(CONNECTIONS).find((c) => c.refresh_token === refresh && c.revoked !== true);
348
+ if (!held) return oauthError('invalid_grant', `Refresh token does not exist: ${refresh}`);
349
+ if (live(key!)) return oauthError('invalid_grant', 'The refresh token provided does not belong to the livemode of the API key.');
350
+ const asked = params.scope || String(held.scope);
351
+ if (!(SCOPES as readonly string[]).includes(asked)) return oauthError('invalid_scope', `Invalid scope parameter provided: '${asked}'. Accepted scopes are 'read_write' or 'read_only'.`);
352
+ if (asked === 'read_write' && held.scope !== 'read_write') return oauthError('invalid_scope', `The requested scope 'read_write' is greater than the scope of the refresh token ('${String(held.scope)}').`);
353
+ const account = String(held.id);
354
+ // "Any existing access token with the same scope and mode ... is revoked": the connection holds the one now issued; the
355
+ // generation is read and written in one move, so two refreshes at once issue two tokens, and a refresh never lands
356
+ // on a connection revoked meanwhile
357
+ const renewed = await ctx.atomically((rows) => {
358
+ const now = rows(CONNECTIONS).find((c) => c.id === account && c.revoked !== true);
359
+ if (!now) return { value: undefined };
360
+ const generation = Number(now.generation ?? 1) + 1;
361
+ const fields = { access_token: accessToken(account, generation), access_scope: asked, generation };
362
+ return { value: { ...now, ...fields }, write: { resource: CONNECTIONS, id: account, fields, operation: 'oauth_connection.refresh' } };
363
+ });
364
+ if (!renewed) return oauthError('invalid_grant', `Refresh token does not exist: ${refresh}`);
365
+ return json(tokenBody(renewed, asked as Scope));
366
+ }
367
+
368
+ /** The token endpoint's answer, as the reference lists it. */
369
+ function tokenBody(c: Row, scope: Scope): Row {
370
+ return { access_token: c.access_token, livemode: false, refresh_token: c.refresh_token, scope, stripe_publishable_key: c.publishable_key, stripe_user_id: c.account ?? c.id, token_type: 'bearer' };
371
+ }
372
+
373
+ async function deauthorize(request: Request, scope: Scoped): Promise<Response> {
374
+ const params = await bodyOf(request);
375
+ const key = secretKey(request, params);
376
+ const refused = keyRefused(key);
377
+ if (refused) return refused;
378
+ const ctx = await semanticsContext(manifest, request, OPERATION, scope);
379
+ const client = params.client_id;
380
+ const account = params.stripe_user_id;
381
+ if (!client) return oauthError('invalid_request', 'No client_id provided');
382
+ if (!account) return oauthError('invalid_request', 'No stripe_user_id provided');
383
+ if (client !== PLATFORM_CLIENT_ID) return oauthError('invalid_client', `No such application: '${client}'`);
384
+ if (live(key!)) return oauthError('invalid_client', `The API key mode (live) does not match the mode of the client_id ${client} (test).`);
385
+ // the check and the revocation are one move (revoke): of two deauthorizations at once, one revokes and the other finds
386
+ // the account no longer connected
387
+ if (!(await revoke(ctx, account))) return oauthError('invalid_client', `This application is not connected to stripe account ${account}, or that account does not exist.`);
388
+ return json({ stripe_user_id: account });
389
+ }
390
+
391
+ /** connect.stripe.com's OAuth endpoints, or undefined for any other request. */
392
+ export function stripeConnectOAuthFlow(scope: Scoped): (request: Request) => Promise<Response | undefined> {
393
+ return async (request) => {
394
+ const path = new URL(request.url).pathname.replace(/\/+$/, '');
395
+ if (path === '/oauth/authorize' && (request.method === 'GET' || request.method === 'POST')) return authorize(request, scope);
396
+ if (path === '/oauth/token' && request.method === 'POST') return token(request, scope);
397
+ if (path === '/oauth/deauthorize' && request.method === 'POST') return deauthorize(request, scope);
398
+ return undefined;
399
+ };
400
+ }
@@ -0,0 +1,121 @@
1
+ // THE DASHBOARD'S CONNECT OAUTH SETTINGS — a workspace page (docs/contributing/architecture.md, "Screens") of the
2
+ // Dashboard (manifest screen `dashboard`), at dashboard.stripe.com/settings/connect/onboarding-options/oauth, where a
3
+ // platform starts an OAuth integration: "Enable onboarding accounts with OAuth", "Copy your client_id, a unique
4
+ // identifier for your platform that's generated by Stripe", and "Set your redirect_uri ... You must specify all redirect
5
+ // URLs in your platform settings. If you don't include the redirect_uri parameter in your request, Stripe defaults to
6
+ // using the first address you've configured" (docs.stripe.com/connect/oauth-standard-accounts). The test client_id
7
+ // "allows you to: Set your redirect_uri to a non-HTTPS URL; Set your redirect_uri to localhost"
8
+ // (docs.stripe.com/connect/testing#using-oauth). Stripe's API has no call that reads or changes these settings (GET
9
+ // /v1/account does not carry the client_id), so this page is the only way to them, and connect.stripe.com/oauth/authorize
10
+ // (./connect-oauth.tsx) reads what it saves. Authored from plain markup under Stripe's type; nothing of Stripe's page is
11
+ // copied.
12
+ //
13
+ // Where the documentation stops and the twin decides: the World has one platform, and its test client_id is
14
+ // `ca_twin_self` (PLATFORM_CLIENT_ID; the platform account is acct_twin_self), the same in every World, shown on the
15
+ // page in an element a runner reads (data-testid="connect-client-id"). OAuth starts disabled. The page is served at
16
+ // the docs' address, at /test/… (the docs' test-mode link) and at /settings/connect (the address Cal.com's setup
17
+ // guide gives). The redirect URIs are one form field, one per line (or comma-separated, as the reference describes
18
+ // them), saved whole: each an absolute http or https URL without a fragment; http and localhost are allowed, as the
19
+ // test client_id allows them. No sign-in guards the page, as none guards the Dashboard mirror. The form posts to the
20
+ // page's own address and the save redirects to it.
21
+ import { semanticsContext, type SemanticsContext } from '@volter/world-core';
22
+ import { flowPage } from '@volter/world-ui';
23
+ import surface from '../generated/surface.gen.json' with { type: 'json' };
24
+ import { manifest } from '../manifest.ts';
25
+ import { formOf } from './consent-skin.ts';
26
+
27
+ /** The platform's test client_id: the World's one platform (acct_twin_self) is the application it names. */
28
+ export const PLATFORM_CLIENT_ID = 'ca_twin_self';
29
+ export const SETTINGS_URL = 'https://dashboard.stripe.com/settings/connect/onboarding-options/oauth';
30
+ /** The page's addresses: the docs' own, its test-mode link, and Connect's settings root. */
31
+ export const CONNECT_SETTINGS_PATH = /^\/(test\/)?settings\/connect(\/onboarding-options\/oauth)?\/?$/;
32
+ const SETTINGS = '_connect_oauth_settings';
33
+ const OPERATION = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'PostAccountsAccount')!;
34
+
35
+ export type ConnectOAuthSettings = { oauth_enabled: boolean; redirect_uris: string[] };
36
+
37
+ /** What the platform saved, or the settings a new platform starts with (OAuth off, no redirect URI). */
38
+ export function connectOAuthSettings(ctx: SemanticsContext): ConnectOAuthSettings {
39
+ const row = ctx.rowsRaw(SETTINGS).find((r) => r.id === PLATFORM_CLIENT_ID);
40
+ return { oauth_enabled: row?.oauth_enabled === true, redirect_uris: Array.isArray(row?.redirect_uris) ? (row!.redirect_uris as string[]) : [] };
41
+ }
42
+
43
+ /** The redirect URIs a form's field names, or the first one refused and why. */
44
+ export function redirectUris(field: string): { uris: string[] } | { refused: string } {
45
+ const uris = [...new Set(field.split(/[\n,]/).map((s) => s.trim()).filter(Boolean))];
46
+ for (const uri of uris) {
47
+ let url: URL;
48
+ try { url = new URL(uri); } catch { return { refused: `${uri} is not a valid URL.` }; }
49
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return { refused: `${uri} must use http or https.` };
50
+ if (uri.includes('#')) return { refused: `${uri} must not contain a fragment.` };
51
+ }
52
+ return { uris };
53
+ }
54
+
55
+ const CSS = `
56
+ * { box-sizing: border-box; }
57
+ body { margin: 0; background: #f6f8fa; color: #1a1f36; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif; font-size: 14px; }
58
+ .co { max-width: 680px; margin: 48px auto; padding: 0 16px; }
59
+ .co h1 { font-size: 28px; margin: 16px 0 4px; }
60
+ .co-lead { color: #697386; margin: 0 0 24px; }
61
+ .co-card { background: #ffffff; border: 1px solid #e3e8ee; border-radius: 8px; padding: 24px; margin-bottom: 16px; }
62
+ .co-card h2 { font-size: 16px; margin: 0 0 12px; }
63
+ .co-card label { display: block; font-weight: 600; margin-bottom: 6px; }
64
+ .co-card small { display: block; color: #697386; margin-bottom: 8px; }
65
+ .co-card textarea { width: 100%; min-height: 96px; padding: 8px 10px; border: 1px solid #c1c9d2; border-radius: 6px; font: 13px ui-monospace, Menlo, monospace; }
66
+ .co-id { font: 14px ui-monospace, Menlo, monospace; background: #f6f8fa; border: 1px solid #e3e8ee; border-radius: 6px; padding: 6px 10px; display: inline-block; }
67
+ .co-toggle { display: flex; gap: 8px; align-items: center; font-weight: 600; }
68
+ .co-actions { display: flex; justify-content: flex-end; }
69
+ .co-actions button { background: #635bff; color: #ffffff; border: 0; border-radius: 6px; padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
70
+ .co-notice { padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; }
71
+ .co-error { background: #fff0f3; color: #df1b41; }
72
+ .co-saved { background: #e7f8ef; color: #0e6245; }
73
+ .co-uris { margin: 0; padding-left: 18px; }
74
+ `;
75
+
76
+ function page(s: ConnectOAuthSettings, opts: { value?: string; enabled?: boolean; error?: string; saved?: boolean } = {}): Response {
77
+ const enabled = opts.enabled ?? s.oauth_enabled;
78
+ return flowPage({
79
+ title: 'OAuth – Connect settings – Stripe',
80
+ css: [CSS],
81
+ ...(opts.error ? { status: 400 } : {}),
82
+ body: (
83
+ <main className="co">
84
+ <h1>OAuth</h1>
85
+ <p className="co-lead">Onboard Standard accounts by sending them to Stripe's OAuth flow. Test mode.</p>
86
+ {opts.error ? <p className="co-notice co-error" role="alert">{opts.error}</p> : null}
87
+ {opts.saved ? <p className="co-notice co-saved" role="status">Your OAuth settings were saved.</p> : null}
88
+ <section className="co-card">
89
+ <h2>Test mode client ID</h2>
90
+ <small>The client_id your platform sends to connect.stripe.com/oauth/authorize.</small>
91
+ <code className="co-id" data-testid="connect-client-id">{PLATFORM_CLIENT_ID}</code>
92
+ <p data-testid="connect-oauth-status">{s.oauth_enabled ? 'OAuth is enabled.' : 'OAuth is disabled.'}</p>
93
+ {s.redirect_uris.length ? <ul className="co-uris">{s.redirect_uris.map((u) => <li key={u} data-testid="connect-redirect-uri">{u}</li>)}</ul> : <p>No redirect URIs.</p>}
94
+ </section>
95
+ <form className="co-card" method="post">
96
+ <label className="co-toggle" htmlFor="oauth_enabled"><input type="checkbox" id="oauth_enabled" name="oauth_enabled" value="on" defaultChecked={enabled} /> Enable OAuth for Standard accounts</label>
97
+ <label htmlFor="redirect_uris">Redirect URIs</label>
98
+ <small>One per line. The first is used when a request names none. http and localhost are allowed in test mode.</small>
99
+ <textarea id="redirect_uris" name="redirect_uris" defaultValue={opts.value ?? s.redirect_uris.join('\n')} />
100
+ <div className="co-actions"><button type="submit">Save</button></div>
101
+ </form>
102
+ </main>
103
+ ),
104
+ });
105
+ }
106
+
107
+ export function stripeConnectSettingsFlow(scope: { root?: string; clock?: () => string }): (request: Request) => Promise<Response | undefined> {
108
+ return async (request) => {
109
+ const url = new URL(request.url);
110
+ if (!CONNECT_SETTINGS_PATH.test(url.pathname) || (request.method !== 'GET' && request.method !== 'POST')) return undefined;
111
+ const form = request.method === 'POST' ? await formOf(request.clone()) : {};
112
+ const ctx = await semanticsContext(manifest, request, OPERATION, scope);
113
+ const held = connectOAuthSettings(ctx);
114
+ if (request.method === 'GET') return page(held, { saved: url.searchParams.has('saved') });
115
+ const enabled = form.oauth_enabled === 'on' || form.oauth_enabled === 'true';
116
+ const parsed = redirectUris(form.redirect_uris ?? '');
117
+ if ('refused' in parsed) return page(held, { value: form.redirect_uris ?? '', enabled, error: parsed.refused });
118
+ await ctx.record(SETTINGS, { oauth_enabled: enabled, redirect_uris: parsed.uris }, PLATFORM_CLIENT_ID);
119
+ return new Response(null, { status: 303, headers: { location: '?saved=1' } });
120
+ };
121
+ }
@@ -21,6 +21,7 @@ import { flowPage, Portal, PORTAL_CSS, type PaymentField, type PortalItem } from
21
21
  import surface from '../generated/surface.gen.json' with { type: 'json' };
22
22
  import { manifest } from '../manifest.ts';
23
23
  import { payerOf, payOpenInvoice } from '../semantics/renewals.ts';
24
+ import { afterSuccessOf } from '../semantics/after-payment.ts';
24
25
  import { created, type Row } from '../semantics/shared.ts';
25
26
  import { publicBusinessName } from '../stripe-shared.ts';
26
27
  import { declineFor, paymentMethodSubObject, PLATFORM_ACCOUNT_ID } from '../stripe-twin.ts';
@@ -176,6 +177,7 @@ async function updatePaymentMethod(ctx: SemanticsContext, id: string, session: R
176
177
  type: 'card', customer, livemode: false,
177
178
  billing_details: { address: { country: v.billingCountry || null, postal_code: v.billingPostalCode || null, city: null, line1: null, line2: null, state: null }, email: null, name: v.billingName || null, phone: null },
178
179
  ...paymentMethodSubObject('card', { card }), _declineOutcome: declineFor(() => undefined, { card }) ?? null,
180
+ ...(afterSuccessOf(ctx, card) ? { _afterSuccess: afterSuccessOf(ctx, card) } : {}),
179
181
  });
180
182
  const holder = ctx.get('customer', customer) ?? {};
181
183
  await ctx.write('customer', customer, { invoice_settings: { ...((holder.invoice_settings as Row | undefined) ?? {}), default_payment_method: pm.id } }, 'customer.update');
@@ -17,9 +17,10 @@
17
17
  import type { SemanticsContext } from '@volter/world-core';
18
18
  import { disputeEvidence } from '../stripe-twin.ts';
19
19
  import { actingAccount, settleApplicationFee, settleDispute, settleTransfer } from './ledger.ts';
20
+ import { BYPASS_PENDING_CARDS } from './test-cards.ts';
20
21
  import { created, type Row } from './shared.ts';
21
22
 
22
- type Outcome = 'dispute' | 'dispute_not_received' | 'inquiry' | 'review';
23
+ type Outcome = 'dispute' | 'dispute_not_received' | 'inquiry' | 'review' | 'available';
23
24
  const DAY = 86_400;
24
25
 
25
26
  /** The test cards whose success Stripe follows with its own act, by number and by their documented test names. */
@@ -28,12 +29,16 @@ export const AFTER_SUCCESS_CARDS: Record<string, { brand: string; number: string
28
29
  pm_card_createDisputeProductNotReceived: { brand: 'visa', number: '4000000000002685', outcome: 'dispute_not_received' },
29
30
  pm_card_createDisputeInquiry: { brand: 'visa', number: '4000000000001976', outcome: 'inquiry' },
30
31
  pm_card_riskLevelElevated: { brand: 'visa', number: '4000000000009235', outcome: 'review' },
32
+ // its funds go straight to the available balance (ledger.ts reads it; no act follows here)
33
+ ...Object.fromEntries(Object.entries(BYPASS_PENDING_CARDS).map(([name, c]) => [name, { ...c, outcome: 'available' as const }])),
31
34
  };
32
35
  const BY_NUMBER: Record<string, Outcome> = Object.fromEntries(Object.values(AFTER_SUCCESS_CARDS).map((c) => [c.number, c.outcome]));
33
36
 
34
37
  /** What Stripe does after a card succeeds: from a raw number, a test name (pm_card_* or tok_*), or a stored
35
38
  * PaymentMethod that recorded it when it was made. */
36
39
  export function afterSuccessOf(ctx: SemanticsContext, ref: unknown): Outcome | undefined {
40
+ // a card given as a token (card[token], payment_method_data[card][token]) is the card the token names
41
+ if (ref && typeof ref === 'object' && typeof (ref as Row).token === 'string') return afterSuccessOf(ctx, (ref as Row).token);
37
42
  if (ref && typeof ref === 'object') return BY_NUMBER[String((ref as Row).number ?? '').replace(/\D/g, '')];
38
43
  if (typeof ref !== 'string' || !ref) return undefined;
39
44
  const stored = ctx.row('payment_method', ref);
@@ -23,13 +23,22 @@ export function chargeBody(ctx: SemanticsContext, c: Row): Row {
23
23
  // (capture=false: its status is succeeded, captured false), and charge.failed, "Occurs whenever a failed charge attempt
24
24
  // occurs", for a declined attempt. The twin makes no pending charge (charge.pending, "Occurs whenever a pending charge is
25
25
  // created"): its charges settle when made.
26
+ /** The create's parameters as the charge keeps them: without `card`, the request's card details ("A token, like the ones
27
+ * returned by Stripe.js", or a hash of the number, expiry and CVC). The Charge object has no `card` property (served
28
+ * spec, charge); the card it was made with is its payment_method_details. Kept, it answered the card's full number and
29
+ * CVC back on every read of the charge. */
30
+ function withoutCard(params: Row): Row {
31
+ const { card: _card, ...rest } = params;
32
+ return rest;
33
+ }
34
+
26
35
  /** A Charges-API charge a declining test card refuses: a 402 card_error naming the failed charge Stripe records for the
27
36
  * attempt (docs.stripe.com/declines). */
28
37
  async function declinedCharge(ctx: SemanticsContext, decline: { code: string; decline_code?: string; message: string }): Promise<Response> {
29
38
  const params = ctx.params;
30
39
  const failedId = ctx.mint('charge');
31
40
  const amount = Number(params.amount) || 0;
32
- await created(ctx, 'charge', { ...params, id: failedId }, {
41
+ await created(ctx, 'charge', { ...withoutCard(params), id: failedId }, {
33
42
  ...chargeDefaults(failedId, amount, false, ctx.occurredAt), status: 'failed', paid: false, captured: false, capture_before: null,
34
43
  failure_code: decline.code, failure_message: decline.message, balance_transaction: null,
35
44
  outcome: { type: 'issuer_declined', network_status: 'declined_by_network', reason: decline.decline_code ?? decline.code, risk_level: 'normal', seller_message: 'The bank did not return any further details with this decline.' },
@@ -63,7 +72,7 @@ const create: Semantics = async (ctx) => {
63
72
  const card = String(params.source ?? params.payment_method ?? (params.card as Row | undefined)?.number ?? '');
64
73
  const bt = captured ? await settleCharge(ctx, id, amount, String(params.currency ?? 'usd'), card) : null;
65
74
  const details = paymentMethodDetails(ctx, params.payment_method ?? params.source ?? params.card);
66
- await created(ctx, 'charge', { ...params, id }, { ...chargeDefaults(id, amount, captured, ctx.occurredAt), balance_transaction: bt, payment_method_details: details ?? null }, { operation: 'charge.succeeded' });
75
+ await created(ctx, 'charge', { ...withoutCard(params), id }, { ...chargeDefaults(id, amount, captured, ctx.occurredAt), balance_transaction: bt, payment_method_details: details ?? null }, { operation: 'charge.succeeded' });
67
76
  await afterCharge(ctx, id, { amount, currency: String(params.currency ?? 'usd'), application_fee_amount: params.application_fee_amount, destination: (params.transfer_data as Row | undefined)?.destination }, params.payment_method ?? params.source ?? params.card);
68
77
  return ctx.reply(chargeBody(ctx, ctx.get('charge', id)!));
69
78
  };