@volter/twin-stripe 0.1.2 → 2.0.0
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 +64 -27
- package/client/dashboard-api.ts +286 -0
- package/client/stripe-mirror.css +272 -159
- package/client/stripe-mirror.tsx +1384 -541
- package/dist/client/dashboard-api.d.ts +107 -0
- package/dist/client/dashboard-api.js +238 -0
- package/dist/client/dashboard-api.ts +286 -0
- package/dist/client/stripe-mirror.bundle.js +236 -0
- package/dist/client/stripe-mirror.css +275 -0
- package/dist/client/stripe-mirror.d.ts +134 -0
- package/dist/client/stripe-mirror.js +823 -0
- package/dist/client/stripe-mirror.tsx +1534 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/generated/events.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +73 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1065 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +241 -0
- package/dist/src/screens/consent-skin.d.ts +4 -0
- package/dist/src/screens/consent-skin.js +18 -0
- package/dist/src/screens/financial-connections.d.ts +5 -0
- package/dist/src/screens/financial-connections.js +90 -0
- package/dist/src/screens/identity.d.ts +5 -0
- package/dist/src/screens/identity.js +86 -0
- package/dist/src/screens/industries.d.ts +1 -0
- package/dist/src/screens/industries.js +267 -0
- package/dist/src/screens/onboarding.d.ts +13 -0
- package/dist/src/screens/onboarding.js +225 -0
- package/dist/src/screens/portal.d.ts +5 -0
- package/dist/src/screens/portal.js +214 -0
- package/dist/src/screens/public-details.d.ts +5 -0
- package/dist/src/screens/public-details.js +90 -0
- package/dist/src/semantics/after-payment.d.ts +22 -0
- package/dist/src/semantics/after-payment.js +93 -0
- package/dist/src/semantics/apps-secrets.d.ts +2 -0
- package/dist/src/semantics/apps-secrets.js +54 -0
- package/dist/src/semantics/balance.d.ts +11 -0
- package/dist/src/semantics/balance.js +195 -0
- package/dist/src/semantics/billing.d.ts +2 -0
- package/dist/src/semantics/billing.js +220 -0
- package/dist/src/semantics/charges.d.ts +28 -0
- package/dist/src/semantics/charges.js +201 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +303 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +476 -0
- package/dist/src/semantics/coupons.d.ts +6 -0
- package/dist/src/semantics/coupons.js +92 -0
- package/dist/src/semantics/credit-notes.d.ts +2 -0
- package/dist/src/semantics/credit-notes.js +172 -0
- package/dist/src/semantics/customers.d.ts +6 -0
- package/dist/src/semantics/customers.js +429 -0
- package/dist/src/semantics/disputes.d.ts +2 -0
- package/dist/src/semantics/disputes.js +51 -0
- package/dist/src/semantics/entitlements.d.ts +2 -0
- package/dist/src/semantics/entitlements.js +95 -0
- package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
- package/dist/src/semantics/ephemeral-keys.js +34 -0
- package/dist/src/semantics/files.d.ts +2 -0
- package/dist/src/semantics/files.js +125 -0
- package/dist/src/semantics/invoices.d.ts +18 -0
- package/dist/src/semantics/invoices.js +541 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +570 -0
- package/dist/src/semantics/ledger.d.ts +54 -0
- package/dist/src/semantics/ledger.js +181 -0
- package/dist/src/semantics/payment-intents.d.ts +18 -0
- package/dist/src/semantics/payment-intents.js +404 -0
- package/dist/src/semantics/payment-links.d.ts +2 -0
- package/dist/src/semantics/payment-links.js +133 -0
- package/dist/src/semantics/payment-methods.d.ts +20 -0
- package/dist/src/semantics/payment-methods.js +138 -0
- package/dist/src/semantics/plans.d.ts +5 -0
- package/dist/src/semantics/plans.js +121 -0
- package/dist/src/semantics/platform.d.ts +9 -0
- package/dist/src/semantics/platform.js +206 -0
- package/dist/src/semantics/products.d.ts +2 -0
- package/dist/src/semantics/products.js +140 -0
- package/dist/src/semantics/radar.d.ts +2 -0
- package/dist/src/semantics/radar.js +83 -0
- package/dist/src/semantics/refunds.d.ts +9 -0
- package/dist/src/semantics/refunds.js +195 -0
- package/dist/src/semantics/renewals.d.ts +47 -0
- package/dist/src/semantics/renewals.js +251 -0
- package/dist/src/semantics/setup-intents.d.ts +2 -0
- package/dist/src/semantics/setup-intents.js +84 -0
- package/dist/src/semantics/shared.d.ts +78 -0
- package/dist/src/semantics/shared.js +192 -0
- package/dist/src/semantics/subscription-schedules.d.ts +2 -0
- package/dist/src/semantics/subscription-schedules.js +119 -0
- package/dist/src/semantics/subscriptions.d.ts +11 -0
- package/dist/src/semantics/subscriptions.js +605 -0
- package/dist/src/semantics/tax.d.ts +2 -0
- package/dist/src/semantics/tax.js +197 -0
- package/dist/src/semantics/terminal.d.ts +5 -0
- package/dist/src/semantics/terminal.js +182 -0
- package/dist/src/semantics/test-clocks.d.ts +6 -0
- package/dist/src/semantics/test-clocks.js +73 -0
- package/dist/src/semantics/tokens.d.ts +4 -0
- package/dist/src/semantics/tokens.js +44 -0
- package/dist/src/semantics/transfers.d.ts +2 -0
- package/dist/src/semantics/transfers.js +154 -0
- package/dist/src/semantics/treasury.d.ts +2 -0
- package/dist/src/semantics/treasury.js +377 -0
- package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
- package/dist/src/semantics/webhook-endpoints.js +85 -0
- package/dist/src/stripe-budget.d.ts +55 -0
- package/dist/src/stripe-budget.js +155 -0
- package/dist/src/stripe-capabilities.d.ts +3 -0
- package/dist/src/stripe-capabilities.js +5052 -0
- package/dist/src/stripe-conformance.d.ts +41 -0
- package/dist/src/stripe-conformance.js +96 -0
- package/dist/src/stripe-connector.d.ts +161 -0
- package/dist/src/stripe-connector.js +414 -0
- package/dist/src/stripe-emit.d.ts +2 -0
- package/dist/src/stripe-emit.js +145 -0
- package/dist/src/stripe-events.d.ts +93 -0
- package/dist/src/stripe-events.js +388 -0
- package/dist/src/stripe-js.d.ts +4 -0
- package/dist/src/stripe-js.js +70 -0
- package/dist/src/stripe-mirror-ui.d.ts +15 -0
- package/dist/src/stripe-mirror-ui.js +87 -0
- package/dist/src/stripe-params.d.ts +3 -0
- package/dist/src/stripe-params.js +43 -0
- package/dist/src/stripe-perform-harness.d.ts +9 -0
- package/dist/src/stripe-perform-harness.js +26 -0
- package/dist/src/stripe-server.d.ts +33 -0
- package/dist/src/stripe-server.js +326 -0
- package/dist/src/stripe-shared.d.ts +106 -0
- package/dist/src/stripe-shared.js +273 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1226 -0
- package/dist/src/stripe-ui-conformance.d.ts +5 -0
- package/dist/src/stripe-ui-conformance.js +79 -0
- package/dist/src/stripe-ui-structure.d.ts +3 -0
- package/dist/src/stripe-ui-structure.js +168 -0
- package/dist/src/stripe-version.d.ts +10 -0
- package/dist/src/stripe-version.js +285 -0
- package/dist/test-fixtures/stripe-known-deviations.json +105 -0
- package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
- package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
- package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
- package/dist/test-fixtures/stripe-schemas.json +3740 -0
- package/package.json +18 -10
- package/src/cli.ts +7 -7
- package/src/generated/events.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +31 -9
- package/src/manifest.ts +1097 -0
- package/src/screens/checkout.tsx +252 -0
- package/src/screens/consent-skin.ts +20 -0
- package/src/screens/financial-connections.tsx +101 -0
- package/src/screens/identity.tsx +96 -0
- package/src/screens/industries.ts +267 -0
- package/src/screens/onboarding.tsx +243 -0
- package/src/screens/portal.tsx +218 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +113 -0
- package/src/semantics/apps-secrets.ts +58 -0
- package/src/semantics/balance.ts +209 -0
- package/src/semantics/billing.ts +216 -0
- package/src/semantics/charges.ts +211 -0
- package/src/semantics/checkout.ts +297 -0
- package/src/semantics/connect.ts +471 -0
- package/src/semantics/coupons.ts +97 -0
- package/src/semantics/credit-notes.ts +168 -0
- package/src/semantics/customers.ts +432 -0
- package/src/semantics/disputes.ts +62 -0
- package/src/semantics/entitlements.ts +94 -0
- package/src/semantics/ephemeral-keys.ts +34 -0
- package/src/semantics/files.ts +143 -0
- package/src/semantics/invoices.ts +541 -0
- package/src/semantics/issuing.ts +585 -0
- package/src/semantics/ledger.ts +216 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +143 -0
- package/src/semantics/plans.ts +131 -0
- package/src/semantics/platform.ts +220 -0
- package/src/semantics/products.ts +154 -0
- package/src/semantics/radar.ts +85 -0
- package/src/semantics/refunds.ts +218 -0
- package/src/semantics/renewals.ts +274 -0
- package/src/semantics/setup-intents.ts +87 -0
- package/src/semantics/shared.ts +215 -0
- package/src/semantics/subscription-schedules.ts +129 -0
- package/src/semantics/subscriptions.ts +610 -0
- package/src/semantics/tax.ts +220 -0
- package/src/semantics/terminal.ts +195 -0
- package/src/semantics/test-clocks.ts +77 -0
- package/src/semantics/tokens.ts +52 -0
- package/src/semantics/transfers.ts +174 -0
- package/src/semantics/treasury.ts +383 -0
- package/src/semantics/webhook-endpoints.ts +87 -0
- package/src/stripe-budget.ts +4 -4
- package/src/stripe-capabilities.ts +1456 -222
- package/src/stripe-conformance.ts +6 -5
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +14 -7
- package/src/stripe-events.ts +94 -36
- package/src/stripe-js.ts +70 -0
- package/src/stripe-mirror-ui.ts +28 -298
- package/src/stripe-params.ts +44 -0
- package/src/stripe-perform-harness.ts +29 -0
- package/src/stripe-server.ts +263 -38
- package/src/stripe-shared.ts +294 -0
- package/src/stripe-twin.ts +429 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +278 -0
- package/test-fixtures/stripe-known-deviations.json +2 -7
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/src/stripe-form.ts +0 -35
package/src/stripe-server.ts
CHANGED
|
@@ -7,18 +7,121 @@
|
|
|
7
7
|
// The surface is a plain `fetch` (`createStripeTwinFetch`) and the SERVER is one line of
|
|
8
8
|
// `Bun.serve` around it — see that factory's docstring for why (a serverless entry has no
|
|
9
9
|
// port to bind, so it mounts the fetch in-process).
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { bindSemantics, coreFor, createDerivedFetch, crossCutting, readParams, semanticsContext, serveHttp, vendorError, type DerivedCall, type DerivedFetch } from '@volter/world-core';
|
|
11
|
+
import { stripeCheckoutFlow } from './screens/checkout.tsx';
|
|
12
|
+
import { stripeJs } from './stripe-js.ts';
|
|
13
|
+
import { stripeFinancialConnectionsFlow } from './screens/financial-connections.tsx';
|
|
14
|
+
import { stripeIdentityFlow } from './screens/identity.tsx';
|
|
15
|
+
import { stripeOnboardingFlow } from './screens/onboarding.tsx';
|
|
16
|
+
import { stripePortalFlow } from './screens/portal.tsx';
|
|
17
|
+
import { stripePublicDetailsFlow } from './screens/public-details.tsx';
|
|
18
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
19
|
+
import { manifest } from './manifest.ts';
|
|
20
|
+
import { appsSecrets } from './semantics/apps-secrets.ts';
|
|
21
|
+
import { balances } from './semantics/balance.ts';
|
|
22
|
+
import { billing } from './semantics/billing.ts';
|
|
23
|
+
import { charges } from './semantics/charges.ts';
|
|
24
|
+
import { checkout } from './semantics/checkout.ts';
|
|
25
|
+
import { connect } from './semantics/connect.ts';
|
|
26
|
+
import { coupons } from './semantics/coupons.ts';
|
|
27
|
+
import { creditNotes } from './semantics/credit-notes.ts';
|
|
28
|
+
import { customers } from './semantics/customers.ts';
|
|
29
|
+
import { disputes } from './semantics/disputes.ts';
|
|
30
|
+
import { entitlements } from './semantics/entitlements.ts';
|
|
31
|
+
import { ephemeralKeys } from './semantics/ephemeral-keys.ts';
|
|
32
|
+
import { files } from './semantics/files.ts';
|
|
33
|
+
import { invoices } from './semantics/invoices.ts';
|
|
34
|
+
import { issuing, lapseRealtimeRequests } from './semantics/issuing.ts';
|
|
35
|
+
import { paymentIntents } from './semantics/payment-intents.ts';
|
|
36
|
+
import { paymentLinks } from './semantics/payment-links.ts';
|
|
37
|
+
import { paymentMethods } from './semantics/payment-methods.ts';
|
|
38
|
+
import { platform } from './semantics/platform.ts';
|
|
39
|
+
import { plans } from './semantics/plans.ts';
|
|
40
|
+
import { products } from './semantics/products.ts';
|
|
41
|
+
import { radar } from './semantics/radar.ts';
|
|
42
|
+
import { refunds } from './semantics/refunds.ts';
|
|
43
|
+
import { setupIntents } from './semantics/setup-intents.ts';
|
|
44
|
+
import { subscriptionSchedules } from './semantics/subscription-schedules.ts';
|
|
45
|
+
import { subscriptions } from './semantics/subscriptions.ts';
|
|
46
|
+
import { tax } from './semantics/tax.ts';
|
|
47
|
+
import { terminal } from './semantics/terminal.ts';
|
|
48
|
+
import { testClocks } from './semantics/test-clocks.ts';
|
|
49
|
+
import { ledger } from './semantics/ledger.ts';
|
|
50
|
+
import { tokens } from './semantics/tokens.ts';
|
|
51
|
+
import { transfers } from './semantics/transfers.ts';
|
|
52
|
+
import { treasury } from './semantics/treasury.ts';
|
|
53
|
+
import { webhookEndpoints } from './semantics/webhook-endpoints.ts';
|
|
54
|
+
import { advancePayouts, balanceBody, payDuePayouts } from './semantics/balance.ts';
|
|
55
|
+
import { settleDueEntries } from './semantics/ledger.ts';
|
|
56
|
+
import { settleHeldRefunds } from './semantics/refunds.ts';
|
|
57
|
+
import { settleBankDebits } from './semantics/payment-intents.ts';
|
|
58
|
+
import { lapseCoupons } from './semantics/coupons.ts';
|
|
59
|
+
import { finishClockAdvances } from './semantics/test-clocks.ts';
|
|
60
|
+
import { settleTopups } from './semantics/terminal.ts';
|
|
61
|
+
import { finishReportRuns } from './semantics/platform.ts';
|
|
62
|
+
import { advanceBilling } from './semantics/renewals.ts';
|
|
63
|
+
import { refuseParameters } from './stripe-params.ts';
|
|
64
|
+
import { afterStripeWrite, handleStripeDoor, PLATFORM_ACCOUNT_ID } from './stripe-twin.ts';
|
|
65
|
+
import { worldNow, statefulTwinManifest} from '@volter/world-core';
|
|
66
|
+
import { expandOf, render, servesCurrent, withoutEndpointSecret } from './stripe-version.ts';
|
|
12
67
|
|
|
13
68
|
/** Options shared by the fetch handler and the Bun.serve wrapper around it. `port` is a
|
|
14
69
|
* BIND concern the fetch ignores; it stays in one shape so a caller configures the twin
|
|
15
70
|
* once whichever way it is mounted. */
|
|
16
|
-
export type StripeTwinOptions = { root?: string; port?: number; readOnly?: boolean };
|
|
71
|
+
export type StripeTwinOptions = { root?: string; port?: number; readOnly?: boolean; /** the World instant, when a caller pins it */ clock?: () => string };
|
|
72
|
+
|
|
73
|
+
// ── what a publishable key may call ──
|
|
74
|
+
// "Publishable API key pk_... | Safe to expose: Yes | API key for Stripe.js, Elements, and mobile SDKs. It can identify
|
|
75
|
+
// your account and create tokens or PaymentMethods from payment details, but it can't perform sensitive operations such
|
|
76
|
+
// as creating charges or reading account data" (docs.stripe.com/keys). The served spec names the calls a publishable key
|
|
77
|
+
// makes with an object's client secret: "You can retrieve a PaymentIntent client-side using a publishable key when the
|
|
78
|
+
// client_secret is provided in the query string" (GET /v1/payment_intents/{intent}), "Client-side retrieval using a
|
|
79
|
+
// publishable key is allowed when the client_secret is provided" (GET /v1/setup_intents/{intent}), "Required if a
|
|
80
|
+
// publishable key is used to retrieve the source" (GET /v1/sources/{source}), and the confirm and verify_microdeposits
|
|
81
|
+
// calls that take "The client secret of the PaymentIntent" (or SetupIntent). Anything else is refused with Stripe's
|
|
82
|
+
// code: "secret_key_required | The API key provided is a publishable key, but a secret key is required"
|
|
83
|
+
// (docs.stripe.com/error-codes), as 403, "The API key doesn't have permissions to perform the request"
|
|
84
|
+
// (docs.stripe.com/api/errors).
|
|
85
|
+
// Where the documentation stops and the twin decides: the list above is the whole of what a publishable key may call
|
|
86
|
+
// (Stripe publishes no complete list); a client-secret call without the secret answers parameter_missing, and one with a
|
|
87
|
+
// secret that is not the object's answers the object's 404, as though the key could not see it.
|
|
88
|
+
const PUBLISHABLE_CREATES = new Set(['PostTokens', 'PostPaymentMethods']);
|
|
89
|
+
const BY_CLIENT_SECRET: Record<string, { type: string; param: string }> = {
|
|
90
|
+
GetPaymentIntentsIntent: { type: 'payment_intent', param: 'intent' },
|
|
91
|
+
PostPaymentIntentsIntentConfirm: { type: 'payment_intent', param: 'intent' },
|
|
92
|
+
PostPaymentIntentsIntentVerifyMicrodeposits: { type: 'payment_intent', param: 'intent' },
|
|
93
|
+
GetSetupIntentsIntent: { type: 'setup_intent', param: 'intent' },
|
|
94
|
+
PostSetupIntentsIntentConfirm: { type: 'setup_intent', param: 'intent' },
|
|
95
|
+
PostSetupIntentsIntentVerifyMicrodeposits: { type: 'setup_intent', param: 'intent' },
|
|
96
|
+
GetSourcesSource: { type: 'source', param: 'source' },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Stripe's refusal of a call a publishable key may not make, or undefined when the key may make it. */
|
|
100
|
+
async function publishableKeyRefused(call: DerivedCall, scope: { root?: string; clock?: () => string }): Promise<Response | undefined> {
|
|
101
|
+
const key = /^bearer\s+(\S+)/i.exec(call.request.headers.get('authorization') ?? '')?.[1] ?? '';
|
|
102
|
+
if (!key.startsWith('pk_')) return undefined;
|
|
103
|
+
if (PUBLISHABLE_CREATES.has(call.operation.id)) return undefined;
|
|
104
|
+
const scoped = BY_CLIENT_SECRET[call.operation.id];
|
|
105
|
+
if (!scoped) return vendorError(manifest, { status: 403, code: 'secret_key_required', message: 'The API key provided is a publishable key, but a secret key is required.' });
|
|
106
|
+
const params = await readParams(manifest, call.request.clone(), call.operation);
|
|
107
|
+
const secret = typeof params.client_secret === 'string' ? params.client_secret : '';
|
|
108
|
+
if (!secret) return vendorError(manifest, { status: 400, code: 'parameter_missing', param: 'client_secret', message: 'Missing required param: client_secret.' });
|
|
109
|
+
const id = call.params[scoped.param] ?? '';
|
|
110
|
+
// the context reads a body of its own, so the handler after this still has the request's
|
|
111
|
+
const ctx = await semanticsContext(manifest, call.request.clone(), call.operation, scope);
|
|
112
|
+
return ctx.get(scoped.type, id)?.client_secret === secret ? undefined : wrongClientSecret(scoped, id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A client-secret call whose secret is not the object's: answered as though the key could not see it (the twin's
|
|
116
|
+
* decision, above). */
|
|
117
|
+
function wrongClientSecret(scoped: { type: string; param: string }, id: string): Response {
|
|
118
|
+
return vendorError(manifest, { status: 404, code: 'resource_missing', param: scoped.param, message: `No such ${scoped.type}: '${id}'` });
|
|
119
|
+
}
|
|
17
120
|
|
|
18
121
|
/**
|
|
19
122
|
* The whole Stripe serve path as a plain `(Request) => Response` — the `/twin` manifest
|
|
20
123
|
* door, the header threading (idempotency key, pinned API version, Connect account) and
|
|
21
|
-
* the dispatch
|
|
124
|
+
* the derived dispatch over the hand-written routes. NOTHING about it is port-bound.
|
|
22
125
|
*
|
|
23
126
|
* Why this is the factory and `createStripeTwinServer` is a wrapper (runtime contract R12,
|
|
24
127
|
* the Cloudflare ruling): a Durable Object / Worker entry has no loopback ports — it mounts
|
|
@@ -33,49 +136,171 @@ export type StripeTwinOptions = { root?: string; port?: number; readOnly?: boole
|
|
|
33
136
|
* `finally` fires when `fn` RETURNS, i.e. at the first `await`, restoring the previous store
|
|
34
137
|
* under the rest of the request.
|
|
35
138
|
*/
|
|
36
|
-
export function createStripeTwinFetch(options: StripeTwinOptions):
|
|
139
|
+
export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch {
|
|
37
140
|
const readOnly = options.readOnly ?? false;
|
|
38
|
-
|
|
141
|
+
const scope = { ...(options.root !== undefined ? { root: options.root } : {}), ...(options.clock ? { clock: options.clock } : {}) };
|
|
142
|
+
// The derived dispatch owns the API: an operation with a semantics handler is served by it, one on a
|
|
143
|
+
// resource the manifest declares by the derived core, and one neither models answers Stripe's
|
|
144
|
+
// unrecognized-URL 404.
|
|
145
|
+
const guard = crossCutting(manifest, { readOnly, ...scope });
|
|
146
|
+
const handlerMap = { ...billing, ...paymentIntents, ...customers, ...products, ...plans, ...charges, ...refunds, ...setupIntents, ...paymentMethods, ...subscriptions, ...invoices, ...checkout, ...ephemeralKeys, ...disputes, ...balances, ...coupons, ...creditNotes, ...subscriptionSchedules, ...entitlements, ...testClocks, ...webhookEndpoints, ...connect, ...transfers, ...appsSecrets, ...tokens, ...files, ...tax, ...paymentLinks, ...radar, ...issuing, ...terminal, ...treasury, ...platform, ...ledger };
|
|
147
|
+
const core = coreFor(manifest, scope);
|
|
148
|
+
const derived = createDerivedFetch({
|
|
149
|
+
surface,
|
|
150
|
+
handlers: bindSemantics(manifest, handlerMap, scope),
|
|
151
|
+
core,
|
|
152
|
+
// after the credential and the version, a publishable key is held to the client-side calls, then a request's
|
|
153
|
+
// 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()),
|
|
155
|
+
gap: (request) => vendorError(manifest, { status: 404, message: `Unrecognized request URL (${request.method}: ${new URL(request.url).pathname}).` }),
|
|
156
|
+
});
|
|
157
|
+
// 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);
|
|
160
|
+
// 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)];
|
|
162
|
+
// the twin's own doors sit in front of the API: discovery, and what stands in for an act Stripe's API
|
|
163
|
+
// does not have (stripe-twin.ts)
|
|
164
|
+
// GET /twin: what this twin is (not Stripe's; a host reads it)
|
|
165
|
+
function discovery(): Response {
|
|
166
|
+
return Response.json(statefulTwinManifest({ vendor: 'stripe', twinOf: 'the Stripe REST API', stores: 'customers, payment objects, checkout sessions, products/prices and webhook endpoints (signed events)' }));
|
|
167
|
+
}
|
|
168
|
+
// /_twin/…: a door's body is read as the API's is: a form, or JSON
|
|
169
|
+
const twinDoor = async (request: Request, url: URL): Promise<Response | undefined> => {
|
|
170
|
+
const answer = await handleStripeDoor({
|
|
171
|
+
method: request.method, path: url.pathname, readOnly,
|
|
172
|
+
occurredAt: options.clock ? options.clock() : worldNow(),
|
|
173
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
174
|
+
}, await readParams(manifest, request));
|
|
175
|
+
return answer && Response.json(answer.body, { status: answer.status, headers: { 'request-id': 'req_twin' } });
|
|
176
|
+
};
|
|
177
|
+
const door = async (request: Request): Promise<Response | undefined> => {
|
|
39
178
|
const url = new URL(request.url);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
179
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') return discovery();
|
|
180
|
+
return url.pathname.startsWith('/_twin/') ? twinDoor(request, url) : undefined;
|
|
181
|
+
};
|
|
182
|
+
// time's moves Stripe makes on its own (a subscription renewing at its period's end, its renewal charged an hour
|
|
183
|
+
// later: semantics/renewals.ts; each account's automatic payouts: semantics/balance.ts) are caught up to the World's clock before anything is answered, so every door
|
|
184
|
+
// (the API, the hosted pages) reads the account as it stands now
|
|
185
|
+
const billingClock = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'GetSubscriptions')!;
|
|
186
|
+
const catchUp = async (request: Request): Promise<void> => {
|
|
187
|
+
if (readOnly) return;
|
|
188
|
+
const ctx = await semanticsContext(manifest, new Request(request.url), billingClock, scope);
|
|
189
|
+
// a test clock advanced by the last request has reached its time (semantics/test-clocks.ts)
|
|
190
|
+
await finishClockAdvances(ctx);
|
|
191
|
+
await advanceBilling(ctx);
|
|
192
|
+
// a submitted bank debit settles (in test mode at once) before anything is answered (semantics/payment-intents.ts)
|
|
193
|
+
await settleBankDebits(ctx);
|
|
194
|
+
// a coupon past its redeem_by is no longer valid (semantics/coupons.ts)
|
|
195
|
+
await lapseCoupons(ctx);
|
|
196
|
+
// a top-up's funds arrive five days after it is made (semantics/terminal.ts)
|
|
197
|
+
await settleTopups(ctx);
|
|
198
|
+
// a report run completes (semantics/platform.ts)
|
|
199
|
+
await finishReportRuns(ctx);
|
|
200
|
+
// a real-time authorization request no one answered is decided when its window ends (semantics/issuing.ts)
|
|
201
|
+
await lapseRealtimeRequests(ctx);
|
|
202
|
+
// a refund held for want of balance is made when funds cover it, before any payout takes them (semantics/refunds.ts)
|
|
203
|
+
await settleHeldRefunds(ctx);
|
|
204
|
+
await advancePayouts(ctx);
|
|
205
|
+
};
|
|
206
|
+
// Time's EVENTS, sent on the vendor's clock through a twin-only drain door, as the qstash and vercel twins send
|
|
207
|
+
// theirs (a caller — a runner's drainer — drives each move). POST /_twin/drain catches time up as every request
|
|
208
|
+
// does, then writes what time settled on each account: payouts whose arrival date came (pending → paid, sent as
|
|
209
|
+
// payout.paid) and funds that came due (pending → available), for which the account is sent one balance.available
|
|
210
|
+
// ("Occurs whenever your Stripe balance has been updated (e.g., when a charge is available to be paid out)",
|
|
211
|
+
// docs.stripe.com/api/events/types) carrying its Balance, when an entry that settled adds to it ("This event is not
|
|
212
|
+
// fired for negative transactions", the same page); a connected account's events go to Connect endpoints.
|
|
213
|
+
// A read already sees these moves; the drain is what makes Stripe's events about them arrive. Answers what it sent.
|
|
214
|
+
const drain = async (request: Request): Promise<Response> => {
|
|
215
|
+
if (readOnly) return Response.json({ error: 'twin is read-only; omit readOnly to accept writes' }, { status: 405 });
|
|
216
|
+
await catchUp(request);
|
|
217
|
+
const at = (account?: string) => semanticsContext(manifest, new Request(request.url, account ? { headers: { 'stripe-account': account } } : {}), billingClock, scope);
|
|
218
|
+
const platformCtx = await at();
|
|
219
|
+
const accounts = [undefined, ...platformCtx.rows('account').map((a) => String(a.id)).filter((id) => id !== PLATFORM_ACCOUNT_ID)];
|
|
220
|
+
const delivered: Array<{ type: string; id?: string; account?: string }> = [];
|
|
221
|
+
for (const account of accounts) {
|
|
222
|
+
const ctx = account ? await at(account) : platformCtx;
|
|
223
|
+
for (const id of await payDuePayouts(ctx)) delivered.push({ type: 'payout.paid', id, ...(account ? { account } : {}) });
|
|
224
|
+
if ((await settleDueEntries(ctx)).some((t) => Number(t.net) > 0)) {
|
|
225
|
+
await afterStripeWrite('balance', 'balance.available', balanceBody(ctx), options.root, ctx.occurredAt, undefined, account);
|
|
226
|
+
delivered.push({ type: 'balance.available', ...(account ? { account } : {}) });
|
|
227
|
+
}
|
|
43
228
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
method: request.method,
|
|
59
|
-
path: url.pathname + (url.search || ''),
|
|
60
|
-
body,
|
|
61
|
-
readOnly,
|
|
62
|
-
...(apiVersion ? { apiVersion } : {}),
|
|
63
|
-
...(stripeAccount ? { stripeAccount } : {}),
|
|
64
|
-
// The WORLD instant (R9): served `created` epochs come from the world clock, never
|
|
65
|
-
// wall time. Embedded/test callers pass their own occurredAt.
|
|
66
|
-
occurredAt: worldNow(),
|
|
67
|
-
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
68
|
-
...(options.root !== undefined ? { root: options.root } : {}),
|
|
69
|
-
});
|
|
70
|
-
return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json', 'request-id': 'req_twin' } });
|
|
229
|
+
return Response.json({ delivered });
|
|
230
|
+
};
|
|
231
|
+
const served = async (request: Request): Promise<Response> => {
|
|
232
|
+
if (request.method === 'POST' && new URL(request.url).pathname.replace(/\/+$/, '') === '/_twin/drain') return drain(request);
|
|
233
|
+
const opened = await door(request);
|
|
234
|
+
if (opened) return opened;
|
|
235
|
+
// the client library an application's page loads from js.stripe.com (stripe-js.ts); it moves nothing, so a
|
|
236
|
+
// read-only twin serves it too
|
|
237
|
+
const library = stripeJs(request);
|
|
238
|
+
if (library) return library;
|
|
239
|
+
await catchUp(request);
|
|
240
|
+
if (!readOnly) for (const flow of flows) { const page = await flow(request); if (page) return page; }
|
|
241
|
+
// 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));
|
|
71
243
|
};
|
|
244
|
+
// every answer in the shape of the API version the caller is served (stripe-version.ts)
|
|
245
|
+
const rendered = async (request: Request): Promise<Response> => {
|
|
246
|
+
const expand = expandOf(request.clone());
|
|
247
|
+
const creates = request.method === 'POST' && new URL(request.url).pathname.replace(/\/+$/, '') === '/v1/webhook_endpoints';
|
|
248
|
+
const res = await served(request);
|
|
249
|
+
const pinned = request.headers.get('stripe-version');
|
|
250
|
+
if (!(res.headers.get('content-type') ?? '').includes('json')) return res;
|
|
251
|
+
if (!servesCurrent(pinned) && creates) return res;
|
|
252
|
+
const body = await res.json();
|
|
253
|
+
// a webhook endpoint's `secret` is "Only returned at creation" (docs.stripe.com/api/webhook_endpoints/object)
|
|
254
|
+
const answered = creates ? body : withoutEndpointSecret(body);
|
|
255
|
+
return new Response(JSON.stringify(servesCurrent(pinned) ? render(answered, pinned, await expand) : answered), { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
256
|
+
};
|
|
257
|
+
return Object.assign(rendered, { owners: derived.owners });
|
|
72
258
|
}
|
|
73
259
|
|
|
74
|
-
export function createStripeTwinServer(options: StripeTwinOptions): { port: number; stop: () => void } {
|
|
75
|
-
const server =
|
|
260
|
+
export async function createStripeTwinServer(options: StripeTwinOptions): Promise<{ port: number; stop: () => void }> {
|
|
261
|
+
const server = await serveHttp({
|
|
76
262
|
port: options.port ?? 0,
|
|
77
263
|
idleTimeout: 60,
|
|
78
264
|
fetch: createStripeTwinFetch(options),
|
|
79
265
|
});
|
|
80
266
|
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
81
267
|
}
|
|
268
|
+
|
|
269
|
+
// the update operations, by their path as a pattern, for mergeMetadata
|
|
270
|
+
const UPDATES = (surface.operations as Array<DerivedCall['operation']>).filter((o) => o.class === 'update' && o.method.toUpperCase() === 'POST')
|
|
271
|
+
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
272
|
+
|
|
273
|
+
/** An update's `metadata`, merged into what the object holds before anything serves the update. "This parameter uses a
|
|
274
|
+
* merge mechanism, which allows you to add new key-value pairs to an object in an update call without affecting any
|
|
275
|
+
* existing metadata"; "Pass in the key with an empty string as the value to remove the key from the metadata"; "Pass an
|
|
276
|
+
* empty string as the value for the metadata attribute to delete all of the keys simultaneously"
|
|
277
|
+
* (docs.stripe.com/metadata). The derived core already merges a nested hash where the manifest leaves `update` at
|
|
278
|
+
* `merge` (dropping keys set to ""), so only a clear-all is rewritten for it; where a handler or a `replace` update
|
|
279
|
+
* stores the request's metadata whole, the request is rewritten to carry the merged hash, so every door stores the same
|
|
280
|
+
* answer. The request is rewritten as JSON (readParams reads either). */
|
|
281
|
+
async function mergeMetadata(request: Request, coreMerges: (op: DerivedCall['operation']) => boolean, scope: { root?: string; clock?: () => string }): Promise<Request> {
|
|
282
|
+
if (request.method !== 'POST' || (request.headers.get('content-type') ?? '').includes('multipart')) return request;
|
|
283
|
+
const path = new URL(request.url).pathname;
|
|
284
|
+
let op: DerivedCall['operation'] | undefined;
|
|
285
|
+
let id = '';
|
|
286
|
+
for (const u of UPDATES) { const m = u.re.exec(path); if (m) { op = u.op; id = decodeURIComponent(m.at(-1) ?? ''); break; } }
|
|
287
|
+
if (!op?.resource) return request;
|
|
288
|
+
const params = await readParams(manifest, request.clone(), op);
|
|
289
|
+
if (!('metadata' in params)) return request;
|
|
290
|
+
const given = params.metadata;
|
|
291
|
+
// a plan is kept as the recurring price it is (semantics/plans.ts)
|
|
292
|
+
const resource = op.resource === 'plan' ? 'price' : op.resource;
|
|
293
|
+
const ctx = await semanticsContext(manifest, request.clone(), op, scope);
|
|
294
|
+
const held = ctx.get(resource, id)?.metadata;
|
|
295
|
+
const prior = held && typeof held === 'object' ? (held as Record<string, unknown>) : {};
|
|
296
|
+
const merges = coreMerges(op) && manifest.resources[resource]?.update !== 'replace';
|
|
297
|
+
let metadata: Record<string, unknown>;
|
|
298
|
+
if (given === '') metadata = merges ? Object.fromEntries(Object.keys(prior).map((k) => [k, ''])) : {};
|
|
299
|
+
else if (given && typeof given === 'object' && !Array.isArray(given)) {
|
|
300
|
+
if (merges) return request;
|
|
301
|
+
metadata = Object.fromEntries(Object.entries({ ...prior, ...(given as Record<string, unknown>) }).filter(([, v]) => v !== ''));
|
|
302
|
+
} else return request;
|
|
303
|
+
const headers = new Headers(request.headers);
|
|
304
|
+
headers.set('content-type', 'application/json');
|
|
305
|
+
return new Request(request.url, { method: request.method, headers, body: JSON.stringify({ ...params, metadata }) });
|
|
306
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// The stripe mirror's SHARED vocabulary — formatters, tones, reference resolution — with no
|
|
2
|
+
// import from the server side: the mirror client bundles this into the browser (a server import
|
|
3
|
+
// would drag core, and with it node:async_hooks, into the bundle and the page would never mount).
|
|
4
|
+
// The server module re-exports it, so nothing else moves.
|
|
5
|
+
|
|
6
|
+
export type StripeRow = Record<string, any>;
|
|
7
|
+
|
|
8
|
+
/** The name an account shows its customers (Checkout, the customer portal): its public business name,
|
|
9
|
+
* `business_profile.name` ("The customer-facing business name", docs.stripe.com/api/accounts/object), which the
|
|
10
|
+
* operator sets on the Dashboard ("You can change a Checkout page's name by modifying the Business name field",
|
|
11
|
+
* docs.stripe.com/payments/checkout/customization/appearance). Where the documentation stops and the twin decides:
|
|
12
|
+
* with none set it falls back to the Dashboard's account name, `settings.dashboard.display_name` ("used on the Stripe
|
|
13
|
+
* Dashboard to differentiate between accounts", the same object page), and with neither to "Twin Inc.", the name the
|
|
14
|
+
* Dashboard mirror has always given the World's own account. */
|
|
15
|
+
export const PLATFORM_DEFAULT_NAME = 'Twin Inc.';
|
|
16
|
+
export function publicBusinessName(account: StripeRow | undefined): string {
|
|
17
|
+
const named = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v : undefined);
|
|
18
|
+
return named(account?.business_profile?.name) ?? named(account?.settings?.dashboard?.display_name) ?? PLATFORM_DEFAULT_NAME;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Zero-decimal currencies (Stripe stores these in whole units, not cents). */
|
|
22
|
+
const ZERO_DECIMAL = new Set(['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Format a Stripe minor-unit amount (cents) as a localized currency string.
|
|
26
|
+
* Honors zero-decimal currencies (¥4200 not ¥42.00). Non-numbers → an em dash.
|
|
27
|
+
*/
|
|
28
|
+
export function formatStripeAmount(value: unknown, currency?: string): string {
|
|
29
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
|
|
30
|
+
const ccy = String(currency ?? 'usd').toLowerCase();
|
|
31
|
+
const zero = ZERO_DECIMAL.has(ccy);
|
|
32
|
+
const major = zero ? value : value / 100;
|
|
33
|
+
try {
|
|
34
|
+
return new Intl.NumberFormat('en-US', {
|
|
35
|
+
style: 'currency', currency: ccy.toUpperCase(),
|
|
36
|
+
minimumFractionDigits: zero ? 0 : 2, maximumFractionDigits: zero ? 0 : 2,
|
|
37
|
+
}).format(major);
|
|
38
|
+
} catch {
|
|
39
|
+
// Unknown/invalid currency code → a stable, readable fallback.
|
|
40
|
+
return `${zero ? major : major.toFixed(2)} ${ccy.toUpperCase()}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Render a Stripe price's `recurring` block as a short interval label, e.g.
|
|
46
|
+
* "every month", "every 3 months". Returns '' when it isn't a recurring price.
|
|
47
|
+
*/
|
|
48
|
+
export function formatRecurring(recurring: unknown): string {
|
|
49
|
+
if (!recurring || typeof recurring !== 'object') return '';
|
|
50
|
+
const r = recurring as Record<string, unknown>;
|
|
51
|
+
const interval = typeof r.interval === 'string' ? r.interval : '';
|
|
52
|
+
if (!interval) return '';
|
|
53
|
+
const count = Number(r.interval_count);
|
|
54
|
+
return count > 1 ? `every ${count} ${interval}s` : `every ${interval}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Render a saved payment method as a short human label, e.g. "Visa •••• 4242"
|
|
59
|
+
* for a card, or the bare `type` ("us_bank_account") for non-card methods.
|
|
60
|
+
*/
|
|
61
|
+
export function formatPaymentMethod(pm: unknown): string {
|
|
62
|
+
if (!pm || typeof pm !== 'object') return '—';
|
|
63
|
+
const m = pm as Record<string, any>;
|
|
64
|
+
const card = m.card && typeof m.card === 'object' ? (m.card as Record<string, any>) : undefined;
|
|
65
|
+
if (card && (card.brand || card.last4)) {
|
|
66
|
+
const brand = card.brand ? String(card.brand).replace(/\b\w/g, (c) => c.toUpperCase()) : 'Card';
|
|
67
|
+
return card.last4 ? `${brand} •••• ${card.last4}` : brand;
|
|
68
|
+
}
|
|
69
|
+
return typeof m.type === 'string' && m.type ? m.type : (m.id ?? '—');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract the HTTP(S) image URLs from a product's `images` field (Stripe stores an
|
|
74
|
+
* array of URL strings) so the mirror can render them as <img> thumbnails instead
|
|
75
|
+
* of plain text. Non-arrays / non-URL entries are dropped. Order is preserved.
|
|
76
|
+
*/
|
|
77
|
+
export function productImageUrls(images: unknown): string[] {
|
|
78
|
+
if (!Array.isArray(images)) return [];
|
|
79
|
+
return images.filter((u): u is string => typeof u === 'string' && /^https?:\/\//i.test(u));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render a payment_intent / charge `last_payment_error` (the test-card decline state)
|
|
84
|
+
* as a single human-readable line, e.g. "card_declined (insufficient_funds): Your card
|
|
85
|
+
* has insufficient funds." Returns '' when there is no error object. This is the
|
|
86
|
+
* vendor-faithful decline reason the twin populates on a declined confirm.
|
|
87
|
+
*/
|
|
88
|
+
export function formatPaymentError(error: unknown): string {
|
|
89
|
+
if (!error || typeof error !== 'object') return '';
|
|
90
|
+
const e = error as Record<string, any>;
|
|
91
|
+
const code = typeof e.code === 'string' ? e.code : '';
|
|
92
|
+
const declineCode = typeof e.decline_code === 'string' ? e.decline_code : '';
|
|
93
|
+
const message = typeof e.message === 'string' ? e.message : '';
|
|
94
|
+
const head = code ? (declineCode ? `${code} (${declineCode})` : code) : declineCode;
|
|
95
|
+
if (head && message) return `${head}: ${message}`;
|
|
96
|
+
return head || message;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Summarize a synthesized Stripe `balance` object (available/pending arrays, one entry
|
|
101
|
+
* per currency) into short per-bucket lines, e.g. ["available: $42.00", "pending: $0.00"].
|
|
102
|
+
* The balance is not a list collection, so the mirror renders this summary directly.
|
|
103
|
+
*/
|
|
104
|
+
export function formatBalanceSummary(balance: unknown): Array<{ bucket: string; text: string }> {
|
|
105
|
+
if (!balance || typeof balance !== 'object') return [];
|
|
106
|
+
const b = balance as Record<string, any>;
|
|
107
|
+
const sum = (arr: unknown): string => {
|
|
108
|
+
if (!Array.isArray(arr) || arr.length === 0) return formatStripeAmount(0, 'usd');
|
|
109
|
+
return arr
|
|
110
|
+
.map((e) => formatStripeAmount((e as Record<string, any>)?.amount, (e as Record<string, any>)?.currency))
|
|
111
|
+
.join(', ');
|
|
112
|
+
};
|
|
113
|
+
const out: Array<{ bucket: string; text: string }> = [];
|
|
114
|
+
if (Array.isArray(b.available)) out.push({ bucket: 'available', text: sum(b.available) });
|
|
115
|
+
if (Array.isArray(b.pending)) out.push({ bucket: 'pending', text: sum(b.pending) });
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Summarize a Connect connected `account` object's enablement state into the three
|
|
121
|
+
* boolean capability flags a real Stripe Connect dashboard shows up front:
|
|
122
|
+
* charges_enabled, payouts_enabled, details_submitted. Each is rendered as a tone-
|
|
123
|
+
* carrying flag (true → ok, false → warn) so an un-onboarded account reads as such.
|
|
124
|
+
*/
|
|
125
|
+
export type AccountFlag = { key: string; label: string; enabled: boolean };
|
|
126
|
+
export function formatAccountFlags(account: unknown): AccountFlag[] {
|
|
127
|
+
if (!account || typeof account !== 'object') return [];
|
|
128
|
+
const a = account as Record<string, any>;
|
|
129
|
+
return [
|
|
130
|
+
{ key: 'charges_enabled', label: 'Charges', enabled: a.charges_enabled === true },
|
|
131
|
+
{ key: 'payouts_enabled', label: 'Payouts', enabled: a.payouts_enabled === true },
|
|
132
|
+
{ key: 'details_submitted', label: 'Details submitted', enabled: a.details_submitted === true },
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract a Connect account's outstanding onboarding requirements (the
|
|
138
|
+
* `requirements.currently_due` list real Stripe shows as "needs attention"). Returns
|
|
139
|
+
* an ordered list of the still-due field paths; empty when nothing is due.
|
|
140
|
+
*/
|
|
141
|
+
export function accountCurrentlyDue(account: unknown): string[] {
|
|
142
|
+
if (!account || typeof account !== 'object') return [];
|
|
143
|
+
const req = (account as Record<string, any>).requirements;
|
|
144
|
+
if (!req || typeof req !== 'object') return [];
|
|
145
|
+
const due = (req as Record<string, any>).currently_due;
|
|
146
|
+
return Array.isArray(due) ? due.filter((d): d is string => typeof d === 'string') : [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Tone for a status pill: 'ok' (green), 'warn' (amber), 'bad' (red), '' (neutral). */
|
|
150
|
+
export type PillTone = 'ok' | 'warn' | 'bad' | '';
|
|
151
|
+
const PILL_OK = new Set(['active', 'succeeded', 'paid', 'true', 'enabled', 'available', 'won']);
|
|
152
|
+
const PILL_WARN = new Set(['open', 'draft', 'pending', 'processing', 'incomplete', 'trialing', 'requires_confirmation', 'requires_action', 'requires_capture', 'requires_payment_method', 'past_due', 'unpaid', 'in_transit', 'warning_needs_response', 'needs_response', 'under_review', 'warning_under_review']);
|
|
153
|
+
const PILL_BAD = new Set(['canceled', 'cancelled', 'void', 'uncollectible', 'failed', 'incomplete_expired', 'false', 'disabled', 'lost', 'charge_refunded']);
|
|
154
|
+
export function statusTone(value: unknown): PillTone {
|
|
155
|
+
const v = String(value ?? '').toLowerCase();
|
|
156
|
+
if (PILL_OK.has(v)) return 'ok';
|
|
157
|
+
if (PILL_BAD.has(v)) return 'bad';
|
|
158
|
+
if (PILL_WARN.has(v)) return 'warn';
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True when a key names a field whose value is a Stripe object id we can link. */
|
|
163
|
+
export function isReferenceKey(key: string): boolean {
|
|
164
|
+
return REFERENCE_FIELDS.has(key);
|
|
165
|
+
}
|
|
166
|
+
// field name → the COLLECTION it points at (so the UI can jump sections + select).
|
|
167
|
+
const REFERENCE_FIELD_MAP: Record<string, string> = {
|
|
168
|
+
customer: 'customers', product: 'products', price: 'prices',
|
|
169
|
+
subscription: 'subscriptions', latest_invoice: 'invoices', invoice: 'invoices',
|
|
170
|
+
payment_intent: 'payment_intents', latest_charge: 'charges', charge: 'charges',
|
|
171
|
+
payment_method: 'payment_methods', default_payment_method: 'payment_methods',
|
|
172
|
+
dispute: 'disputes', payout: 'payouts', balance_transaction: 'balance_transactions',
|
|
173
|
+
source_transaction: 'charges', setup_intent: 'setup_intents',
|
|
174
|
+
// Connect: a transfer's `destination` points at a connected account.
|
|
175
|
+
account: 'accounts', destination: 'accounts',
|
|
176
|
+
// Billing: a customer_balance_transaction may reference the credit_note that created it.
|
|
177
|
+
credit_note: 'credit_notes',
|
|
178
|
+
};
|
|
179
|
+
const REFERENCE_FIELDS = new Set(Object.keys(REFERENCE_FIELD_MAP));
|
|
180
|
+
/** The collection a reference field points at, or undefined if not a reference. */
|
|
181
|
+
export function referenceCollection(key: string): string | undefined {
|
|
182
|
+
return REFERENCE_FIELD_MAP[key];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A single line in the flattened, human-readable view of a nested value. */
|
|
186
|
+
export type FlatLine = { depth: number; label: string; value: string; ref?: string };
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Flatten an arbitrary nested Stripe value (object / array / scalar) into ordered,
|
|
190
|
+
* indented label/value lines suitable for a readable detail view — instead of the
|
|
191
|
+
* old "[object Object]". Stripe "list" wrappers ({object:'list',data:[...]}) are
|
|
192
|
+
* unwrapped to their `data`. Amount-ish fields are currency-formatted.
|
|
193
|
+
*/
|
|
194
|
+
export function flattenStripeValue(value: unknown, opts: { label?: string; depth?: number; currency?: string } = {}): FlatLine[] {
|
|
195
|
+
const depth = opts.depth ?? 0;
|
|
196
|
+
const label = opts.label ?? '';
|
|
197
|
+
const currency = opts.currency;
|
|
198
|
+
if (value === null || value === undefined) return [{ depth, label, value: '—' }];
|
|
199
|
+
|
|
200
|
+
if (Array.isArray(value)) {
|
|
201
|
+
if (value.length === 0) return [{ depth, label, value: '(none)' }];
|
|
202
|
+
const out: FlatLine[] = label ? [{ depth, label, value: '' }] : [];
|
|
203
|
+
value.forEach((item, i) => {
|
|
204
|
+
const childDepth = label ? depth + 1 : depth;
|
|
205
|
+
out.push(...flattenStripeValue(item, { label: `#${i + 1}`, depth: childDepth, currency }));
|
|
206
|
+
});
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (typeof value === 'object') {
|
|
211
|
+
const obj = value as Record<string, unknown>;
|
|
212
|
+
// Unwrap Stripe list objects to their data array.
|
|
213
|
+
if (obj.object === 'list' && Array.isArray(obj.data)) {
|
|
214
|
+
return flattenStripeValue(obj.data, { label, depth, currency });
|
|
215
|
+
}
|
|
216
|
+
const childCurrency = typeof obj.currency === 'string' ? obj.currency : currency;
|
|
217
|
+
const keys = Object.keys(obj).filter((k) => obj[k] !== null && obj[k] !== undefined && k !== 'object');
|
|
218
|
+
if (keys.length === 0) return [{ depth, label, value: '(empty)' }];
|
|
219
|
+
const out: FlatLine[] = label ? [{ depth, label, value: '' }] : [];
|
|
220
|
+
const childDepth = label ? depth + 1 : depth;
|
|
221
|
+
for (const k of keys) out.push(...flattenStripeValue(obj[k], { label: k, depth: childDepth, currency: childCurrency }));
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// scalar
|
|
226
|
+
const ref = typeof value === 'string' && isReferenceKey(label) ? value : undefined;
|
|
227
|
+
const isMoney = typeof value === 'number' && (label === 'unit_amount' || /amount|total|subtotal|balance/.test(label));
|
|
228
|
+
const rendered = isMoney ? formatStripeAmount(value, currency) : String(value);
|
|
229
|
+
return [{ depth, label, value: rendered, ...(ref ? { ref } : {}) }];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolve the cross-references for a row: the rows in other collections that this
|
|
234
|
+
* row points AT (outgoing, e.g. invoice→customer) and the rows that point BACK at
|
|
235
|
+
* it (incoming, e.g. customer←subscriptions). Resolved purely from already-fetched
|
|
236
|
+
* collection data, so the UI can render clickable links without extra requests.
|
|
237
|
+
*/
|
|
238
|
+
export type RefLink = { collection: string; id: string; label: string };
|
|
239
|
+
export type CrossRefs = { outgoing: RefLink[]; incoming: RefLink[] };
|
|
240
|
+
|
|
241
|
+
const LABELERS: Record<string, (r: StripeRow) => string> = {
|
|
242
|
+
customers: (r) => r.name || r.email || r.id,
|
|
243
|
+
products: (r) => r.name || r.id,
|
|
244
|
+
prices: (r) => `${formatStripeAmount(r.unit_amount, r.currency)}${formatRecurring(r.recurring) ? ` ${formatRecurring(r.recurring)}` : ''}`,
|
|
245
|
+
subscriptions: (r) => `${r.status ?? 'subscription'} · ${r.id}`,
|
|
246
|
+
invoices: (r) => `${r.status ?? 'invoice'} · ${formatStripeAmount(r.total ?? r.amount_due, r.currency)}`,
|
|
247
|
+
payment_intents: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? ''}`,
|
|
248
|
+
charges: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? ''}`,
|
|
249
|
+
refunds: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'refund'}`,
|
|
250
|
+
payment_methods: (r) => `${formatPaymentMethod(r)} · ${r.id}`,
|
|
251
|
+
disputes: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'dispute'}`,
|
|
252
|
+
payouts: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'payout'}`,
|
|
253
|
+
balance_transactions: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.type ?? 'txn'}`,
|
|
254
|
+
events: (r) => `${r.type ?? 'event'} · ${r.id}`,
|
|
255
|
+
setup_intents: (r) => `${r.status ?? 'setup_intent'} · ${r.id}`,
|
|
256
|
+
invoiceitems: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.id}`,
|
|
257
|
+
accounts: (r) => `${r.email || r.id} · ${r.type ?? 'account'}`,
|
|
258
|
+
transfers: (r) => `${formatStripeAmount(r.amount, r.currency)} → ${r.destination ?? '?'}`,
|
|
259
|
+
credit_notes: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'credit_note'}`,
|
|
260
|
+
tax_ids: (r) => `${r.value ?? r.id} · ${r.type ?? ''}`,
|
|
261
|
+
};
|
|
262
|
+
function labelFor(collection: string, row: StripeRow): string {
|
|
263
|
+
return (LABELERS[collection] ?? ((r: StripeRow) => r.id))(row);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function resolveCrossRefs(
|
|
267
|
+
collection: string,
|
|
268
|
+
row: StripeRow,
|
|
269
|
+
data: Record<string, StripeRow[]>,
|
|
270
|
+
): CrossRefs {
|
|
271
|
+
const outgoing: RefLink[] = [];
|
|
272
|
+
const incoming: RefLink[] = [];
|
|
273
|
+
if (!row) return { outgoing, incoming };
|
|
274
|
+
|
|
275
|
+
// Outgoing: every reference field on this row that we can resolve to a row.
|
|
276
|
+
for (const [key, value] of Object.entries(row)) {
|
|
277
|
+
if (typeof value !== 'string' || !isReferenceKey(key)) continue;
|
|
278
|
+
const target = referenceCollection(key)!;
|
|
279
|
+
const found = (data[target] ?? []).find((r) => r.id === value);
|
|
280
|
+
outgoing.push({ collection: target, id: value, label: `${key}: ${found ? labelFor(target, found) : value}` });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Incoming: rows in other collections whose reference field points at this row.
|
|
284
|
+
for (const [other, rows] of Object.entries(data)) {
|
|
285
|
+
if (other === collection) continue;
|
|
286
|
+
for (const r of rows) {
|
|
287
|
+
for (const [key, value] of Object.entries(r)) {
|
|
288
|
+
if (value !== row.id || !isReferenceKey(key)) continue;
|
|
289
|
+
incoming.push({ collection: other, id: r.id, label: `${labelFor(other, r)}` });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { outgoing, incoming };
|
|
294
|
+
}
|