@volter/twin-stripe 0.1.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/LICENSE +202 -0
- package/README.md +110 -0
- package/client/stripe-mirror.css +162 -0
- package/client/stripe-mirror.tsx +691 -0
- package/package.json +71 -0
- package/src/cli.ts +29 -0
- package/src/index.ts +52 -0
- package/src/stripe-capabilities.ts +3304 -0
- package/src/stripe-conformance.ts +110 -0
- package/src/stripe-connector.ts +373 -0
- package/src/stripe-events.ts +202 -0
- package/src/stripe-form.ts +35 -0
- package/src/stripe-mirror-ui.ts +345 -0
- package/src/stripe-server.ts +46 -0
- package/src/stripe-twin.ts +5227 -0
- package/src/stripe-ui-conformance.ts +125 -0
- package/src/stripe-ui-structure.ts +406 -0
- package/test-fixtures/stripe-known-deviations.json +85 -0
- package/test-fixtures/stripe-schemas.json +3364 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Stripe twin conformance (scorecard R2) — spec-conformance against Stripe's
|
|
2
|
+
// PUBLISHED OpenAPI, offline (no API key). Stripe is the twin we CANNOT dual-run
|
|
3
|
+
// against a live sandbox, so the published spec carries the most weight here.
|
|
4
|
+
//
|
|
5
|
+
// For every resource the twin emits, validate its REST body against the vendored
|
|
6
|
+
// per-object schema (test-fixtures/stripe-schemas.json) via the shared
|
|
7
|
+
// specConformance harness: declared TYPES, REQUIRED fields (omission), and ENUMS.
|
|
8
|
+
// Polymorphic Stripe fields (anyOf/$ref — `customer` may be id|object|null) are
|
|
9
|
+
// left untyped in the projection and so are not type-checked, avoiding false
|
|
10
|
+
// positives. Fields the twin deliberately does not model are DECLARED in
|
|
11
|
+
// stripe-known-deviations.json with reasons; an undeclared missing-required field,
|
|
12
|
+
// a wrong type, an enum violation, or a fabricated field all fail CI.
|
|
13
|
+
import { twinResources } from '@volter/twin';
|
|
14
|
+
import { specConformance } from '@volter/twin-tooling';
|
|
15
|
+
import type { TwinResource } from '@volter/twin';
|
|
16
|
+
import { OBJECT_NAME } from './stripe-twin.ts';
|
|
17
|
+
|
|
18
|
+
type JsonSchema = specConformance.JsonSchema;
|
|
19
|
+
type KnownDeviation = specConformance.KnownDeviation;
|
|
20
|
+
type SpecViolation = specConformance.SpecViolation;
|
|
21
|
+
|
|
22
|
+
// twin resource type -> Stripe OpenAPI object name (the resources the twin serves)
|
|
23
|
+
const TYPE_TO_OBJECT: Record<string, string> = {
|
|
24
|
+
charge: 'charge', customer: 'customer', payment_intent: 'payment_intent', refund: 'refund',
|
|
25
|
+
subscription: 'subscription', setup_intent: 'setup_intent', payment_method: 'payment_method',
|
|
26
|
+
price: 'price', product: 'product', invoice: 'invoice', invoiceitem: 'invoiceitem',
|
|
27
|
+
dispute: 'dispute', payout: 'payout', balance_transaction: 'balance_transaction', event: 'event',
|
|
28
|
+
checkout_session: 'checkout.session', billing_portal_session: 'billing_portal.session',
|
|
29
|
+
billing_portal_configuration: 'billing_portal.configuration',
|
|
30
|
+
account: 'account', transfer: 'transfer',
|
|
31
|
+
tax_rate: 'tax_rate', tax_calculation: 'tax.calculation', tax_registration: 'tax.registration',
|
|
32
|
+
credit_note: 'credit_note', tax_id: 'tax_id', customer_balance_transaction: 'customer_balance_transaction',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type StripeSchemas = Record<string, JsonSchema>;
|
|
36
|
+
export type StripeViolation = SpecViolation & { object: string; id: string };
|
|
37
|
+
export type StripeConformanceReport = {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
resourcesChecked: number;
|
|
40
|
+
fieldsChecked: number;
|
|
41
|
+
violations: StripeViolation[];
|
|
42
|
+
knownIgnored: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const isTwinExtra = (k: string): boolean => k.startsWith('_');
|
|
46
|
+
const fixturePath = (name: string): string => new URL(`../test-fixtures/${name}`, import.meta.url).pathname;
|
|
47
|
+
|
|
48
|
+
// The emitted REST body == the twin's view(): strip internal fields, add object + id.
|
|
49
|
+
// Mirrors stripe-twin.ts view(): a vendor `type` field collides with the kernel
|
|
50
|
+
// discriminator, so it is stored under `_stripe_type` and restored to `type` on emit.
|
|
51
|
+
function emitted(r: TwinResource): Record<string, unknown> {
|
|
52
|
+
const { type, updatedAt, _stripe_type, ...rest } = r as TwinResource & { _stripe_type?: unknown };
|
|
53
|
+
const out: Record<string, unknown> = { object: OBJECT_NAME[type] ?? type, ...rest, id: r.id };
|
|
54
|
+
if (_stripe_type !== undefined) out.type = _stripe_type;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Load the vendored per-object Stripe JSON Schemas (type+required+enum from OpenAPI). */
|
|
59
|
+
export async function loadStripeSchemas(): Promise<StripeSchemas> {
|
|
60
|
+
return JSON.parse(await Bun.file(fixturePath('stripe-schemas.json')).text()) as StripeSchemas;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Load the declared scope (Stripe fields the twin does not model, + reasons). */
|
|
64
|
+
export async function loadStripeKnownDeviations(): Promise<KnownDeviation[]> {
|
|
65
|
+
const doc = JSON.parse(await Bun.file(fixturePath('stripe-known-deviations.json')).text()) as { deviations: KnownDeviation[] };
|
|
66
|
+
return doc.deviations;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate every Stripe resource the twin serves (in `root`) against the published
|
|
71
|
+
* schema. A wrong type, an undeclared missing-required field, an enum violation, or
|
|
72
|
+
* a fabricated field fails the report.
|
|
73
|
+
*/
|
|
74
|
+
export function checkStripeConformance(schemas: StripeSchemas, opts: { root?: string; known?: KnownDeviation[] } = {}): StripeConformanceReport {
|
|
75
|
+
const resources = twinResources('stripe', opts.root);
|
|
76
|
+
const violations: StripeViolation[] = [];
|
|
77
|
+
let fieldsChecked = 0;
|
|
78
|
+
let knownIgnored = 0;
|
|
79
|
+
for (const r of resources) {
|
|
80
|
+
const objectName = TYPE_TO_OBJECT[r.type];
|
|
81
|
+
if (!objectName) continue;
|
|
82
|
+
const schema = schemas[objectName];
|
|
83
|
+
if (!schema) continue;
|
|
84
|
+
const rep = specConformance.checkSpecConformance(emitted(r), schema, { exemptKey: isTwinExtra, known: opts.known ?? [] });
|
|
85
|
+
fieldsChecked += rep.fieldsChecked;
|
|
86
|
+
knownIgnored += rep.knownIgnored;
|
|
87
|
+
for (const v of rep.violations) violations.push({ ...v, object: objectName, id: r.id });
|
|
88
|
+
}
|
|
89
|
+
return { ok: violations.length === 0, resourcesChecked: resources.length, fieldsChecked, violations, knownIgnored };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type StripeCoverageReport = specConformance.SpecCoverageReport & { object: string };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Per-object coverage: of the fields each Stripe object declares, which does the
|
|
96
|
+
* twin emit? Computed over one representative resource of each type present in
|
|
97
|
+
* `root` — the "what we emulate / what we're missing" report, from the spec.
|
|
98
|
+
*/
|
|
99
|
+
export function stripeCoverage(schemas: StripeSchemas, opts: { root?: string } = {}): StripeCoverageReport[] {
|
|
100
|
+
const byType = new Map<string, TwinResource>();
|
|
101
|
+
for (const r of twinResources('stripe', opts.root)) if (!byType.has(r.type)) byType.set(r.type, r);
|
|
102
|
+
const out: StripeCoverageReport[] = [];
|
|
103
|
+
for (const [type, r] of byType) {
|
|
104
|
+
const objectName = TYPE_TO_OBJECT[type];
|
|
105
|
+
const schema = objectName ? schemas[objectName] : undefined;
|
|
106
|
+
if (!objectName || !schema) continue;
|
|
107
|
+
out.push({ object: objectName, ...specConformance.specCoverage(schema, emitted(r), { exemptKey: isTwinExtra }) });
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// Stripe CONNECTOR — the live-vendor pull/push path that gives the Stripe twin the
|
|
2
|
+
// full "git for SaaS" lifecycle (the piece every other connector pack already had,
|
|
3
|
+
// and Stripe was missing entirely).
|
|
4
|
+
//
|
|
5
|
+
// PULL (real → twin): fetch real Stripe objects, map snake_case → SyncResource[],
|
|
6
|
+
// fold into the event log via syncPull (shadow-diff dedup, so a
|
|
7
|
+
// re-pull of identical state appends nothing).
|
|
8
|
+
// PUSH (twin → real): for every PENDING local action, call the real Stripe REST API
|
|
9
|
+
// and confirmAction on success — which records the confirmed
|
|
10
|
+
// fields as an observed event and suppresses the local
|
|
11
|
+
// projection (the change is counted exactly once).
|
|
12
|
+
//
|
|
13
|
+
// The vendor I/O is an INJECTED executor (the auth-boundary, hard-problem #6): the
|
|
14
|
+
// kernel and this pack hold NO Stripe key and import NO network client.
|
|
15
|
+
// - offline/tests pass a fake executor (deterministic, no network),
|
|
16
|
+
// - live runs pass `liveStripeExecute(apiKey)` (the user's own secret key).
|
|
17
|
+
// Same code path either way, so the connector is fully exercisable offline AND
|
|
18
|
+
// runnable against a real account.
|
|
19
|
+
import { confirmAction, pendingActions, syncPull } from '@volter/twin';
|
|
20
|
+
import type { SyncResource, TwinAction } from '@volter/twin';
|
|
21
|
+
|
|
22
|
+
const SERVICE = 'stripe';
|
|
23
|
+
|
|
24
|
+
// Subject types that are twin-internal and are NEVER pushed to real Stripe: the recorded
|
|
25
|
+
// Stripe `event` envelopes (the local Events-API store) and idempotency bookkeeping.
|
|
26
|
+
const INTERNAL_SUBJECT_TYPES = new Set(['event', '_idempotency']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The injected real-Stripe boundary. `request` issues ONE Stripe REST call:
|
|
30
|
+
* method — 'GET' | 'POST' | 'DELETE'
|
|
31
|
+
* path — e.g. '/v1/customers' or '/v1/customers/cus_123'
|
|
32
|
+
* params — form params (POST) or query filters (GET); omitted for plain GETs
|
|
33
|
+
* It returns the parsed JSON body (a Stripe object or `{ object: 'list', data }`) or
|
|
34
|
+
* a Stripe `{ error }` envelope. A real client (the `stripe` SDK or a raw fetch
|
|
35
|
+
* wrapper) is structurally assignable; tests pass a fake.
|
|
36
|
+
*/
|
|
37
|
+
export type StripeExecute = (
|
|
38
|
+
method: 'GET' | 'POST' | 'DELETE',
|
|
39
|
+
path: string,
|
|
40
|
+
params?: Record<string, unknown>,
|
|
41
|
+
) => Promise<{ object?: string; data?: any; error?: { message?: string; type?: string }; [k: string]: unknown }>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A live executor against the real Stripe REST API (secret key = the user's own).
|
|
45
|
+
* Form-encodes POST params; sends GET filters as a query string. Never imported by
|
|
46
|
+
* the pack's own code path — only constructed by a caller that opts into real I/O.
|
|
47
|
+
*/
|
|
48
|
+
export function liveStripeExecute(apiKey: string, base = 'https://api.stripe.com'): StripeExecute {
|
|
49
|
+
return async (method, path, params) => {
|
|
50
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
|
|
51
|
+
let url = `${base}${path}`;
|
|
52
|
+
const form = encodeForm(params ?? {});
|
|
53
|
+
const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
|
|
54
|
+
if (method === 'GET') {
|
|
55
|
+
if (form) url += `?${form}`;
|
|
56
|
+
} else if (form) {
|
|
57
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
58
|
+
init.body = form;
|
|
59
|
+
}
|
|
60
|
+
const res = await fetch(url, init);
|
|
61
|
+
return (await res.json()) as { object?: string; data?: any; error?: { message?: string } };
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Stripe's nested form encoding (a[b]=c). Only the shapes the connector pushes
|
|
66
|
+
// (flat scalars + one level of object nesting, e.g. metadata) are handled.
|
|
67
|
+
function encodeForm(params: Record<string, unknown>, prefix = ''): string {
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
for (const [key, value] of Object.entries(params)) {
|
|
70
|
+
if (value === undefined) continue;
|
|
71
|
+
const name = prefix ? `${prefix}[${key}]` : key;
|
|
72
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
73
|
+
const nested = encodeForm(value as Record<string, unknown>, name);
|
|
74
|
+
if (nested) parts.push(nested);
|
|
75
|
+
} else {
|
|
76
|
+
parts.push(`${encodeURIComponent(name)}=${encodeURIComponent(value === null ? '' : String(value))}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return parts.join('&');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function listOf(res: { data?: unknown }): any[] {
|
|
83
|
+
return Array.isArray(res.data) ? res.data : [];
|
|
84
|
+
}
|
|
85
|
+
function throwIfError(res: { error?: { message?: string } }, ctx: string): void {
|
|
86
|
+
if (res.error) throw new Error(`stripe ${ctx} failed: ${res.error.message ?? 'unknown error'}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── PULL ────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Map a real-Stripe customer (snake_case) → a twin sync resource. Only the stable
|
|
93
|
+
* scalar fields the twin tracks are carried; absent fields become null so the diff
|
|
94
|
+
* is faithful (an unset email reads as null, not missing).
|
|
95
|
+
*/
|
|
96
|
+
export function mapCustomer(c: Record<string, unknown>): SyncResource {
|
|
97
|
+
return {
|
|
98
|
+
type: 'customer',
|
|
99
|
+
id: String(c.id),
|
|
100
|
+
fields: {
|
|
101
|
+
email: (c.email as string) ?? null,
|
|
102
|
+
name: (c.name as string) ?? null,
|
|
103
|
+
description: (c.description as string) ?? null,
|
|
104
|
+
phone: (c.phone as string) ?? null,
|
|
105
|
+
currency: (c.currency as string) ?? null,
|
|
106
|
+
delinquent: typeof c.delinquent === 'boolean' ? c.delinquent : null,
|
|
107
|
+
created: typeof c.created === 'number' ? c.created : null,
|
|
108
|
+
livemode: typeof c.livemode === 'boolean' ? c.livemode : null,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Map a real-Stripe subscription (snake_case) → a twin sync resource. The single
|
|
115
|
+
* subscribed price id is lifted out of items.data[0].price.id (Stripe's nested list
|
|
116
|
+
* shape) so the twin tracks a flat, diffable `price`.
|
|
117
|
+
*/
|
|
118
|
+
export function mapSubscription(s: Record<string, unknown>): SyncResource {
|
|
119
|
+
const items = s.items as { data?: Array<{ price?: { id?: unknown } }> } | undefined;
|
|
120
|
+
const firstPrice = items?.data?.[0]?.price?.id;
|
|
121
|
+
return {
|
|
122
|
+
type: 'subscription',
|
|
123
|
+
id: String(s.id),
|
|
124
|
+
fields: {
|
|
125
|
+
customer: (s.customer as string) ?? null,
|
|
126
|
+
status: (s.status as string) ?? null,
|
|
127
|
+
currency: (s.currency as string) ?? null,
|
|
128
|
+
cancel_at_period_end: typeof s.cancel_at_period_end === 'boolean' ? s.cancel_at_period_end : null,
|
|
129
|
+
price: typeof firstPrice === 'string' ? firstPrice : null,
|
|
130
|
+
created: typeof s.created === 'number' ? s.created : null,
|
|
131
|
+
livemode: typeof s.livemode === 'boolean' ? s.livemode : null,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A GENERIC scalar mapper for the collections that don't need special id-lifting like
|
|
138
|
+
* subscriptions do. We carry the stable, diffable scalar fields a twin tracks (anything
|
|
139
|
+
* that is a string/number/boolean), dropping nested objects/arrays (which the twin either
|
|
140
|
+
* synthesizes or doesn't diff on). This keeps every collection's pull faithful without a
|
|
141
|
+
* bespoke mapper per type — vendor-SPECIFIC lifting (subscription.price) stays explicit.
|
|
142
|
+
*/
|
|
143
|
+
export function mapScalarResource(type: string, r: Record<string, unknown>): SyncResource {
|
|
144
|
+
const fields: Record<string, unknown> = {};
|
|
145
|
+
for (const [k, v] of Object.entries(r)) {
|
|
146
|
+
if (k === 'id' || k === 'object') continue;
|
|
147
|
+
if (v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') fields[k] = v ?? null;
|
|
148
|
+
}
|
|
149
|
+
return { type, id: String(r.id), fields };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The full set of collections a full sync pulls, mapped to the REST list path + the twin
|
|
153
|
+
// resource type. Customers + subscriptions keep their bespoke mappers (id-lifting); the
|
|
154
|
+
// rest use the generic scalar mapper.
|
|
155
|
+
const PULL_COLLECTIONS: Array<{ path: string; type: string; map?: (r: Record<string, unknown>) => SyncResource; params?: Record<string, unknown> }> = [
|
|
156
|
+
{ path: '/v1/customers', type: 'customer', map: mapCustomer },
|
|
157
|
+
{ path: '/v1/subscriptions', type: 'subscription', map: mapSubscription, params: { status: 'all' } },
|
|
158
|
+
{ path: '/v1/products', type: 'product' },
|
|
159
|
+
{ path: '/v1/prices', type: 'price' },
|
|
160
|
+
{ path: '/v1/charges', type: 'charge' },
|
|
161
|
+
{ path: '/v1/payment_intents', type: 'payment_intent' },
|
|
162
|
+
{ path: '/v1/invoices', type: 'invoice' },
|
|
163
|
+
{ path: '/v1/refunds', type: 'refund' },
|
|
164
|
+
{ path: '/v1/payouts', type: 'payout' },
|
|
165
|
+
{ path: '/v1/disputes', type: 'dispute' },
|
|
166
|
+
{ path: '/v1/coupons', type: 'coupon' },
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
/** Pull real Stripe customers via the executor and map them to twin sync resources. */
|
|
170
|
+
export async function pullStripeCustomers(execute: StripeExecute, opts: { limit?: number } = {}): Promise<SyncResource[]> {
|
|
171
|
+
const res = await execute('GET', '/v1/customers', { limit: opts.limit ?? 100 });
|
|
172
|
+
throwIfError(res, 'pull customers');
|
|
173
|
+
return listOf(res).map(mapCustomer);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Pull real Stripe subscriptions via the executor and map them to twin sync resources. */
|
|
177
|
+
export async function pullStripeSubscriptions(execute: StripeExecute, opts: { limit?: number } = {}): Promise<SyncResource[]> {
|
|
178
|
+
const res = await execute('GET', '/v1/subscriptions', { status: 'all', limit: opts.limit ?? 100 });
|
|
179
|
+
throwIfError(res, 'pull subscriptions');
|
|
180
|
+
return listOf(res).map(mapSubscription);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Pull from real Stripe (customers + subscriptions) and fold into the twin (mirror
|
|
185
|
+
* seeding). syncPull's shadow-diff makes a re-pull of identical state a no-op.
|
|
186
|
+
*/
|
|
187
|
+
export async function syncStripeFromReal(
|
|
188
|
+
execute: StripeExecute,
|
|
189
|
+
opts: { root?: string; occurredAt: string; limit?: number },
|
|
190
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
191
|
+
const customers = await pullStripeCustomers(execute, { ...(opts.limit ? { limit: opts.limit } : {}) });
|
|
192
|
+
const subscriptions = await pullStripeSubscriptions(execute, { ...(opts.limit ? { limit: opts.limit } : {}) });
|
|
193
|
+
const resources = [...customers, ...subscriptions];
|
|
194
|
+
const result = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
195
|
+
return { observed: result.observed, deltasAppended: result.deltasAppended };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── PUSH ────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
// Stripe resource type → REST collection segment, for building create paths.
|
|
201
|
+
// (Pluralization isn't uniform — e.g. `dispute`→`disputes` is fine but several types
|
|
202
|
+
// the twin writes need an explicit mapping so the path matches real Stripe exactly.)
|
|
203
|
+
const COLLECTION: Record<string, string> = {
|
|
204
|
+
customer: 'customers',
|
|
205
|
+
subscription: 'subscriptions',
|
|
206
|
+
charge: 'charges',
|
|
207
|
+
product: 'products',
|
|
208
|
+
price: 'prices',
|
|
209
|
+
invoice: 'invoices',
|
|
210
|
+
payment_intent: 'payment_intents',
|
|
211
|
+
setup_intent: 'setup_intents',
|
|
212
|
+
payment_method: 'payment_methods',
|
|
213
|
+
refund: 'refunds',
|
|
214
|
+
dispute: 'disputes',
|
|
215
|
+
payout: 'payouts',
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Verb-style twin operations that map to a real-Stripe SUB-ACTION endpoint
|
|
219
|
+
// (POST /v1/<collection>/:id/<verb>) rather than a plain resource update. The twin
|
|
220
|
+
// records these verbs verbatim as the operation's suffix (see stripe-twin.ts:
|
|
221
|
+
// payment_intent.confirm, setup_intent.confirm, payment_method.detach,
|
|
222
|
+
// invoice.finalize|pay|void, dispute.close, payout.cancel). The verb here IS the
|
|
223
|
+
// real Stripe path segment, so this stays faithful as the twin grows.
|
|
224
|
+
const SUBACTION_VERBS = new Set(['confirm', 'detach', 'finalize', 'pay', 'void', 'close']);
|
|
225
|
+
|
|
226
|
+
// `cancel` is resource-specific in real Stripe: a subscription is canceled with
|
|
227
|
+
// DELETE /v1/subscriptions/:id, but a payout is canceled with POST
|
|
228
|
+
// /v1/payouts/:id/cancel. Anything else canceled via DELETE on the resource is a safe
|
|
229
|
+
// default (no other type the twin emits a `.cancel` for uses a sub-action path).
|
|
230
|
+
const CANCEL_VIA_SUBACTION = new Set(['payout']);
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolve the REST (method, path) for ONE pending action — faithful to the real
|
|
234
|
+
* Stripe REST surface for EVERY write operation the twin records. The action carries
|
|
235
|
+
* the twin operation (`<type>.<verb>`) plus its subject:
|
|
236
|
+
* - `<type>.create` → POST /v1/<collection>
|
|
237
|
+
* - `subscription.cancel` → DELETE /v1/subscriptions/:id
|
|
238
|
+
* - `payout.cancel` → POST /v1/payouts/:id/cancel (sub-action)
|
|
239
|
+
* - `payment_intent.confirm` → POST /v1/payment_intents/:id/confirm
|
|
240
|
+
* - `setup_intent.confirm` → POST /v1/setup_intents/:id/confirm
|
|
241
|
+
* - `payment_method.detach` → POST /v1/payment_methods/:id/detach
|
|
242
|
+
* - `invoice.finalize|pay|void` → POST /v1/invoices/:id/<verb>
|
|
243
|
+
* - `dispute.close` → POST /v1/disputes/:id/close
|
|
244
|
+
* - `<type>.update` (or any other verb) → POST /v1/<collection>/:id
|
|
245
|
+
*
|
|
246
|
+
* Unknown/unsupported operations must FAIL LOUDLY at push time (see pushStripeAction),
|
|
247
|
+
* never be silently dropped — so this resolver always returns a concrete request and
|
|
248
|
+
* the caller validates the operation is one the twin is allowed to push.
|
|
249
|
+
*/
|
|
250
|
+
export function stripeRequestForAction(action: Pick<TwinAction, 'operation' | 'subject'>): { method: 'POST' | 'DELETE'; path: string } {
|
|
251
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
252
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
253
|
+
const collection = COLLECTION[action.subject.type] ?? `${action.subject.type}s`;
|
|
254
|
+
const base = `/v1/${collection}`;
|
|
255
|
+
if (verb === 'create') return { method: 'POST', path: base };
|
|
256
|
+
if (verb === 'cancel') {
|
|
257
|
+
return CANCEL_VIA_SUBACTION.has(action.subject.type)
|
|
258
|
+
? { method: 'POST', path: `${base}/${action.subject.id}/cancel` }
|
|
259
|
+
: { method: 'DELETE', path: `${base}/${action.subject.id}` };
|
|
260
|
+
}
|
|
261
|
+
// confirm / detach / finalize / pay / void / close → /:id/<verb>
|
|
262
|
+
if (SUBACTION_VERBS.has(verb)) return { method: 'POST', path: `${base}/${action.subject.id}/${verb}` };
|
|
263
|
+
// update (and any other plain mutation) → POST the resource itself.
|
|
264
|
+
return { method: 'POST', path: `${base}/${action.subject.id}` };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The twin operations this connector knows how to push to real Stripe. Anything not
|
|
268
|
+
// here (a new/unmodeled write op) must FAIL LOUDLY rather than be silently dropped —
|
|
269
|
+
// pushing an unrecognized op risks hitting the wrong real endpoint or no-op'ing a
|
|
270
|
+
// real change. New twin write ops must be deliberately added here.
|
|
271
|
+
const PUSHABLE_VERBS = new Set(['create', 'update', 'cancel', ...SUBACTION_VERBS]);
|
|
272
|
+
|
|
273
|
+
/** Throw if `op` is not a write operation this connector can faithfully push. */
|
|
274
|
+
function assertPushable(op: string): void {
|
|
275
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
276
|
+
if (!PUSHABLE_VERBS.has(verb)) {
|
|
277
|
+
throw new Error(`stripe push: unsupported operation '${op}' — refusing to silently drop a local write`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Push ONE pending action to REAL Stripe via the injected executor. Returns the real
|
|
283
|
+
* external id (the Stripe object id from the response — for a create that is a freshly
|
|
284
|
+
* minted id; for an update it echoes the subject). WRITES TO THE REAL ACCOUNT.
|
|
285
|
+
*/
|
|
286
|
+
export async function pushStripeAction(
|
|
287
|
+
execute: StripeExecute,
|
|
288
|
+
action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>,
|
|
289
|
+
): Promise<{ externalId: string }> {
|
|
290
|
+
assertPushable(action.operation ?? `${action.subject.type}.update`);
|
|
291
|
+
const { method, path } = stripeRequestForAction(action);
|
|
292
|
+
const res = await execute(method, path, method === 'DELETE' ? undefined : (action.fields ?? {}));
|
|
293
|
+
throwIfError(res, `push ${action.subject.type}`);
|
|
294
|
+
const id = (res as { id?: unknown }).id;
|
|
295
|
+
return { externalId: typeof id === 'string' && id ? id : action.subject.id };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Push the twin's PENDING local actions to real Stripe and CONFIRM each (R18): for
|
|
300
|
+
* every pending `set` action, call the real API; on success, `confirmAction` records
|
|
301
|
+
* the confirmed fields as an observed event and maps action → event (suppressing the
|
|
302
|
+
* local projection). Idempotency: a confirmed action is no longer pending, so a
|
|
303
|
+
* re-push enacts NOTHING — the executor is never called twice for the same change.
|
|
304
|
+
* `execute` is injected (fake offline / live key).
|
|
305
|
+
*/
|
|
306
|
+
export async function pushPendingStripeActions(
|
|
307
|
+
execute: StripeExecute,
|
|
308
|
+
opts: { root?: string; occurredAt: string },
|
|
309
|
+
): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string> }> {
|
|
310
|
+
const confirmed: string[] = [];
|
|
311
|
+
const externalIds: Record<string, string> = {};
|
|
312
|
+
for (const action of pendingActions(SERVICE, opts.root)) {
|
|
313
|
+
// Twin-internal subject types (the recorded Stripe `event` envelopes the write path
|
|
314
|
+
// persists for the Events API, and idempotency bookkeeping) are NOT real-Stripe writes —
|
|
315
|
+
// they are local-only state and must never be pushed. Skip them (not an error).
|
|
316
|
+
if (INTERNAL_SUBJECT_TYPES.has(action.subject.type)) continue;
|
|
317
|
+
const { externalId } = await pushStripeAction(execute, action);
|
|
318
|
+
confirmAction({ service: SERVICE, actionId: action.id, subject: action.subject, fields: action.fields ?? {}, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
319
|
+
confirmed.push(action.id);
|
|
320
|
+
externalIds[action.id] = externalId;
|
|
321
|
+
}
|
|
322
|
+
return { pushed: confirmed.length, confirmed, externalIds };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── FULL SYNC (all collections + webhooks, bi-directional) ───────────────────
|
|
326
|
+
|
|
327
|
+
/** Pull a single REST list collection and map each row to a twin sync resource. */
|
|
328
|
+
async function pullCollection(execute: StripeExecute, spec: { path: string; type: string; map?: (r: Record<string, unknown>) => SyncResource; params?: Record<string, unknown> }, limit: number): Promise<SyncResource[]> {
|
|
329
|
+
const res = await execute('GET', spec.path, { ...(spec.params ?? {}), limit });
|
|
330
|
+
throwIfError(res, `pull ${spec.type}`);
|
|
331
|
+
const map = spec.map ?? ((r: Record<string, unknown>) => mapScalarResource(spec.type, r));
|
|
332
|
+
return listOf(res).map(map);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Pull EVERY tracked collection from real Stripe (not just customers + subscriptions) in
|
|
337
|
+
* one pass and return the combined sync resources. This is the read half of a full sync.
|
|
338
|
+
*/
|
|
339
|
+
export async function pullStripeAll(execute: StripeExecute, opts: { limit?: number } = {}): Promise<SyncResource[]> {
|
|
340
|
+
const limit = opts.limit ?? 100;
|
|
341
|
+
const all: SyncResource[] = [];
|
|
342
|
+
for (const spec of PULL_COLLECTIONS) all.push(...await pullCollection(execute, spec, limit));
|
|
343
|
+
return all;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Pull the account's registered webhook endpoints (so the twin mirrors them too). */
|
|
347
|
+
export async function pullStripeWebhookEndpoints(execute: StripeExecute, opts: { limit?: number } = {}): Promise<SyncResource[]> {
|
|
348
|
+
const res = await execute('GET', '/v1/webhook_endpoints', { limit: opts.limit ?? 100 });
|
|
349
|
+
throwIfError(res, 'pull webhook_endpoints');
|
|
350
|
+
return listOf(res).map((w: Record<string, unknown>) => mapScalarResource('webhook_endpoint', w));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* FULL bi-directional sync over the injected client: (1) PUSH every pending local action to
|
|
355
|
+
* real Stripe and confirm it, then (2) PULL all collections + webhook endpoints back and
|
|
356
|
+
* fold them into the event log. Pushing first means the pull observes the twin's own writes
|
|
357
|
+
* as confirmed external state (no double-count). Returns per-direction counts. Same code
|
|
358
|
+
* path offline (fake executor) and live (real key) — D4-faithful.
|
|
359
|
+
*/
|
|
360
|
+
export async function fullSyncStripe(
|
|
361
|
+
execute: StripeExecute,
|
|
362
|
+
opts: { root?: string; occurredAt: string; limit?: number },
|
|
363
|
+
): Promise<{ pushed: number; observed: number; deltasAppended: number; collections: number }> {
|
|
364
|
+
// 1. PUSH pending local changes to real Stripe (and confirm each).
|
|
365
|
+
const push = await pushPendingStripeActions(execute, { occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
366
|
+
// 2. PULL all collections + webhooks back into the twin.
|
|
367
|
+
const resources = [
|
|
368
|
+
...await pullStripeAll(execute, { ...(opts.limit ? { limit: opts.limit } : {}) }),
|
|
369
|
+
...await pullStripeWebhookEndpoints(execute, { ...(opts.limit ? { limit: opts.limit } : {}) }),
|
|
370
|
+
];
|
|
371
|
+
const pull = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
372
|
+
return { pushed: push.pushed, observed: pull.observed, deltasAppended: pull.deltasAppended, collections: PULL_COLLECTIONS.length + 1 };
|
|
373
|
+
}
|