@volter/twin-stripe 2.0.0 → 2.0.1

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 (65) 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 +13 -9
  30. package/dist/src/stripe-server.js +72 -5
  31. package/dist/src/stripe-shared.d.ts +3 -0
  32. package/dist/src/stripe-shared.js +3 -0
  33. package/dist/src/stripe-twin.js +7 -1
  34. package/dist/src/stripe-version.d.ts +2 -0
  35. package/dist/src/stripe-version.js +2 -0
  36. package/dist/test-fixtures/stripe-known-deviations.json +6 -1
  37. package/dist/test-fixtures/stripe-schemas.json +85 -12
  38. package/package.json +4 -4
  39. package/src/index.ts +6 -4
  40. package/src/manifest.ts +8 -3
  41. package/src/screens/checkout.tsx +21 -6
  42. package/src/screens/connect-oauth.tsx +400 -0
  43. package/src/screens/connect-settings.tsx +121 -0
  44. package/src/screens/portal.tsx +2 -0
  45. package/src/semantics/after-payment.ts +6 -1
  46. package/src/semantics/charges.ts +11 -2
  47. package/src/semantics/checkout.ts +19 -6
  48. package/src/semantics/connect.ts +19 -3
  49. package/src/semantics/invoices.ts +4 -0
  50. package/src/semantics/issuing.ts +7 -2
  51. package/src/semantics/ledger.ts +60 -23
  52. package/src/semantics/payment-methods.ts +2 -0
  53. package/src/semantics/shared.ts +14 -3
  54. package/src/semantics/test-cards.ts +7 -0
  55. package/src/semantics/transfers.ts +1 -1
  56. package/src/stripe-capabilities.ts +826 -182
  57. package/src/stripe-conformance.ts +13 -2
  58. package/src/stripe-emit.ts +2 -2
  59. package/src/stripe-events.ts +13 -9
  60. package/src/stripe-server.ts +60 -5
  61. package/src/stripe-shared.ts +3 -0
  62. package/src/stripe-twin.ts +6 -1
  63. package/src/stripe-version.ts +3 -0
  64. package/test-fixtures/stripe-known-deviations.json +6 -1
  65. package/test-fixtures/stripe-schemas.json +85 -12
@@ -15,6 +15,7 @@ import { twinResources } from '@volter/world-core';
15
15
  import { specConformance } from '@volter/world-tooling';
16
16
  import type { TwinResource } from '@volter/world-core';
17
17
  import { OBJECT_NAME } from './stripe-twin.ts';
18
+ import { INCLUDABLE_FIELDS, render } from './stripe-version.ts';
18
19
 
19
20
  type JsonSchema = specConformance.JsonSchema;
20
21
  type KnownDeviation = specConformance.KnownDeviation;
@@ -43,6 +44,8 @@ export type StripeConformanceReport = {
43
44
  fieldsChecked: number;
44
45
  violations: StripeViolation[];
45
46
  knownIgnored: number;
47
+ /** fields checked per twin resource type: a type the check skipped (no schema) is absent */
48
+ fieldsByType: Record<string, number>;
46
49
  };
47
50
 
48
51
  const isTwinExtra = (k: string): boolean => k.startsWith('_');
