@forklaunch/core 2.1.1 → 3.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.
Files changed (52) hide show
  1. package/bin/gateway-mock/routes/email.mjs +233 -0
  2. package/bin/gateway-mock/routes/models.mjs +192 -0
  3. package/bin/gateway-mock/routes/payments.mjs +541 -0
  4. package/bin/gateway-mock/routes/sms.mjs +209 -0
  5. package/bin/gateway-mock/routes/voice.mjs +237 -0
  6. package/bin/gateway-mock/routes/whatsapp.mjs +227 -0
  7. package/bin/gateway-mock/server.mjs +321 -0
  8. package/bin/migrate-compliant-fields.mjs +312 -0
  9. package/bin/model-gateway-mock.mjs +13 -0
  10. package/lib/http/emailClient.d.ts +102 -0
  11. package/lib/http/emailClient.d.ts.map +1 -0
  12. package/lib/http/index.d.ts +7 -0
  13. package/lib/http/index.d.ts.map +1 -1
  14. package/lib/http/index.js +850 -4
  15. package/lib/http/index.js.map +1 -1
  16. package/lib/http/index.mjs +809 -1
  17. package/lib/http/index.mjs.map +1 -1
  18. package/lib/http/instanceGateway.d.ts +113 -0
  19. package/lib/http/instanceGateway.d.ts.map +1 -0
  20. package/lib/http/modelGatewayClient.d.ts +126 -0
  21. package/lib/http/modelGatewayClient.d.ts.map +1 -0
  22. package/lib/http/paymentsClient.d.ts +123 -0
  23. package/lib/http/paymentsClient.d.ts.map +1 -0
  24. package/lib/http/smsClient.d.ts +123 -0
  25. package/lib/http/smsClient.d.ts.map +1 -0
  26. package/lib/http/voiceClient.d.ts +105 -0
  27. package/lib/http/voiceClient.d.ts.map +1 -0
  28. package/lib/http/whatsappClient.d.ts +118 -0
  29. package/lib/http/whatsappClient.d.ts.map +1 -0
  30. package/lib/objectstore/interfaces/objectstore.interface.d.ts +45 -0
  31. package/lib/objectstore/interfaces/objectstore.interface.d.ts.map +1 -1
  32. package/lib/persistence/compliancePropertyBuilder.d.ts.map +1 -1
  33. package/lib/persistence/complianceTypes.d.ts +10 -1
  34. package/lib/persistence/complianceTypes.d.ts.map +1 -1
  35. package/lib/persistence/compliantField.d.ts +203 -0
  36. package/lib/persistence/compliantField.d.ts.map +1 -0
  37. package/lib/persistence/defineComplianceEntity.d.ts.map +1 -1
  38. package/lib/persistence/encryptedType.d.ts +8 -1
  39. package/lib/persistence/encryptedType.d.ts.map +1 -1
  40. package/lib/persistence/fieldEncryptor.d.ts +47 -6
  41. package/lib/persistence/fieldEncryptor.d.ts.map +1 -1
  42. package/lib/persistence/index.d.ts +3 -2
  43. package/lib/persistence/index.d.ts.map +1 -1
  44. package/lib/persistence/index.js +987 -259
  45. package/lib/persistence/index.js.map +1 -1
  46. package/lib/persistence/index.mjs +976 -257
  47. package/lib/persistence/index.mjs.map +1 -1
  48. package/lib/persistence/keyRotation.d.ts +5 -0
  49. package/lib/persistence/keyRotation.d.ts.map +1 -1
  50. package/lib/services/index.js.map +1 -1
  51. package/lib/services/index.mjs.map +1 -1
  52. package/package.json +25 -9
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Payments: Stripe Connect through the instance gateway (createPaymentsClient
3
+ * and createStripeClient from @forklaunch/core/http).
4
+ *
5
+ * Gateway routes, HMAC-verified like the platform's:
6
+ * POST /payments/onboarding-link { returnUrl, refreshUrl } -> { url }
7
+ * creates the instance's connected account
8
+ * on first use (acct_mock_<n>)
9
+ * GET /payments/status { chargesEnabled, payoutsEnabled, requirementsDue }
10
+ * * /stripe/v1/... the Stripe API, as the real Stripe SDK
11
+ * sends it (form-encoded, signed verbatim)
12
+ *
13
+ * The proxy does what the platform does, in the same order: refuses a call
14
+ * before onboarding (409), forwards only the allowlist below (403 otherwise:
15
+ * never payouts, transfers, external accounts or account changes), refuses
16
+ * metadata keys named like protected data and SSN-shaped text (400), applies
17
+ * the rate limit (429), pins `Stripe-Account` to the instance's account
18
+ * (overwriting any the app sent), adds the application fee, and answers with
19
+ * Stripe-shaped objects kept in memory: enough for customers, products and
20
+ * prices, checkout and billing-portal sessions, payment links, subscriptions,
21
+ * payment intents, refunds and invoices. Refusals use Stripe's error body, so
22
+ * the SDK raises StripePermissionError / StripeInvalidRequestError / …
23
+ * Every call's /__mock/requests entry gets `forwarded` = { method, path,
24
+ * stripeAccount, sentStripeAccount, body } — what went upstream.
25
+ *
26
+ * Events (to MOCK_EVENTS_URL_PAYMENTS / MOCK_EVENTS_URL, as platform events):
27
+ * account.updated when onboarding completes
28
+ * checkout.session.completed POST /__mock/payments/complete/<session id>
29
+ * (pays the session: payment_status=paid, and
30
+ * a subscription for mode=subscription)
31
+ * or anything via POST /__mock/events/payments { type, data }.
32
+ * Other vendor routes: POST /__mock/payments/enable (finish onboarding by hand).
33
+ *
34
+ * MOCK_PAYMENTS_AUTO_ENABLE onboarding completes as soon as a link is made
35
+ * (default 1; 0 = wait for /__mock/payments/enable)
36
+ * MOCK_PAYMENTS_FEE_PERCENT the product's application fee, percent (default none)
37
+ * MOCK_PAYMENTS_FEE_AMOUNT or a flat fee in minor units (default none)
38
+ * MOCK_PAYMENTS_RPM Stripe calls per instance per minute (default 300)
39
+ * MOCK_STRIPE_UPSTREAM forward allowlisted calls to a Stripe-compatible
40
+ * server instead of answering in memory, e.g.
41
+ * http://localhost:12111 (docker stripe/stripe-mock)
42
+ * MOCK_STRIPE_UPSTREAM_KEY the key sent upstream (default sk_test_123)
43
+ */
44
+ import { randomBytes } from 'node:crypto';
45
+
46
+ const env = process.env;
47
+ const AUTO_ENABLE = !['0', 'false', 'no'].includes(
48
+ String(env.MOCK_PAYMENTS_AUTO_ENABLE ?? '1').toLowerCase()
49
+ );
50
+ const RPM = Number(env.MOCK_PAYMENTS_RPM ?? 300);
51
+ const FEE = {
52
+ percent: env.MOCK_PAYMENTS_FEE_PERCENT ? Number(env.MOCK_PAYMENTS_FEE_PERCENT) : undefined,
53
+ amount: env.MOCK_PAYMENTS_FEE_AMOUNT ? Number(env.MOCK_PAYMENTS_FEE_AMOUNT) : undefined
54
+ };
55
+ const UPSTREAM = env.MOCK_STRIPE_UPSTREAM?.replace(/\/+$/, '');
56
+ const UPSTREAM_KEY = env.MOCK_STRIPE_UPSTREAM_KEY ?? 'sk_test_123';
57
+
58
+ // ---------------------------------------------------------------- policy
59
+ // Mirrors forklaunch-platform managed-apps domain/services/payments-policy.ts.
60
+ // Generated from what @forklaunch/implementation-billing-stripe and the
61
+ // billing-stripe / ecommerce-stripe blueprints call, plus refunds and invoice
62
+ // reads.
63
+ const ID = '[A-Za-z0-9_]+';
64
+ export const STRIPE_ALLOWLIST = [
65
+ ['POST', '/v1/customers'],
66
+ ['GET', `/v1/customers/${ID}`],
67
+ ['POST', `/v1/customers/${ID}`],
68
+ ['POST', '/v1/products'],
69
+ ['GET', '/v1/products/search'],
70
+ ['GET', `/v1/products/${ID}`],
71
+ ['POST', `/v1/products/${ID}`],
72
+ ['POST', '/v1/prices'],
73
+ ['GET', '/v1/prices'],
74
+ ['GET', `/v1/prices/${ID}`],
75
+ ['POST', `/v1/prices/${ID}`],
76
+ ['POST', '/v1/plans'],
77
+ ['GET', `/v1/plans/${ID}`],
78
+ ['DELETE', `/v1/plans/${ID}`],
79
+ ['POST', '/v1/checkout/sessions'],
80
+ ['GET', `/v1/checkout/sessions/${ID}`],
81
+ ['POST', `/v1/checkout/sessions/${ID}`],
82
+ ['POST', `/v1/checkout/sessions/${ID}/expire`],
83
+ ['POST', '/v1/billing_portal/sessions'],
84
+ ['POST', '/v1/payment_links'],
85
+ ['GET', '/v1/payment_links'],
86
+ ['GET', `/v1/payment_links/${ID}`],
87
+ ['POST', `/v1/payment_links/${ID}`],
88
+ ['POST', '/v1/subscriptions'],
89
+ ['GET', `/v1/subscriptions/${ID}`],
90
+ ['POST', `/v1/subscriptions/${ID}`],
91
+ ['DELETE', `/v1/subscriptions/${ID}`],
92
+ ['POST', `/v1/subscriptions/${ID}/resume`],
93
+ ['POST', '/v1/payment_intents'],
94
+ ['GET', `/v1/payment_intents/${ID}`],
95
+ ['POST', '/v1/payment_methods'],
96
+ ['POST', '/v1/tax/calculations'],
97
+ ['GET', '/v1/invoices'],
98
+ ['GET', `/v1/invoices/${ID}`],
99
+ ['POST', '/v1/refunds'],
100
+ ['GET', `/v1/refunds/${ID}`]
101
+ ].map(([method, pattern]) => ({ method, re: new RegExp(`^${pattern}$`) }));
102
+
103
+ export function isAllowedStripeCall(method, path) {
104
+ return STRIPE_ALLOWLIST.some((a) => a.method === method && a.re.test(path));
105
+ }
106
+
107
+ // Metadata keys named like protected data. The gateway cannot see where a
108
+ // value came from (`.deanon` is app-side), so it refuses by NAME; the CLI's
109
+ // `payments-protected-data` check is the real guard.
110
+ const DENY_WORDS = new Set(['ssn', 'dob', 'mrn', 'icd', 'icd10', 'phi', 'pii', 'npi']);
111
+ const DENY_FRAGMENTS = [
112
+ 'socialsecurity',
113
+ 'dateofbirth',
114
+ 'birthdate',
115
+ 'diagnos',
116
+ 'medicalrecord',
117
+ 'medication',
118
+ 'prescription',
119
+ 'allerg',
120
+ 'insurance',
121
+ 'symptom',
122
+ 'treatment',
123
+ 'labresult',
124
+ 'healthcondition',
125
+ 'patientname'
126
+ ];
127
+ const SSN = /\b\d{3}-\d{2}-\d{4}\b/;
128
+
129
+ function keyWords(key) {
130
+ return key
131
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
132
+ .toLowerCase()
133
+ .split(/[^a-z0-9]+/)
134
+ .filter(Boolean);
135
+ }
136
+
137
+ export function protectedDataViolation(params) {
138
+ for (const [key, value] of params) {
139
+ const meta = /(?:^metadata|\[metadata\])\[([^\]]+)\]$/.exec(key);
140
+ if (meta) {
141
+ const words = keyWords(meta[1]);
142
+ const joined = words.join('');
143
+ if (
144
+ words.some((w) => DENY_WORDS.has(w)) ||
145
+ DENY_FRAGMENTS.some((f) => joined.includes(f))
146
+ ) {
147
+ return `metadata key '${meta[1]}' looks like protected health or personal data; keep it in the app and reference it by an opaque id`;
148
+ }
149
+ }
150
+ const field = /(?:^|\[)(description|statement_descriptor(?:_suffix)?)\]?$/.exec(key);
151
+ if ((meta || field) && SSN.test(value)) {
152
+ return `${key} contains an SSN-shaped value`;
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /** The product's application fee, set on the calls that create a charge. */
159
+ export function applyApplicationFee(method, path, params, fee) {
160
+ if (method !== 'POST' || (fee.percent == null && fee.amount == null)) return;
161
+ const put = (k, v) => params.set(k, String(v));
162
+ if (path === '/v1/checkout/sessions') {
163
+ if (params.get('mode') === 'subscription') {
164
+ if (fee.percent != null) put('subscription_data[application_fee_percent]', fee.percent);
165
+ } else if (fee.amount != null) {
166
+ put('payment_intent_data[application_fee_amount]', fee.amount);
167
+ }
168
+ } else if (path === '/v1/payment_intents') {
169
+ const amount = Number(params.get('amount') ?? 0);
170
+ const value = fee.amount ?? Math.round((amount * fee.percent) / 100);
171
+ put('application_fee_amount', value);
172
+ } else if (path === '/v1/subscriptions' && fee.percent != null) {
173
+ put('application_fee_percent', fee.percent);
174
+ }
175
+ }
176
+
177
+ // ------------------------------------------------------------- form + ids
178
+
179
+ /** Stripe's bracketed form encoding -> nested objects and arrays. */
180
+ export function parseForm(raw) {
181
+ const out = {};
182
+ for (const [key, value] of new URLSearchParams(raw ?? '')) {
183
+ const parts = key.split(/\[|\]\[|\]/).filter((p, i) => i === 0 || p !== '');
184
+ let node = out;
185
+ parts.forEach((part, i) => {
186
+ const last = i === parts.length - 1;
187
+ const nextIsIndex = !last && /^\d+$/.test(parts[i + 1]);
188
+ if (last) {
189
+ node[part] = value;
190
+ } else {
191
+ node[part] ??= nextIsIndex ? [] : {};
192
+ node = node[part];
193
+ }
194
+ });
195
+ }
196
+ return coerce(out);
197
+ }
198
+
199
+ function coerce(value, inMetadata = false) {
200
+ if (Array.isArray(value)) return value.map((v) => coerce(v, inMetadata));
201
+ if (value && typeof value === 'object') {
202
+ return Object.fromEntries(
203
+ Object.entries(value).map(([k, v]) => [k, coerce(v, inMetadata || k === 'metadata')])
204
+ );
205
+ }
206
+ if (inMetadata || typeof value !== 'string') return value;
207
+ if (/^-?\d+$/.test(value)) return Number(value);
208
+ if (value === 'true' || value === 'false') return value === 'true';
209
+ return value;
210
+ }
211
+
212
+ const rid = (prefix) => `${prefix}_${randomBytes(12).toString('hex').slice(0, 24)}`;
213
+ const now = () => Math.floor(Date.now() / 1000);
214
+
215
+ // --------------------------------------------------------------- objects
216
+
217
+ const RESOURCES = {
218
+ customers: { prefix: 'cus', object: 'customer' },
219
+ products: { prefix: 'prod', object: 'product', base: { active: true } },
220
+ prices: { prefix: 'price', object: 'price', base: { active: true, currency: 'usd' } },
221
+ plans: { prefix: 'plan', object: 'plan', base: { active: true } },
222
+ 'checkout/sessions': { prefix: 'cs_test', object: 'checkout.session' },
223
+ 'billing_portal/sessions': { prefix: 'bps', object: 'billing_portal.session' },
224
+ payment_links: { prefix: 'plink', object: 'payment_link', base: { active: true } },
225
+ subscriptions: { prefix: 'sub', object: 'subscription' },
226
+ payment_intents: { prefix: 'pi', object: 'payment_intent' },
227
+ payment_methods: { prefix: 'pm', object: 'payment_method' },
228
+ 'tax/calculations': { prefix: 'taxcalc', object: 'tax.calculation' },
229
+ invoices: { prefix: 'in', object: 'invoice' },
230
+ refunds: { prefix: 're', object: 'refund' }
231
+ };
232
+ const RESOURCE_KEYS = Object.keys(RESOURCES).sort((a, b) => b.length - a.length);
233
+
234
+ function splitPath(path) {
235
+ const rest = path.replace(/^\/v1\//, '');
236
+ const key = RESOURCE_KEYS.find((k) => rest === k || rest.startsWith(`${k}/`));
237
+ if (!key) return null;
238
+ const [id, action] = rest.slice(key.length + 1).split('/').filter(Boolean);
239
+ return { key, id, action };
240
+ }
241
+
242
+ function lineItemsTotal(store, items = []) {
243
+ return items.reduce((sum, item) => {
244
+ const price = store.objects.get(item.price);
245
+ return sum + Number(price?.unit_amount ?? 0) * Number(item.quantity ?? 1);
246
+ }, 0);
247
+ }
248
+
249
+ function created(store, key, params, account) {
250
+ const spec = RESOURCES[key];
251
+ const id = rid(spec.prefix);
252
+ const obj = {
253
+ id,
254
+ object: spec.object,
255
+ created: now(),
256
+ livemode: false,
257
+ metadata: {},
258
+ ...spec.base,
259
+ ...params
260
+ };
261
+ delete obj.expand;
262
+ switch (key) {
263
+ case 'prices':
264
+ obj.type = params.recurring ? 'recurring' : 'one_time';
265
+ break;
266
+ case 'checkout/sessions':
267
+ Object.assign(obj, {
268
+ status: 'open',
269
+ payment_status: 'unpaid',
270
+ mode: params.mode ?? 'payment',
271
+ amount_total: lineItemsTotal(store, params.line_items),
272
+ currency: 'usd',
273
+ url: `https://checkout.stripe.com/c/pay/${id}`,
274
+ payment_intent: null,
275
+ subscription: null
276
+ });
277
+ delete obj.line_items;
278
+ break;
279
+ case 'billing_portal/sessions':
280
+ obj.url = `https://billing.stripe.com/p/session/test_${id}`;
281
+ break;
282
+ case 'payment_links':
283
+ obj.url = `https://buy.stripe.com/test_${id}`;
284
+ delete obj.line_items;
285
+ break;
286
+ case 'subscriptions':
287
+ Object.assign(obj, {
288
+ status: 'active',
289
+ current_period_start: now(),
290
+ items: {
291
+ object: 'list',
292
+ data: (params.items ?? []).map((item) => ({
293
+ id: rid('si'),
294
+ object: 'subscription_item',
295
+ price: store.objects.get(item.price) ?? { id: item.price },
296
+ quantity: item.quantity ?? 1
297
+ }))
298
+ }
299
+ });
300
+ break;
301
+ case 'payment_intents':
302
+ Object.assign(obj, {
303
+ status: 'requires_payment_method',
304
+ client_secret: `${id}_secret_${randomBytes(8).toString('hex')}`
305
+ });
306
+ break;
307
+ case 'tax/calculations':
308
+ Object.assign(obj, {
309
+ amount_total: (params.line_items ?? []).reduce((s, l) => s + Number(l.amount ?? 0), 0),
310
+ tax_amount_exclusive: 0,
311
+ tax_amount_inclusive: 0
312
+ });
313
+ break;
314
+ case 'refunds':
315
+ obj.status = 'succeeded';
316
+ break;
317
+ }
318
+ obj.__account = account;
319
+ store.objects.set(id, obj);
320
+ return obj;
321
+ }
322
+
323
+ const visible = (obj) => {
324
+ if (!obj) return obj;
325
+ const { __account, ...rest } = obj;
326
+ return rest;
327
+ };
328
+
329
+ function missing(ctx, id) {
330
+ return stripeError(ctx, 404, 'invalid_request_error', `No such object: '${id}'`, 'resource_missing');
331
+ }
332
+
333
+ function stripeError(ctx, status, type, message, code, headers = {}) {
334
+ return ctx.send(
335
+ status,
336
+ { error: { type, message, ...(code ? { code } : {}) } },
337
+ { 'stripe-should-retry': 'false', ...headers }
338
+ );
339
+ }
340
+
341
+ function answerInMemory(ctx, method, path, params, account) {
342
+ const store = ctx.state;
343
+ const where = splitPath(path);
344
+ if (!where) return stripeError(ctx, 404, 'invalid_request_error', `Unrecognized request URL (${method}: ${path})`);
345
+ const { key, id, action } = where;
346
+ const owned = (o) => o && o.__account === account;
347
+ const list = () =>
348
+ [...store.objects.values()].filter(
349
+ (o) => owned(o) && o.object === RESOURCES[key].object
350
+ );
351
+
352
+ if (method === 'POST' && !id) return ctx.send(200, visible(created(store, key, params, account)));
353
+ if (method === 'GET' && !id) {
354
+ let data = list();
355
+ if (params.active !== undefined) data = data.filter((o) => o.active === params.active);
356
+ if (params.product) data = data.filter((o) => o.product === params.product);
357
+ const limit = Number(params.limit ?? 10);
358
+ return ctx.send(200, {
359
+ object: 'list',
360
+ url: path,
361
+ has_more: data.length > limit,
362
+ data: data.slice(0, limit).map(visible)
363
+ });
364
+ }
365
+ if (method === 'GET' && id === 'search') {
366
+ const data = list();
367
+ return ctx.send(200, { object: 'search_result', url: path, has_more: false, data: data.map(visible) });
368
+ }
369
+ const obj = store.objects.get(id);
370
+ if (!owned(obj)) return missing(ctx, id);
371
+ if (method === 'GET') return ctx.send(200, visible(obj));
372
+ if (method === 'DELETE') {
373
+ if (key === 'subscriptions') {
374
+ obj.status = 'canceled';
375
+ obj.canceled_at = now();
376
+ return ctx.send(200, visible(obj));
377
+ }
378
+ store.objects.delete(id);
379
+ return ctx.send(200, { id, object: RESOURCES[key].object, deleted: true });
380
+ }
381
+ if (action === 'expire') obj.status = 'expired';
382
+ else if (action === 'resume') obj.status = 'active';
383
+ else {
384
+ const { metadata, expand, ...rest } = params;
385
+ Object.assign(obj, rest);
386
+ if (metadata) obj.metadata = { ...obj.metadata, ...metadata };
387
+ }
388
+ return ctx.send(200, visible(obj));
389
+ }
390
+
391
+ async function answerUpstream(ctx, method, path, query, form, account) {
392
+ const headers = {
393
+ authorization: `Bearer ${UPSTREAM_KEY}`,
394
+ 'stripe-account': account
395
+ };
396
+ for (const h of ['stripe-version', 'idempotency-key']) {
397
+ if (ctx.req.headers[h]) headers[h] = ctx.req.headers[h];
398
+ }
399
+ if (form) headers['content-type'] = 'application/x-www-form-urlencoded';
400
+ const response = await fetch(`${UPSTREAM}${path}${query}`, {
401
+ method,
402
+ headers,
403
+ body: form || undefined
404
+ });
405
+ const text = await response.text();
406
+ return ctx.send(response.status, JSON.parse(text || '{}'), {
407
+ 'request-id': response.headers.get('request-id') ?? ''
408
+ });
409
+ }
410
+
411
+ // ---------------------------------------------------------------- routes
412
+
413
+ function accountFor(ctx) {
414
+ ctx.state.accounts ??= {};
415
+ ctx.state.objects ??= new Map();
416
+ return ctx.state.accounts[ctx.instanceId];
417
+ }
418
+
419
+ function enable(ctx, account) {
420
+ if (account.chargesEnabled) return;
421
+ Object.assign(account, {
422
+ chargesEnabled: true,
423
+ payoutsEnabled: true,
424
+ detailsSubmitted: true,
425
+ requirementsDue: []
426
+ });
427
+ return ctx.emit('account.updated', {
428
+ id: account.id,
429
+ object: 'account',
430
+ charges_enabled: true,
431
+ payouts_enabled: true,
432
+ details_submitted: true,
433
+ requirements: { currently_due: [] }
434
+ });
435
+ }
436
+
437
+ async function onboardingLink(ctx) {
438
+ const { returnUrl, refreshUrl } = ctx.body ?? {};
439
+ for (const [name, value] of [['returnUrl', returnUrl], ['refreshUrl', refreshUrl]]) {
440
+ if (typeof value !== 'string' || !/^https?:\/\//.test(value)) {
441
+ return ctx.send(400, `${name} must be an absolute http(s) URL`);
442
+ }
443
+ }
444
+ let account = accountFor(ctx);
445
+ if (!account) {
446
+ ctx.state.accountCount = (ctx.state.accountCount ?? 0) + 1;
447
+ account = ctx.state.accounts[ctx.instanceId] = {
448
+ id: `acct_mock_${String(ctx.state.accountCount).padStart(4, '0')}`,
449
+ chargesEnabled: false,
450
+ payoutsEnabled: false,
451
+ detailsSubmitted: false,
452
+ requirementsDue: ['business_profile.url', 'external_account', 'tos_acceptance.date']
453
+ };
454
+ }
455
+ const url = `https://connect.stripe.com/setup/e/${account.id}/mock_${randomBytes(6).toString('hex')}`;
456
+ ctx.send(200, { url });
457
+ if (AUTO_ENABLE) await enable(ctx, account);
458
+ }
459
+
460
+ function status(ctx) {
461
+ const account = accountFor(ctx);
462
+ return ctx.send(200, {
463
+ chargesEnabled: Boolean(account?.chargesEnabled),
464
+ payoutsEnabled: Boolean(account?.payoutsEnabled),
465
+ requirementsDue: account ? account.requirementsDue : ['onboarding']
466
+ });
467
+ }
468
+
469
+ async function stripeProxy(ctx) {
470
+ const method = ctx.req.method;
471
+ const path = ctx.path.replace(/^\/instance-gateway\/stripe/, '');
472
+ const query = new URL(ctx.req.url ?? '/', 'http://mock').search;
473
+ const account = accountFor(ctx);
474
+ if (!account) {
475
+ return stripeError(ctx, 409, 'invalid_request_error', 'Payments are not set up for this instance: create an onboarding link first', 'payments_not_onboarded');
476
+ }
477
+ if (!isAllowedStripeCall(method, path)) {
478
+ return stripeError(ctx, 403, 'invalid_request_error', `${method} ${path} is not available to managed instances`, 'forklaunch_gateway_forbidden');
479
+ }
480
+ const params = new URLSearchParams(method === 'GET' || method === 'DELETE' ? query.slice(1) : ctx.raw);
481
+ const violation = protectedDataViolation(params);
482
+ if (violation) {
483
+ return stripeError(ctx, 400, 'invalid_request_error', violation, 'forklaunch_protected_data');
484
+ }
485
+ const minute = Math.floor(Date.now() / 60_000);
486
+ ctx.state.rate ??= {};
487
+ const rateKey = `${ctx.instanceId}:${minute}`;
488
+ ctx.state.rate[rateKey] = (ctx.state.rate[rateKey] ?? 0) + 1;
489
+ if (ctx.state.rate[rateKey] > RPM) {
490
+ return stripeError(ctx, 429, 'rate_limit_error', `Rate limited: ${RPM} Stripe calls per minute`, 'rate_limit', { 'retry-after': '60' });
491
+ }
492
+ applyApplicationFee(method, path, params, FEE);
493
+ const form = method === 'GET' || method === 'DELETE' ? '' : params.toString();
494
+ ctx.record.forwarded = {
495
+ method,
496
+ path: `${path}${query}`,
497
+ stripeAccount: account.id,
498
+ sentStripeAccount: ctx.req.headers['stripe-account'] ?? null,
499
+ body: form || undefined
500
+ };
501
+ if (UPSTREAM) return answerUpstream(ctx, method, path, query, form, account.id);
502
+ return answerInMemory(ctx, method, path, parseForm(form || query.slice(1)), account.id);
503
+ }
504
+
505
+ async function complete(ctx) {
506
+ ctx.state.objects ??= new Map();
507
+ const session = ctx.state.objects.get(ctx.params.id);
508
+ if (!session || session.object !== 'checkout.session') return ctx.send(404, `No such checkout session: ${ctx.params.id}`);
509
+ if (session.status !== 'open') return ctx.send(409, `Checkout session is ${session.status}`);
510
+ Object.assign(session, { status: 'complete', payment_status: 'paid' });
511
+ if (session.mode === 'subscription') {
512
+ session.subscription = created(ctx.state, 'subscriptions', { customer: session.customer, metadata: session.metadata }, session.__account).id;
513
+ } else {
514
+ const intent = created(ctx.state, 'payment_intents', { amount: session.amount_total, currency: session.currency, customer: session.customer }, session.__account);
515
+ intent.status = 'succeeded';
516
+ session.payment_intent = intent.id;
517
+ }
518
+ const delivery = await ctx.emit('checkout.session.completed', visible(session));
519
+ return ctx.send(200, { session: visible(session), delivery });
520
+ }
521
+
522
+ async function enableByHand(ctx) {
523
+ const accounts = Object.values(ctx.state.accounts ?? {});
524
+ for (const account of accounts) await enable(ctx, account);
525
+ return ctx.send(200, { enabled: accounts.map((a) => a.id) });
526
+ }
527
+
528
+ const PROXY = /^\/stripe\/v1\/.+$/;
529
+
530
+ export default {
531
+ feature: 'payments',
532
+ routes: [
533
+ { method: 'POST', path: '/payments/onboarding-link', handler: onboardingLink },
534
+ { method: 'GET', path: '/payments/status', handler: status },
535
+ ...['GET', 'POST', 'DELETE'].map((method) => ({ method, path: PROXY, handler: stripeProxy }))
536
+ ],
537
+ vendorRoutes: [
538
+ { method: 'POST', path: /^\/__mock\/payments\/complete\/(?<id>[A-Za-z0-9_]+)$/, handler: complete },
539
+ { method: 'POST', path: '/__mock/payments/enable', handler: enableByHand }
540
+ ]
541
+ };