@@ -51,11 +54,17 @@ const fixturePath = (name: string): string => new URL(`../test-fixtures/${name}`
51
54
  // The emitted REST body == the twin's view(): strip internal fields, add object + id.
52
55
  // Mirrors stripe-twin.ts view(): a vendor `type` field collides with the kernel
53
56
  // discriminator, so it is stored under `_stripe_type` and restored to `type` on emit.
57
+ // ...and then rendered in the version a caller that pins none is served (stripe-version.ts render): since 24e757f60
58
+ // the twin KEEPS objects in the 2024-06-20 shape its rules were written against and answers them in the served
59
+ // version's (basil onward moved an invoice's subscription and charge, a charge's source, ...), so the stored row is
60
+ // not what any client receives. The schemas are projected from a post-basil spec, so the answer is what is checked.
54
61
  function emitted(r: TwinResource): Record<string, unknown> {
55
62
  const { type, updatedAt, _stripe_type, ...rest } = r as TwinResource & { _stripe_type?: unknown };
56
63
  const out: Record<string, unknown> = { object: OBJECT_NAME[type] ?? type, ...rest, id: r.id };
57
64
  if (_stripe_type !== undefined) out.type = _stripe_type;
58
- return out;
65
+ // expanded: an includable field (a session's line_items, a charge's refunds) is answered only when a request asks, and
66
+ // the check reads the answer that carries it so its shape is still checked
67
+ return render(out, undefined, INCLUDABLE_FIELDS) as Record<string, unknown>;
59
68
  }
60
69
 
61
70
  /** Load the vendored per-object Stripe JSON Schemas (type+required+enum from OpenAPI). */
@@ -79,6 +88,7 @@ export function checkStripeConformance(schemas: StripeSchemas, opts: { root?: st
79
88
  const violations: StripeViolation[] = [];
80
89
  let fieldsChecked = 0;
81
90
  let knownIgnored = 0;
91
+ const fieldsByType: Record<string, number> = {};
82
92
  for (const r of resources) {
83
93
  const objectName = TYPE_TO_OBJECT[r.type];
84
94
  if (!objectName) continue;
@@ -86,10 +96,11 @@ export function checkStripeConformance(schemas: StripeSchemas, opts: { root?: st
86
96
  if (!schema) continue;
87
97
  const rep = specConformance.checkSpecConformance(emitted(r), schema, { exemptKey: isTwinExtra, known: opts.known ?? [] });
88
98
  fieldsChecked += rep.fieldsChecked;
99
+ fieldsByType[r.type] = (fieldsByType[r.type] ?? 0) + rep.fieldsChecked;
89
100
  knownIgnored += rep.knownIgnored;
90
101
  for (const v of rep.violations) violations.push({ ...v, object: objectName, id: r.id });
91
102
  }
92
- return { ok: violations.length === 0, resourcesChecked: resources.length, fieldsChecked, violations, knownIgnored };
103
+ return { ok: violations.length === 0, resourcesChecked: resources.length, fieldsChecked, violations, knownIgnored, fieldsByType };
93
104
  }
94
105
 
95
106
  export type StripeCoverageReport = specConformance.SpecCoverageReport & { object: string };
@@ -11,7 +11,7 @@
11
11
  // DELIVER does not transition state: it snapshots the subject AS IT IS in the twin and fires
12
12
  // the named event about it. Completing a checkout session / paying an invoice is the twin
13
13
  // API's job; this verb answers "my app's webhook handler needs to SEE the event, now."
14
- import { projectResources, type EmitEndpoint, type EmittableEvent, type SynthesizedDelivery, type TwinEmitter } from '@volter/world-core';
14
+ import { projectResources, worldNow, type EmitEndpoint, type EmittableEvent, type SynthesizedDelivery, type TwinEmitter } from '@volter/world-core';
15
15
  import { OBJECT_NAME, PLATFORM_ACCOUNT_ID, TWIN_API_VERSION, view } from './stripe-twin.ts';
16
16
  import { STRIPE_WEBHOOK_FALLBACK_SECRET, generateTestHeaderString } from './stripe-events.ts';
17
17
  import { render } from './stripe-version.ts';
@@ -127,7 +127,7 @@ export const stripeEmitter: TwinEmitter = {
127
127
  const endpointRow = liveEndpointRows(root).find((w) => String(w.id) === endpoint.id || String(w.url) === endpoint.url);
128
128
  const secret = typeof endpointRow?.secret === 'string' && endpointRow.secret ? endpointRow.secret : STRIPE_WEBHOOK_FALLBACK_SECRET;
129
129
 
130
- const created = occurredAt ? Math.floor(Date.parse(occurredAt) / 1000) : Math.floor(Date.now() / 1000);
130
+ const created = occurredAt ? Math.floor(Date.parse(occurredAt) / 1000) : Math.floor(Date.parse(worldNow()) / 1000);
131
131
  // a connected account's object is its event's, as the write path scopes it (stripe-twin.ts afterStripeWrite): the
132
132
  // account itself, or a row kept on its books (`_account`). "Each event for a connected account contains a top-level
133
133
  // `account` property that identifies the connected account" (docs.stripe.com/connect/webhooks).
@@ -1,4 +1,5 @@
1
- import { nodeBuiltin } from '@volter/world-core';
1
+ import { nodeBuiltin, worldNow } from '@volter/world-core';
2
+ import { appDestination, appFetch } from '@volter/world-core/app-route';
2
3
  import { worldEgressRefusal } from '@volter/world-core/network-policy';
3
4
  import vendorEvents from './generated/events.gen.json' with { type: 'json' };
4
5
  import { render } from './stripe-version.ts';
@@ -51,7 +52,8 @@ export function computeStripeSignature(payload: string, secret: string, timestam
51
52
 
52
53
  /** Build a `Stripe-Signature` header value for `payload` (mirrors generateTestHeaderString). */
53
54
  export function generateTestHeaderString(opts: { payload: string; secret: string; timestamp?: number; scheme?: string }): string {
54
- const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);
55
+ // signed at the World's time, as its application reads the time (a moved World clock; the app keeps it too)
56
+ const timestamp = opts.timestamp ?? Math.floor(Date.parse(worldNow()) / 1000);
55
57
  const scheme = opts.scheme ?? 'v1';
56
58
  const signature = computeStripeSignature(opts.payload, opts.secret, timestamp);
57
59
  return `t=${timestamp},${scheme}=${signature}`;
@@ -101,7 +103,7 @@ export function constructEvent(payload: string, header: string, secret: string,
101
103
  throw new StripeSignatureVerificationError('No signatures found matching the expected signature for payload.');
102
104
  }
103
105
  if (opts.tolerance !== undefined) {
104
- const now = opts.now ?? Math.floor(Date.now() / 1000);
106
+ const now = opts.now ?? Math.floor(Date.parse(worldNow()) / 1000);
105
107
  if (now - timestamp > opts.tolerance) {
106
108
  throw new StripeSignatureVerificationError('Timestamp outside the tolerance zone');
107
109
  }
@@ -259,8 +261,9 @@ const httpDelivery: StripeEventDelivery = async (url, event, endpointSecret) =>
259
261
  // the event as its API version renders it (stripe-version.ts)
260
262
  const payload = JSON.stringify(render(event, (event as { api_version?: string }).api_version));
261
263
  const secret = endpointSecret ?? secrets.get(url) ?? STRIPE_WEBHOOK_FALLBACK_SECRET;
262
- // the World's egress rule: an endpoint it refuses is not delivered to, and says so
263
- const refusal = worldEgressRefusal(url);
264
+ // the World's egress rule: an endpoint it refuses is not delivered to, and says so; the application's own hostnames
265
+ // reach it inside the World
266
+ const refusal = appDestination(url) ? null : worldEgressRefusal(url);
264
267
  if (refusal !== null) console.error(`[twin:stripe] webhook delivery DROPPED — ${event.type} ${event.id} -> ${url}: ${refusal}`);
265
268
  return refusal !== null ? undefined : postEvent(url, event, payload, secret);
266
269
  };
@@ -271,7 +274,7 @@ async function postEvent(url: string, event: StripeEvent, payload: string, secre
271
274
  for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
272
275
  try {
273
276
  const header = generateTestHeaderString({ payload, secret });
274
- await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header }, body: payload });
277
+ await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header }, body: payload });
275
278
  return;
276
279
  } catch (error) {
277
280
  last = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
@@ -325,14 +328,15 @@ async function httpAuthRequestDelivery(url: string, event: StripeEvent, secret:
325
328
  // the event as its API version renders it (stripe-version.ts)
326
329
  const payload = JSON.stringify(render(event, (event as { api_version?: string }).api_version));
327
330
  const header = generateTestHeaderString({ payload, secret });
328
- // the World's egress rule: a refused endpoint is an unreachable one (the caller's timeout fallback)
329
- const refusal = worldEgressRefusal(url);
331
+ // the World's egress rule: a refused endpoint is an unreachable one (the caller's timeout fallback); the
332
+ // application's own hostnames reach it inside the World
333
+ const refusal = appDestination(url) ? null : worldEgressRefusal(url);
330
334
  return refusal !== null ? Promise.reject(new Error(refusal)) : postAuthRequest(url, payload, header);
331
335
  }
332
336
 
333
337
  /** The HTTP POST of a real-time authorization request, cut off at the window, and the endpoint's answer. */
334
338
  async function postAuthRequest(url: string, payload: string, header: string): Promise<StripeWebhookEndpointResponse> {
335
- const res = await fetch(url, {
339
+ const res = await appFetch(url, {
336
340
  method: 'POST',
337
341
  headers: { 'content-type': 'application/json', 'stripe-signature': header },
338
342
  body: payload,
@@ -15,6 +15,8 @@ import { stripeIdentityFlow } from './screens/identity.tsx';
15
15
  import { stripeOnboardingFlow } from './screens/onboarding.tsx';
16
16
  import { stripePortalFlow } from './screens/portal.tsx';
17
17
  import { stripePublicDetailsFlow } from './screens/public-details.tsx';
18
+ import { connectionRevoked, oauthKeyAccount, redactKey, stripeConnectOAuthFlow } from './screens/connect-oauth.tsx';
19
+ import { stripeConnectSettingsFlow } from './screens/connect-settings.tsx';
18
20
  import surface from './generated/surface.gen.json' with { type: 'json' };
19
21
  import { manifest } from './manifest.ts';
20
22
  import { appsSecrets } from './semantics/apps-secrets.ts';
@@ -112,6 +114,34 @@ async function publishableKeyRefused(call: DerivedCall, scope: { root?: string;
112
114
  return ctx.get(scoped.type, id)?.client_secret === secret ? undefined : wrongClientSecret(scoped, id);
113
115
  }
114
116
 
117
+ // ── an account whose OAuth connection was revoked ──
118
+ // "After revocation, the account can't be accessed by your platform in the Dashboard or through the API"
119
+ // (docs.stripe.com/connect/oauth-reference, deauthorize; so too a connection revoked by a reused code). A call made as it
120
+ // (the Stripe-Account header) or naming it (/v1/accounts/{account}…) is refused with the error Stripe documents for a
121
+ // Stripe-Account the key cannot use: "account_invalid | The account ID provided as a value for the Stripe-Account header
122
+ // is invalid" (docs.stripe.com/error-codes), as 403, "The API key doesn't have permissions to perform the request"
123
+ // (docs.stripe.com/api/errors). Where the documentation stops and the twin decides: the message's wording; an account
124
+ // the platform created itself, or never connected by OAuth, is not affected.
125
+ async function revokedAccountRefused(call: DerivedCall, scope: { root?: string; clock?: () => string }): Promise<Response | undefined> {
126
+ const named = [call.request.headers.get('stripe-account') ?? undefined, call.operation.path.startsWith('/v1/accounts/{account}') ? call.params.account : undefined].filter((a): a is string => !!a);
127
+ if (!named.length) return undefined;
128
+ const ctx = await semanticsContext(manifest, new Request(call.request.url), call.operation, scope); // the rows only: no body read
129
+ const revoked = named.find((a) => connectionRevoked(ctx, a));
130
+ if (!revoked) return undefined;
131
+ const key = requestKey(call.request);
132
+ return vendorError(manifest, { status: 403, code: 'account_invalid', message: `The provided key '${redactKey(key)}' does not have access to account '${revoked}' (or that account does not exist). Application access may have been revoked.` });
133
+ }
134
+
135
+ /** The API key a request carries: Bearer, or Basic's user (`curl -u sk_…:`, docs.stripe.com/api/authentication). */
136
+ function requestKey(request: Request): string {
137
+ const header = request.headers.get('authorization') ?? '';
138
+ const bearer = /^bearer\s+(\S+)/i.exec(header)?.[1];
139
+ if (bearer) return bearer;
140
+ const basic = /^basic\s+(\S+)/i.exec(header)?.[1];
141
+ if (basic) { try { return atob(basic).split(':')[0] ?? ''; } catch { return ''; } }
142
+ return '';
143
+ }
144
+
115
145
  /** A client-secret call whose secret is not the object's: answered as though the key could not see it (the twin's
116
146
  * decision, above). */
117
147
  function wrongClientSecret(scoped: { type: string; param: string }, id: string): Response {
@@ -151,14 +181,16 @@ export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch
151
181
  core,
152
182
  // after the credential and the version, a publishable key is held to the client-side calls, then a request's
153
183
  // parameters are checked against the operation's (stripe-params.ts)
154
- around: (call, next) => guard(call, async () => (await publishableKeyRefused(call, scope)) ?? (await refuseParameters(call)) ?? next()),
184
+ around: (call, next) => guard(call, async () => (await publishableKeyRefused(call, scope)) ?? (await revokedAccountRefused(call, scope)) ?? (await refuseParameters(call)) ?? next()),
155
185
  gap: (request) => vendorError(manifest, { status: 404, message: `Unrecognized request URL (${request.method}: ${new URL(request.url).pathname}).` }),
156
186
  });
157
187
  // the hosted flows sit beside the API: checkout.stripe.com's payment page, billing.stripe.com's customer portal,
158
- // connect.stripe.com's onboarding, verify.stripe.com's identity check and the bank-linking flow Stripe.js opens, and
159
- // the Dashboard's Public details page (dashboard.stripe.com/settings/public, the platform's customer-facing name);
188
+ // connect.stripe.com's onboarding and its OAuth endpoints (screens/connect-oauth.tsx), verify.stripe.com's identity
189
+ // check and the bank-linking flow Stripe.js opens, and the Dashboard's Public details page
190
+ // (dashboard.stripe.com/settings/public, the platform's customer-facing name) and Connect OAuth settings
191
+ // (dashboard.stripe.com/settings/connect/onboarding-options/oauth: the client_id, OAuth on, the redirect URIs);
160
192
  // a read-only twin takes no payments and moves nothing
161
- const flows = [stripeCheckoutFlow(scope), stripePortalFlow(scope), stripeOnboardingFlow(scope), stripeIdentityFlow(scope), stripeFinancialConnectionsFlow(scope), stripePublicDetailsFlow(scope)];
193
+ const flows = [stripeCheckoutFlow(scope), stripePortalFlow(scope), stripeOnboardingFlow(scope), stripeIdentityFlow(scope), stripeFinancialConnectionsFlow(scope), stripePublicDetailsFlow(scope), stripeConnectOAuthFlow(scope), stripeConnectSettingsFlow(scope)];
162
194
  // the twin's own doors sit in front of the API: discovery, and what stands in for an act Stripe's API
163
195
  // does not have (stripe-twin.ts)
164
196
  // GET /twin: what this twin is (not Stripe's; a host reads it)
@@ -238,8 +270,31 @@ export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch
238
270
  if (library) return library;
239
271
  await catchUp(request);
240
272
  if (!readOnly) for (const flow of flows) { const page = await flow(request); if (page) return page; }
273
+ const acting = await actingAsOAuthKey(request);
274
+ if (acting instanceof Response) return acting;
241
275
  // an update's metadata is merged into what the object holds before the handler or the core serves it (mergeMetadata)
242
- return derived(readOnly ? request : await mergeMetadata(request, (op) => !(op.id in handlerMap) && core.owns(op), scope));
276
+ return derived(readOnly ? acting : await mergeMetadata(acting, (op) => !(op.id in handlerMap) && core.owns(op), scope));
277
+ };
278
+ // A connected account's OAuth keys (screens/connect-oauth.tsx): the token endpoint's access_token is "Use the
279
+ // Stripe-Account header with your platform's secret key (that can make requests on behalf of this Stripe account)" and
280
+ // its stripe_publishable_key the same with the publishable key (docs.stripe.com/connect/oauth-reference): a request made
281
+ // with one acts as that account, as a request with the Stripe-Account header does. A key no live connection holds (its
282
+ // connection revoked, or replaced by a refresh: "Any existing access token with the same scope and mode ... is
283
+ // revoked") is Stripe's unknown key, 401 "Invalid API Key provided" (docs.stripe.com/api/authentication, docs.stripe.com
284
+ // /error-codes); one sent with a Stripe-Account header naming another account cannot act as it (account_invalid).
285
+ // Where the documentation stops and the twin decides: the messages' wording; a read_only key is not held to reads
286
+ // (manifest todo stripe.connect.oauth_scope_enforcement).
287
+ const actingAsOAuthKey = async (request: Request): Promise<Request | Response> => {
288
+ const key = requestKey(request);
289
+ if (!/^(sk|pk)_test_oauth_/.test(key)) return request;
290
+ const held = oauthKeyAccount(await semanticsContext(manifest, new Request(request.url), billingClock, scope), key);
291
+ if (!held) return vendorError(manifest, { status: 401, message: `Invalid API Key provided: ${redactKey(key)}` });
292
+ const named = request.headers.get('stripe-account');
293
+ if (named && named !== held.account) return vendorError(manifest, { status: 403, code: 'account_invalid', message: `The provided key '${redactKey(key)}' does not have access to account '${named}' (or that account does not exist). Application access may have been revoked.` });
294
+ const headers = new Headers(request.headers);
295
+ headers.set('stripe-account', held.account);
296
+ const bodied = request.method !== 'GET' && request.method !== 'HEAD';
297
+ return new Request(request.url, { method: request.method, headers, ...(bodied ? { body: await request.arrayBuffer() } : {}) });
243
298
  };
244
299
  // every answer in the shape of the API version the caller is served (stripe-version.ts)
245
300
  const rendered = async (request: Request): Promise<Response> => {
@@ -13,6 +13,9 @@ export type StripeRow = Record<string, any>;
13
13
  * Dashboard to differentiate between accounts", the same object page), and with neither to "Twin Inc.", the name the
14
14
  * Dashboard mirror has always given the World's own account. */
15
15
  export const PLATFORM_DEFAULT_NAME = 'Twin Inc.';
16
+ /** The bookkeeping type of a connected account's Connect OAuth connection (screens/connect-oauth.tsx): one row per
17
+ * account, `revoked: true` once deauthorized or its code reused. Read by the account list and the event path too. */
18
+ export const OAUTH_CONNECTIONS = '_oauth_connection';
16
19
  export function publicBusinessName(account: StripeRow | undefined): string {
17
20
  const named = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v : undefined);
18
21
  return named(account?.business_profile?.name) ?? named(account?.settings?.dashboard?.display_name) ?? PLATFORM_DEFAULT_NAME;
@@ -15,6 +15,7 @@ import { emitStripeEvent, eventTypeFor, type StripeWebhookTarget } from './strip
15
15
  import { SERVED_VERSION } from './stripe-version.ts';
16
16
  import { planOf } from './semantics/plans.ts';
17
17
  import { platformAccountDefault } from './semantics/connect.ts';
18
+ import { OAUTH_CONNECTIONS } from './stripe-shared.ts';
18
19
 
19
20
  const SERVICE = 'stripe';
20
21
 
@@ -483,6 +484,10 @@ export async function afterStripeWrite(type: string, op: string, out: Record<str
483
484
  const id = eventType ? nextStripeEventId(root) : undefined;
484
485
  // stored before it is delivered: a consumer that looks its webhook's event up finds it
485
486
  if (eventType) await persistStripeEvent(eventType, object, root, occurredAt, apiVersion, account, id);
487
+ // a connected account whose OAuth connection was revoked "can't be accessed by your platform" (docs.stripe.com/connect/
488
+ // oauth-reference): its events (a payout time makes, a renewal) are its own and no longer reach the platform's Connect
489
+ // endpoints, except the account.application.deauthorized that says so (screens/connect-oauth.tsx)
490
+ if (account && eventType !== 'account.application.deauthorized' && rows(OAUTH_CONNECTIONS, root).some((c) => (c as Record<string, unknown>).id === account && (c as Record<string, unknown>).revoked === true)) return;
486
491
  await emitStripeEvent(op, object, { occurredAt: occurredAt ?? '1970-01-01T00:00:00.000Z', endpoints: webhookTargets(root), ...(account ? { account } : {}), ...(id ? { id } : {}) });
487
492
  }
488
493
 
@@ -1250,7 +1255,7 @@ export async function handleStripeTwinRequest(req: StripeRequest): Promise<Strip
1250
1255
  // a GET carries its parameters in the query; a caller that handed them as a body keeps them
1251
1256
  let path = req.path.startsWith('/') ? req.path : `/${req.path}`;
1252
1257
  if ((method === 'GET' || method === 'HEAD') && req.body) path += (path.includes('?') ? '&' : '?') + req.body;
1253
- const response = await twin(new Request(`https://api.stripe.com${path}`, { method, headers, ...(method !== 'GET' && method !== 'HEAD' && req.body ? { body: req.body } : {}) }));
1258
+ const response = await twin(new Request(`http://stripe.test${path}`, { method, headers, ...(method !== 'GET' && method !== 'HEAD' && req.body ? { body: req.body } : {}) }));
1254
1259
  const text = await response.text();
1255
1260
  return { status: response.status, body: text ? JSON.parse(text) : null };
1256
1261
  }
@@ -228,6 +228,9 @@ const INCLUDABLE: Record<string, string[]> = {
228
228
  invoice: ['confirmation_secret'],
229
229
  };
230
230
 
231
+ /** Every includable field name, as `expand` paths on a top-level object: what a check of an object's FULL answer expands. */
232
+ export const INCLUDABLE_FIELDS: string[] = [...new Set(Object.values(INCLUDABLE).flat())];
233
+
231
234
  /** The `expand[]` paths a request names, in its query or its body (JSON or form). */
232
235
  export async function expandOf(request: Request): Promise<string[]> {
233
236
  const url = new URL(request.url);
@@ -100,6 +100,11 @@
100
100
  "path": "price",
101
101
  "kind": "extra",
102
102
  "reason": "PEAK-3102 round 2: POST /v1/invoiceitems now stores the caller-supplied `price` id on the invoiceitem (needed to resolve amount = unit_amount * quantity for PeakHealth's catalog-product order path). Real Stripe replaced the top-level InvoiceItem.price field with a `pricing.price_details` object in a later API-version migration — stripe-schemas.json (LATEST published shape) reflects the post-migration object and has no price property, hence 'extra' under the harness's single-snapshot schema, same mechanism as current_period_start/_end and payment_intent above. This twin's default-served TWIN_API_VERSION ('2024-06-20') pre-dates that migration, where real Stripe DOES emit invoiceitem.price top-level, so emitting it here is faithful to what this twin actually serves by default."
103
+ },
104
+ {
105
+ "path": "net_amount",
106
+ "kind": "type",
107
+ "reason": "invoiceitem.net_amount is typed a non-nullable integer in the served spec, but its own description says \"This field is `null` for `discountable=true` items\" (served spec, invoiceitem.net_amount). The twin follows the description: a discountable item answers null. The spec's type and its description disagree; this declares the twin's side."
103
108
  }
104
- ]
109
+ ]
105
110
  }
@@ -297,6 +297,10 @@
297
297
  "status"
298
298
  ],
299
299
  "properties": {
300
+ "allowed_payment_method_types": {
301
+ "type": "array",
302
+ "nullable": true
303
+ },
300
304
  "amount": {
301
305
  "type": "integer"
302
306
  },
@@ -419,6 +423,9 @@
419
423
  "payment_method_types": {
420
424
  "type": "array"
421
425
  },
426
+ "payment_record": {
427
+ "nullable": true
428
+ },
422
429
  "presentment_details": {},
423
430
  "processing": {
424
431
  "nullable": true
@@ -495,6 +502,13 @@
495
502
  "currency": {
496
503
  "type": "string"
497
504
  },
505
+ "customer": {
506
+ "nullable": true
507
+ },
508
+ "customer_account": {
509
+ "type": "string",
510
+ "nullable": true
511
+ },
498
512
  "description": {
499
513
  "type": "string"
500
514
  },
@@ -523,6 +537,9 @@
523
537
  "payment_intent": {
524
538
  "nullable": true
525
539
  },
540
+ "payment_method": {
541
+ "nullable": true
542
+ },
526
543
  "pending_reason": {
527
544
  "enum": [
528
545
  "charge_pending",
@@ -726,6 +743,7 @@
726
743
  ],
727
744
  "type": "string"
728
745
  },
746
+ "status_details": {},
729
747
  "test_clock": {
730
748
  "nullable": true
731
749
  },
@@ -757,6 +775,10 @@
757
775
  "usage"
758
776
  ],
759
777
  "properties": {
778
+ "allowed_payment_method_types": {
779
+ "type": "array",
780
+ "nullable": true
781
+ },
760
782
  "application": {
761
783
  "nullable": true
762
784
  },
@@ -953,6 +975,7 @@
953
975
  "payco": {},
954
976
  "paynow": {},
955
977
  "paypal": {},
978
+ "paypay": {},
956
979
  "payto": {},
957
980
  "pix": {},
958
981
  "promptpay": {},
@@ -962,6 +985,7 @@
962
985
  "satispay": {},
963
986
  "scalapay": {},
964
987
  "sepa_debit": {},
988
+ "sequra": {},
965
989
  "sofort": {},
966
990
  "sunbit": {},
967
991
  "swish": {},
@@ -1204,6 +1228,9 @@
1204
1228
  "tax_code": {
1205
1229
  "nullable": true
1206
1230
  },
1231
+ "tax_details": {
1232
+ "nullable": true
1233
+ },
1207
1234
  "unit_label": {
1208
1235
  "type": "string",
1209
1236
  "nullable": true
@@ -1500,6 +1527,7 @@
1500
1527
  "type": "string",
1501
1528
  "nullable": true
1502
1529
  },
1530
+ "status_details": {},
1503
1531
  "status_transitions": {},
1504
1532
  "subtotal": {
1505
1533
  "type": "integer"
@@ -1579,12 +1607,18 @@
1579
1607
  "type": "array",
1580
1608
  "nullable": true
1581
1609
  },
1610
+ "frozen_fields": {
1611
+ "type": "array"
1612
+ },
1582
1613
  "id": {
1583
1614
  "type": "string"
1584
1615
  },
1585
1616
  "invoice": {
1586
1617
  "nullable": true
1587
1618
  },
1619
+ "invoicing_rules": {
1620
+ "type": "array"
1621
+ },
1588
1622
  "livemode": {
1589
1623
  "type": "boolean"
1590
1624
  },
@@ -2045,6 +2079,10 @@
2045
2079
  "type": "boolean",
2046
2080
  "nullable": true
2047
2081
  },
2082
+ "allowed_payment_method_types": {
2083
+ "type": "array",
2084
+ "nullable": true
2085
+ },
2048
2086
  "amount_subtotal": {
2049
2087
  "type": "integer",
2050
2088
  "nullable": true
@@ -2575,7 +2613,12 @@
2575
2613
  },
2576
2614
  "livemode": {
2577
2615
  "type": "boolean"
2578
- }
2616
+ },
2617
+ "company": {},
2618
+ "groups": {
2619
+ "nullable": true
2620
+ },
2621
+ "individual": {}
2579
2622
  }
2580
2623
  },
2581
2624
  "transfer": {
@@ -2583,7 +2626,6 @@
2583
2626
  "required": [
2584
2627
  "amount",
2585
2628
  "amount_reversed",
2586
- "balance_transaction",
2587
2629
  "created",
2588
2630
  "currency",
2589
2631
  "id",
@@ -2645,13 +2687,13 @@
2645
2687
  "tax_rate": {
2646
2688
  "type": "object",
2647
2689
  "required": [
2648
- "id",
2649
- "object",
2650
2690
  "active",
2651
2691
  "created",
2652
2692
  "display_name",
2693
+ "id",
2653
2694
  "inclusive",
2654
2695
  "livemode",
2696
+ "object",
2655
2697
  "percentage"
2656
2698
  ],
2657
2699
  "properties": {
@@ -2709,20 +2751,36 @@
2709
2751
  "tax_type": {
2710
2752
  "type": "string",
2711
2753
  "nullable": true
2754
+ },
2755
+ "effective_percentage": {
2756
+ "type": "number",
2757
+ "nullable": true
2758
+ },
2759
+ "flat_amount": {
2760
+ "nullable": true
2761
+ },
2762
+ "rate_type": {
2763
+ "enum": [
2764
+ "flat_amount",
2765
+ "percentage"
2766
+ ],
2767
+ "type": "string",
2768
+ "nullable": true
2712
2769
  }
2713
2770
  }
2714
2771
  },
2715
2772
  "tax.calculation": {
2716
2773
  "type": "object",
2717
2774
  "required": [
2718
- "object",
2719
2775
  "amount_total",
2720
2776
  "currency",
2721
2777
  "customer_details",
2722
- "line_items",
2778
+ "livemode",
2779
+ "object",
2723
2780
  "tax_amount_exclusive",
2724
2781
  "tax_amount_inclusive",
2725
- "tax_breakdown"
2782
+ "tax_breakdown",
2783
+ "tax_date"
2726
2784
  ],
2727
2785
  "properties": {
2728
2786
  "id": {
@@ -2772,18 +2830,24 @@
2772
2830
  },
2773
2831
  "tax_date": {
2774
2832
  "type": "integer"
2833
+ },
2834
+ "ship_from_details": {
2835
+ "nullable": true
2775
2836
  }
2776
2837
  }
2777
2838
  },
2778
2839
  "tax.calculation_line_item": {
2779
2840
  "type": "object",
2780
2841
  "required": [
2781
- "id",
2782
- "object",
2783
2842
  "amount",
2784
2843
  "amount_tax",
2844
+ "id",
2785
2845
  "livemode",
2786
- "tax_behavior"
2846
+ "object",
2847
+ "quantity",
2848
+ "reference",
2849
+ "tax_behavior",
2850
+ "tax_code"
2787
2851
  ],
2788
2852
  "properties": {
2789
2853
  "id": {
@@ -2827,18 +2891,27 @@
2827
2891
  },
2828
2892
  "tax_code": {
2829
2893
  "type": "string"
2894
+ },
2895
+ "metadata": {
2896
+ "type": "object",
2897
+ "nullable": true
2898
+ },
2899
+ "performance_location": {
2900
+ "type": "string",
2901
+ "nullable": true
2830
2902
  }
2831
2903
  }
2832
2904
  },
2833
2905
  "tax.registration": {
2834
2906
  "type": "object",
2835
2907
  "required": [
2836
- "id",
2837
- "object",
2838
2908
  "active_from",
2839
2909
  "country",
2840
2910
  "country_options",
2911
+ "created",
2912
+ "id",
2841
2913
  "livemode",
2914
+ "object",
2842
2915
  "status"
2843
2916
  ],
2844
2917
  "properties": {