@volter/twin-stripe 0.1.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/README.md +96 -27
  2. package/client/dashboard-api.ts +286 -0
  3. package/client/stripe-mirror.css +272 -159
  4. package/client/stripe-mirror.tsx +1384 -541
  5. package/dist/client/dashboard-api.d.ts +107 -0
  6. package/dist/client/dashboard-api.js +238 -0
  7. package/dist/client/dashboard-api.ts +286 -0
  8. package/dist/client/stripe-mirror.bundle.js +236 -0
  9. package/dist/client/stripe-mirror.css +275 -0
  10. package/dist/client/stripe-mirror.d.ts +134 -0
  11. package/dist/client/stripe-mirror.js +823 -0
  12. package/dist/client/stripe-mirror.tsx +1534 -0
  13. package/dist/src/cli.d.ts +2 -0
  14. package/dist/src/cli.js +39 -0
  15. package/dist/src/generated/events.gen.json +1 -0
  16. package/dist/src/generated/surface.gen.json +1 -0
  17. package/dist/src/generated/ui.gen.json +1 -0
  18. package/dist/src/index.d.ts +14 -0
  19. package/dist/src/index.js +75 -0
  20. package/dist/src/manifest.d.ts +2 -0
  21. package/dist/src/manifest.js +1070 -0
  22. package/dist/src/screens/checkout.d.ts +31 -0
  23. package/dist/src/screens/checkout.js +255 -0
  24. package/dist/src/screens/connect-oauth.d.ts +27 -0
  25. package/dist/src/screens/connect-oauth.js +414 -0
  26. package/dist/src/screens/connect-settings.d.ts +22 -0
  27. package/dist/src/screens/connect-settings.js +103 -0
  28. package/dist/src/screens/consent-skin.d.ts +4 -0
  29. package/dist/src/screens/consent-skin.js +18 -0
  30. package/dist/src/screens/financial-connections.d.ts +5 -0
  31. package/dist/src/screens/financial-connections.js +90 -0
  32. package/dist/src/screens/identity.d.ts +5 -0
  33. package/dist/src/screens/identity.js +86 -0
  34. package/dist/src/screens/industries.d.ts +1 -0
  35. package/dist/src/screens/industries.js +267 -0
  36. package/dist/src/screens/onboarding.d.ts +13 -0
  37. package/dist/src/screens/onboarding.js +225 -0
  38. package/dist/src/screens/portal.d.ts +5 -0
  39. package/dist/src/screens/portal.js +216 -0
  40. package/dist/src/screens/public-details.d.ts +5 -0
  41. package/dist/src/screens/public-details.js +90 -0
  42. package/dist/src/semantics/after-payment.d.ts +22 -0
  43. package/dist/src/semantics/after-payment.js +99 -0
  44. package/dist/src/semantics/apps-secrets.d.ts +2 -0
  45. package/dist/src/semantics/apps-secrets.js +54 -0
  46. package/dist/src/semantics/balance.d.ts +11 -0
  47. package/dist/src/semantics/balance.js +195 -0
  48. package/dist/src/semantics/billing.d.ts +2 -0
  49. package/dist/src/semantics/billing.js +220 -0
  50. package/dist/src/semantics/charges.d.ts +28 -0
  51. package/dist/src/semantics/charges.js +209 -0
  52. package/dist/src/semantics/checkout.d.ts +15 -0
  53. package/dist/src/semantics/checkout.js +316 -0
  54. package/dist/src/semantics/connect.d.ts +5 -0
  55. package/dist/src/semantics/connect.js +493 -0
  56. package/dist/src/semantics/coupons.d.ts +6 -0
  57. package/dist/src/semantics/coupons.js +92 -0
  58. package/dist/src/semantics/credit-notes.d.ts +2 -0
  59. package/dist/src/semantics/credit-notes.js +172 -0
  60. package/dist/src/semantics/customers.d.ts +6 -0
  61. package/dist/src/semantics/customers.js +429 -0
  62. package/dist/src/semantics/disputes.d.ts +2 -0
  63. package/dist/src/semantics/disputes.js +51 -0
  64. package/dist/src/semantics/entitlements.d.ts +2 -0
  65. package/dist/src/semantics/entitlements.js +95 -0
  66. package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
  67. package/dist/src/semantics/ephemeral-keys.js +34 -0
  68. package/dist/src/semantics/files.d.ts +2 -0
  69. package/dist/src/semantics/files.js +125 -0
  70. package/dist/src/semantics/invoices.d.ts +18 -0
  71. package/dist/src/semantics/invoices.js +545 -0
  72. package/dist/src/semantics/issuing.d.ts +13 -0
  73. package/dist/src/semantics/issuing.js +575 -0
  74. package/dist/src/semantics/ledger.d.ts +59 -0
  75. package/dist/src/semantics/ledger.js +200 -0
  76. package/dist/src/semantics/payment-intents.d.ts +18 -0
  77. package/dist/src/semantics/payment-intents.js +404 -0
  78. package/dist/src/semantics/payment-links.d.ts +2 -0
  79. package/dist/src/semantics/payment-links.js +133 -0
  80. package/dist/src/semantics/payment-methods.d.ts +20 -0
  81. package/dist/src/semantics/payment-methods.js +140 -0
  82. package/dist/src/semantics/plans.d.ts +5 -0
  83. package/dist/src/semantics/plans.js +121 -0
  84. package/dist/src/semantics/platform.d.ts +9 -0
  85. package/dist/src/semantics/platform.js +206 -0
  86. package/dist/src/semantics/products.d.ts +2 -0
  87. package/dist/src/semantics/products.js +140 -0
  88. package/dist/src/semantics/radar.d.ts +2 -0
  89. package/dist/src/semantics/radar.js +83 -0
  90. package/dist/src/semantics/refunds.d.ts +9 -0
  91. package/dist/src/semantics/refunds.js +195 -0
  92. package/dist/src/semantics/renewals.d.ts +47 -0
  93. package/dist/src/semantics/renewals.js +251 -0
  94. package/dist/src/semantics/setup-intents.d.ts +2 -0
  95. package/dist/src/semantics/setup-intents.js +84 -0
  96. package/dist/src/semantics/shared.d.ts +82 -0
  97. package/dist/src/semantics/shared.js +203 -0
  98. package/dist/src/semantics/subscription-schedules.d.ts +2 -0
  99. package/dist/src/semantics/subscription-schedules.js +119 -0
  100. package/dist/src/semantics/subscriptions.d.ts +11 -0
  101. package/dist/src/semantics/subscriptions.js +605 -0
  102. package/dist/src/semantics/tax.d.ts +2 -0
  103. package/dist/src/semantics/tax.js +197 -0
  104. package/dist/src/semantics/terminal.d.ts +5 -0
  105. package/dist/src/semantics/terminal.js +182 -0
  106. package/dist/src/semantics/test-cards.d.ts +4 -0
  107. package/dist/src/semantics/test-cards.js +7 -0
  108. package/dist/src/semantics/test-clocks.d.ts +6 -0
  109. package/dist/src/semantics/test-clocks.js +73 -0
  110. package/dist/src/semantics/tokens.d.ts +4 -0
  111. package/dist/src/semantics/tokens.js +44 -0
  112. package/dist/src/semantics/transfers.d.ts +2 -0
  113. package/dist/src/semantics/transfers.js +154 -0
  114. package/dist/src/semantics/treasury.d.ts +2 -0
  115. package/dist/src/semantics/treasury.js +377 -0
  116. package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
  117. package/dist/src/semantics/webhook-endpoints.js +85 -0
  118. package/dist/src/stripe-budget.d.ts +55 -0
  119. package/dist/src/stripe-budget.js +155 -0
  120. package/dist/src/stripe-capabilities.d.ts +3 -0
  121. package/dist/src/stripe-capabilities.js +5695 -0
  122. package/dist/src/stripe-conformance.d.ts +43 -0
  123. package/dist/src/stripe-conformance.js +105 -0
  124. package/dist/src/stripe-connector.d.ts +161 -0
  125. package/dist/src/stripe-connector.js +414 -0
  126. package/dist/src/stripe-emit.d.ts +2 -0
  127. package/dist/src/stripe-emit.js +145 -0
  128. package/dist/src/stripe-events.d.ts +93 -0
  129. package/dist/src/stripe-events.js +392 -0
  130. package/dist/src/stripe-js.d.ts +4 -0
  131. package/dist/src/stripe-js.js +70 -0
  132. package/dist/src/stripe-mirror-ui.d.ts +15 -0
  133. package/dist/src/stripe-mirror-ui.js +87 -0
  134. package/dist/src/stripe-params.d.ts +3 -0
  135. package/dist/src/stripe-params.js +43 -0
  136. package/dist/src/stripe-perform-harness.d.ts +9 -0
  137. package/dist/src/stripe-perform-harness.js +26 -0
  138. package/dist/src/stripe-server.d.ts +33 -0
  139. package/dist/src/stripe-server.js +393 -0
  140. package/dist/src/stripe-shared.d.ts +109 -0
  141. package/dist/src/stripe-shared.js +276 -0
  142. package/dist/src/stripe-twin.d.ts +155 -0
  143. package/dist/src/stripe-twin.js +1232 -0
  144. package/dist/src/stripe-ui-conformance.d.ts +5 -0
  145. package/dist/src/stripe-ui-conformance.js +79 -0
  146. package/dist/src/stripe-ui-structure.d.ts +3 -0
  147. package/dist/src/stripe-ui-structure.js +168 -0
  148. package/dist/src/stripe-version.d.ts +12 -0
  149. package/dist/src/stripe-version.js +287 -0
  150. package/dist/test-fixtures/stripe-known-deviations.json +110 -0
  151. package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
  152. package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
  153. package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
  154. package/dist/test-fixtures/stripe-schemas.json +3813 -0
  155. package/package.json +18 -10
  156. package/src/cli.ts +7 -7
  157. package/src/generated/events.gen.json +1 -0
  158. package/src/generated/surface.gen.json +1 -0
  159. package/src/generated/ui.gen.json +1 -0
  160. package/src/index.ts +34 -10
  161. package/src/manifest.ts +1102 -0
  162. package/src/screens/checkout.tsx +267 -0
  163. package/src/screens/connect-oauth.tsx +400 -0
  164. package/src/screens/connect-settings.tsx +121 -0
  165. package/src/screens/consent-skin.ts +20 -0
  166. package/src/screens/financial-connections.tsx +101 -0
  167. package/src/screens/identity.tsx +96 -0
  168. package/src/screens/industries.ts +267 -0
  169. package/src/screens/onboarding.tsx +243 -0
  170. package/src/screens/portal.tsx +220 -0
  171. package/src/screens/public-details.tsx +105 -0
  172. package/src/semantics/after-payment.ts +118 -0
  173. package/src/semantics/apps-secrets.ts +58 -0
  174. package/src/semantics/balance.ts +209 -0
  175. package/src/semantics/billing.ts +216 -0
  176. package/src/semantics/charges.ts +220 -0
  177. package/src/semantics/checkout.ts +310 -0
  178. package/src/semantics/connect.ts +487 -0
  179. package/src/semantics/coupons.ts +97 -0
  180. package/src/semantics/credit-notes.ts +168 -0
  181. package/src/semantics/customers.ts +432 -0
  182. package/src/semantics/disputes.ts +62 -0
  183. package/src/semantics/entitlements.ts +94 -0
  184. package/src/semantics/ephemeral-keys.ts +34 -0
  185. package/src/semantics/files.ts +143 -0
  186. package/src/semantics/invoices.ts +545 -0
  187. package/src/semantics/issuing.ts +590 -0
  188. package/src/semantics/ledger.ts +253 -0
  189. package/src/semantics/payment-intents.ts +420 -0
  190. package/src/semantics/payment-links.ts +148 -0
  191. package/src/semantics/payment-methods.ts +145 -0
  192. package/src/semantics/plans.ts +131 -0
  193. package/src/semantics/platform.ts +220 -0
  194. package/src/semantics/products.ts +154 -0
  195. package/src/semantics/radar.ts +85 -0
  196. package/src/semantics/refunds.ts +218 -0
  197. package/src/semantics/renewals.ts +274 -0
  198. package/src/semantics/setup-intents.ts +87 -0
  199. package/src/semantics/shared.ts +226 -0
  200. package/src/semantics/subscription-schedules.ts +129 -0
  201. package/src/semantics/subscriptions.ts +610 -0
  202. package/src/semantics/tax.ts +220 -0
  203. package/src/semantics/terminal.ts +195 -0
  204. package/src/semantics/test-cards.ts +7 -0
  205. package/src/semantics/test-clocks.ts +77 -0
  206. package/src/semantics/tokens.ts +52 -0
  207. package/src/semantics/transfers.ts +174 -0
  208. package/src/semantics/treasury.ts +383 -0
  209. package/src/semantics/webhook-endpoints.ts +87 -0
  210. package/src/stripe-budget.ts +4 -4
  211. package/src/stripe-capabilities.ts +2258 -380
  212. package/src/stripe-conformance.ts +19 -7
  213. package/src/stripe-connector.ts +68 -40
  214. package/src/stripe-emit.ts +15 -8
  215. package/src/stripe-events.ts +102 -40
  216. package/src/stripe-js.ts +70 -0
  217. package/src/stripe-mirror-ui.ts +28 -298
  218. package/src/stripe-params.ts +44 -0
  219. package/src/stripe-perform-harness.ts +29 -0
  220. package/src/stripe-server.ts +318 -38
  221. package/src/stripe-shared.ts +297 -0
  222. package/src/stripe-twin.ts +434 -5325
  223. package/src/stripe-ui-conformance.ts +70 -107
  224. package/src/stripe-ui-structure.ts +124 -348
  225. package/src/stripe-version.ts +281 -0
  226. package/test-fixtures/stripe-known-deviations.json +8 -8
  227. package/test-fixtures/stripe-openapi-operations.json +1188 -2855
  228. package/test-fixtures/stripe-schemas.json +85 -12
  229. package/src/stripe-form.ts +0 -35
@@ -0,0 +1,493 @@
1
+ import { ACCOUNT_TYPES, accountCapabilities, accountRequirements, accountSettings, asBool, PLATFORM_ACCOUNT_ID } from "../stripe-twin.js";
2
+ import { currentlyDue, kindOf } from "../screens/onboarding.js";
3
+ import { at, created, externalList, fail, list, newest, path, syncExternals } from "./shared.js";
4
+ import { OAUTH_CONNECTIONS } from "../stripe-shared.js";
5
+ const accountMissing = (ctx, id, status = 404) => fail(ctx, `No such account: '${id}'`, status, 'resource_missing');
6
+ /** The account, tombstones included where the hand-written routes looked past a deletion. */
7
+ const accountRow = (ctx, id) => ctx.row('account', id, { withDeleted: true });
8
+ // the platform's own account: stored once it is written, otherwise Stripe's default. It "controls itself" (controller
9
+ // type `account`, docs.stripe.com/api/accounts/object), is fully onboarded with nothing due, and takes card payments
10
+ // and transfers. Where the documentation stops and the twin decides: it was created as the World began (2026-01-01),
11
+ // and its bank account, which its automatic payouts reach, is not modelled, so it lists none.
12
+ const PLATFORM_CREATED = 1_767_225_600;
13
+ /** The platform's own account as Stripe's default has it, before anything is written to it. */
14
+ export const platformAccountDefault = () => ({
15
+ object: 'account', id: PLATFORM_ACCOUNT_ID, type: 'standard', country: 'US', default_currency: 'usd', created: PLATFORM_CREATED,
16
+ charges_enabled: true, payouts_enabled: true, details_submitted: true, email: null, metadata: {},
17
+ capabilities: { card_payments: 'active', transfers: 'active' }, controller: { type: 'account' },
18
+ requirements: { alternatives: [], current_deadline: null, currently_due: [], disabled_reason: null, errors: [], eventually_due: [], past_due: [], pending_verification: [] },
19
+ future_requirements: { alternatives: [], current_deadline: null, currently_due: [], disabled_reason: null, errors: [], eventually_due: [], past_due: [], pending_verification: [] },
20
+ external_accounts: externalList(PLATFORM_ACCOUNT_ID, []), tos_acceptance: { date: null, ip: null, user_agent: null }, business_profile: {},
21
+ settings: accountSettings(undefined), livemode: false,
22
+ });
23
+ // GET /v1/account answers the account the request acts as: with the Stripe-Account header (or a connected account's OAuth
24
+ // key, which acts as it: stripe-server.ts) that connected account, "Retrieves the details of an account"
25
+ // (docs.stripe.com/api/accounts/retrieve; docs.stripe.com/connect/authentication), else the platform's own
26
+ const platform = async (ctx) => {
27
+ const acting = ctx.call.request.headers.get('stripe-account');
28
+ const connected = acting && acting !== PLATFORM_ACCOUNT_ID ? ctx.get('account', acting) : undefined;
29
+ // a Stripe-Account naming no account is refused as Stripe refuses a Stripe-Account the key cannot use (account_invalid,
30
+ // docs.stripe.com/error-codes; stripe-server.ts revokedAccountRefused words it the same)
31
+ if (acting && acting !== PLATFORM_ACCOUNT_ID && !connected)
32
+ return fail(ctx, `The provided key does not have access to account '${acting}' (or that account does not exist). Application access may have been revoked.`, 403, 'account_invalid');
33
+ return ctx.reply(connected ?? ctx.get('account', PLATFORM_ACCOUNT_ID) ?? platformAccountDefault());
34
+ };
35
+ /** A new Express account owes what its hosted onboarding will ask for (screens/onboarding.tsx; the US sets
36
+ * docs.stripe.com/connect/required-verification-information lists), or, where the twin does not model the set, the
37
+ * generic list. */
38
+ function expressRequirements(params) {
39
+ const kind = kindOf({ country: params.country ?? 'US', business_type: params.business_type ?? 'individual' });
40
+ const base = accountRequirements();
41
+ return kind ? { ...base, currently_due: currentlyDue(kind), eventually_due: currentlyDue(kind) } : base;
42
+ }
43
+ // the requested capabilities become Stripe's status map, and settings its canonical shape
44
+ const create = async (ctx) => {
45
+ const params = ctx.params;
46
+ const type = typeof params.type === 'string' ? params.type : 'standard';
47
+ if (!ACCOUNT_TYPES.has(type))
48
+ return fail(ctx, 'Invalid account type: must be one of express, standard, or custom.', 400, 'parameter_invalid_string_enum');
49
+ const { capabilities: _caps, settings: _settings, ...accountParams } = keptKyc(params);
50
+ const id = ctx.mint('account');
51
+ const made = await created(ctx, 'account', { ...accountParams, id, type }, {
52
+ business_type: null, charges_enabled: false, payouts_enabled: false, details_submitted: false,
53
+ capabilities: accountCapabilities(params), requirements: type === 'express' ? expressRequirements(params) : accountRequirements(),
54
+ country: typeof params.country === 'string' ? params.country : 'US',
55
+ default_currency: 'usd', email: params.email ?? null, metadata: {}, livemode: false,
56
+ settings: accountSettings(params.settings), business_profile: {},
57
+ controller: controllerOf(type), external_accounts: externalList(id, []),
58
+ future_requirements: { alternatives: [], current_deadline: null, currently_due: [], disabled_reason: null, errors: [], eventually_due: [], past_due: [], pending_verification: [] },
59
+ tos_acceptance: { date: null, ip: null, user_agent: null },
60
+ });
61
+ await review(ctx, String(made.id));
62
+ return ctx.reply(ctx.get('account', String(made.id)));
63
+ };
64
+ /** The controller an account made with a `type` has: "Each of the three account types maps to values in the
65
+ * `controller` hash", Standard to losses.payments `stripe`, fees.payer `account`, requirement_collection `stripe` and
66
+ * a `full` dashboard, Express to `application`, `application_express`, `stripe` and `express`, Custom to
67
+ * `application`, `application_custom`, `application` and `none`; each "type": "application", "is_controller": true
68
+ * (docs.stripe.com/connect/migrate-to-controller-properties). */
69
+ function controllerOf(type) {
70
+ const [losses, payer, collection, dashboard] = type === 'express' ? ['application', 'application_express', 'stripe', 'express']
71
+ : type === 'custom' ? ['application', 'application_custom', 'application', 'none'] : ['stripe', 'account', 'stripe', 'full'];
72
+ return { type: 'application', is_controller: true, losses: { payments: losses }, fees: { payer }, requirement_collection: collection, stripe_dashboard: { type: dashboard } };
73
+ }
74
+ // connected accounts only: the platform's own is not one, and neither is an account whose OAuth connection was revoked,
75
+ // which "can't be accessed by your platform in the Dashboard or through the API" (docs.stripe.com/connect/oauth-reference;
76
+ // its connection row is OAUTH_CONNECTIONS, screens/connect-oauth.tsx)
77
+ const listAccounts = async (ctx) => {
78
+ const revoked = new Set(ctx.rowsRaw(OAUTH_CONNECTIONS).filter((c) => c.revoked === true).map((c) => c.id));
79
+ return list(ctx, 'account', newest(ctx, 'account').filter((a) => a.id !== PLATFORM_ACCOUNT_ID && !revoked.has(a.id)));
80
+ };
81
+ /** What a Custom account still owes, from what the platform has given for it, as Stripe's requirements endpoint lists it
82
+ * for a US account with no Stripe Dashboard and the full service agreement
83
+ * (docs.stripe.com/_endpoint/get-requirements-for-setups, the data behind
84
+ * docs.stripe.com/connect/required-verification-information). With transfers alone, a company owes business_profile.url,
85
+ * company.name, an external account and tos_acceptance.date and .ip at once (capability_limit_amount -1: paused until
86
+ * given) and company.tax_id before $3,000 of payouts (payout_limit_amount 300000); an individual owes the url, its
87
+ * first and last name, the bank account and the terms at once, and its date of birth and ssn_last_4 before $3,000 of
88
+ * payouts. Requesting card_payments adds business_profile.mcc and the entity's address at once (and an individual's
89
+ * email and ssn_last_4). A company with card_payments owes, at once, business_profile.mcc, its address, that its owners
90
+ * are provided, each owner's name and email, and its representative's name, email, address, title and ssn_last_4; and
91
+ * in time its phone, its statement descriptor and its representative's date of birth and phone (the endpoint's answer
92
+ * for US / dashboard none / full terms / company / card_payments and transfers, saved in scratch as
93
+ * req-custom-company-cards-transfers.json: capability_limit_amount -1 for the first, none for the second). The
94
+ * representative and the owners are the account's persons (relationship.representative, relationship.owner).
95
+ * Where the documentation stops and the twin decides: what has no limit, or is owed before $3,000 of payouts, is
96
+ * eventually_due and blocks nothing (the twin keeps no payout total against it); an owner field is met when every
97
+ * person marked owner has it (none marked: met). */
98
+ const at_path = (a, path) => path.split('.').reduce((v, k) => (v && typeof v === 'object' ? v[k] : undefined), a);
99
+ const CARD_COMPANY_NOW = ['business_profile.mcc', 'company.address.line1', 'company.address.city', 'company.address.state', 'company.address.postal_code', 'company.owners_provided', 'owners.first_name', 'owners.last_name', 'owners.email', 'representative.first_name', 'representative.last_name', 'representative.email', 'representative.address.line1', 'representative.address.city', 'representative.address.state', 'representative.address.postal_code', 'representative.relationship.title', 'representative.ssn_last_4'];
100
+ const CARD_COMPANY_LATER = ['company.phone', 'settings.payments.statement_descriptor', 'representative.dob.day', 'representative.dob.month', 'representative.dob.year', 'representative.phone'];
101
+ const CARD_INDIVIDUAL_NOW = ['business_profile.mcc', 'individual.address.line1', 'individual.address.city', 'individual.address.state', 'individual.address.postal_code', 'individual.email', 'individual.ssn_last_4'];
102
+ /** A field only card_payments asks for (the transfers capability's requirements leave it out). */
103
+ const cardOnly = (field) => [...CARD_COMPANY_NOW, ...CARD_COMPANY_LATER, ...CARD_INDIVIDUAL_NOW].includes(field);
104
+ const personHas = (p, field) => {
105
+ if (field === 'ssn_last_4')
106
+ return p.ssn_last_4_provided === true;
107
+ const v = at_path(p, field);
108
+ return v !== undefined && v !== null && v !== '';
109
+ };
110
+ const met = (a, bank, field, persons = []) => {
111
+ if (field === 'external_account')
112
+ return bank;
113
+ if (field.startsWith('representative.')) {
114
+ const rep = persons.find((p) => p.relationship?.representative === true);
115
+ return !!rep && personHas(rep, field.slice('representative.'.length));
116
+ }
117
+ if (field.startsWith('owners.'))
118
+ return persons.filter((p) => p.relationship?.owner === true).every((p) => personHas(p, field.slice('owners.'.length)));
119
+ if (field === 'company.tax_id')
120
+ return at_path(a, 'company.tax_id_provided') === true;
121
+ if (field === 'individual.ssn_last_4')
122
+ return at_path(a, 'individual.ssn_last_4_provided') === true;
123
+ const v = at_path(a, field);
124
+ return v !== undefined && v !== null && v !== '';
125
+ };
126
+ function customRequirements(a, bank, persons = []) {
127
+ const company = a.business_type === 'company';
128
+ const cards = Object.keys(a.capabilities ?? {}).includes('card_payments');
129
+ const now = ['business_profile.url', ...(company ? ['company.name'] : ['individual.first_name', 'individual.last_name']), 'external_account', 'tos_acceptance.date', 'tos_acceptance.ip'];
130
+ if (cards)
131
+ now.push(...(company ? CARD_COMPANY_NOW : CARD_INDIVIDUAL_NOW));
132
+ const later = [...(company ? ['company.tax_id'] : ['individual.dob.day', 'individual.dob.month', 'individual.dob.year', 'individual.ssn_last_4']), ...(cards && company ? CARD_COMPANY_LATER : [])].filter((f) => !now.includes(f));
133
+ return { now: now.filter((f) => !met(a, bank, f, persons)), later: later.filter((f) => !met(a, bank, f, persons)) };
134
+ }
135
+ /** A request's company or individual as Stripe keeps it: a tax id or SSN given is kept only as provided (the Account
136
+ * object answers company.tax_id_provided and individual.ssn_last_4_provided, never the numbers;
137
+ * docs.stripe.com/api/accounts/object). */
138
+ function keptKyc(params) {
139
+ const out = { ...params };
140
+ if (params.company && typeof params.company === 'object') {
141
+ const { tax_id, ...company } = params.company;
142
+ out.company = { ...company, ...(tax_id !== undefined && tax_id !== '' ? { tax_id_provided: true } : {}) };
143
+ }
144
+ if (params.individual && typeof params.individual === 'object') {
145
+ const { ssn_last_4, id_number, ...individual } = params.individual;
146
+ out.individual = { ...individual, ...(ssn_last_4 !== undefined && ssn_last_4 !== '' ? { ssn_last_4_provided: true } : {}), ...(id_number !== undefined && id_number !== '' ? { id_number_provided: true } : {}) };
147
+ }
148
+ return out;
149
+ }
150
+ /** Stripe's review of a Custom account once the platform gives it something: with nothing left due, its requested
151
+ * capabilities become active and it can take charges and receive payouts; with something due again (its bank account
152
+ * removed), they stop. Where the documentation stops and the twin decides: test-mode verification is immediate, and
153
+ * it owes what customRequirements lists. An Express or Standard account is onboarded on Stripe's own pages. */
154
+ async function review(ctx, id) {
155
+ const account = ctx.get('account', id);
156
+ if (account && account.type === 'custom')
157
+ await reviewCustom(ctx, id, account);
158
+ }
159
+ /** The review of a Custom account (see review). */
160
+ async function reviewCustom(ctx, id, account) {
161
+ const bank = ctx.rows('external_account').some((e) => e.account === id);
162
+ const persons = ctx.rows('person').filter((p) => p.account === id);
163
+ const { now: due, later } = customRequirements(account, bank, persons);
164
+ // one account, one review: "If a connected account has both card_payments and transfers, and the status of either one
165
+ // is inactive, then both capabilities are disabled" (docs.stripe.com/connect/account-capabilities)
166
+ const ready = due.length === 0;
167
+ const capabilities = {};
168
+ for (const [key, was] of Object.entries(account.capabilities ?? {})) {
169
+ const to = ready ? 'active' : 'inactive';
170
+ // Stripe's review moves each capability, as the machine declares it may
171
+ if (was !== to)
172
+ ctx.legal('capability', 'status', ctx.call.operation.id, String(was), to, key, 'vendor');
173
+ capabilities[key] = to;
174
+ }
175
+ const requirements = { ...(account.requirements ?? {}), currently_due: due, eventually_due: [...due, ...later], past_due: [], disabled_reason: ready ? null : 'requirements.past_due', current_deadline: null };
176
+ await ctx.write('account', id, { capabilities, requirements, charges_enabled: ready, payouts_enabled: ready, details_submitted: ready }, ready ? 'account.updated' : 'account.update');
177
+ }
178
+ // `type` cannot change; settings merge onto the existing ones
179
+ const update = async (ctx) => {
180
+ const id = at(ctx, 'account');
181
+ const ex = ctx.get('account', id);
182
+ if (!ex)
183
+ return accountMissing(ctx, id);
184
+ const { type: _drop, settings: rawSettings, ...rest } = keptKyc(ctx.params);
185
+ if (rawSettings !== undefined)
186
+ rest.settings = accountSettings(rawSettings, ex.settings);
187
+ // a company or an individual given in part is laid over what the account holds
188
+ for (const k of ['company', 'individual', 'business_profile', 'tos_acceptance'])
189
+ if (rest[k] && typeof rest[k] === 'object' && ex[k] && typeof ex[k] === 'object')
190
+ rest[k] = { ...ex[k], ...rest[k] };
191
+ await ctx.write('account', id, rest, 'account.update');
192
+ await review(ctx, id);
193
+ return ctx.reply(ctx.get('account', id));
194
+ };
195
+ // a single-use Express dashboard link, not stored
196
+ const loginLink = async (ctx) => {
197
+ const id = at(ctx, 'account');
198
+ if (!ctx.get('account', id))
199
+ return accountMissing(ctx, id);
200
+ return ctx.reply({ object: 'login_link', created: ctx.now(), url: `https://connect.twin.local/express/${id}` });
201
+ };
202
+ // ── persons on an account ──
203
+ const personMissing = (ctx) => fail(ctx, `No such person: '${at(ctx, 'person')}'`, 404, 'resource_missing');
204
+ const accountPerson = (ctx) => {
205
+ const p = ctx.get('person', at(ctx, 'person'));
206
+ return p && p.account === at(ctx, 'account') ? p : undefined;
207
+ };
208
+ /** A person's details as Stripe keeps them: an SSN or ID number given is kept only as provided (the Person object
209
+ * answers ssn_last_4_provided and id_number_provided, never the numbers; docs.stripe.com/api/persons/object). */
210
+ function keptPerson(params, was = {}) {
211
+ const { ssn_last_4, id_number, ...rest } = params;
212
+ const out = { ...rest, ...(ssn_last_4 !== undefined && ssn_last_4 !== '' ? { ssn_last_4_provided: true } : {}), ...(id_number !== undefined && id_number !== '' ? { id_number_provided: true } : {}) };
213
+ // a relationship, address or date of birth given in part is laid over what the person holds
214
+ for (const k of ['relationship', 'address', 'dob'])
215
+ if (out[k] && typeof out[k] === 'object' && was[k] && typeof was[k] === 'object')
216
+ out[k] = { ...was[k], ...out[k] };
217
+ return out;
218
+ }
219
+ const RELATIONSHIP = { director: false, executive: false, owner: false, representative: false, percent_ownership: null, title: null };
220
+ // a person given or changed is part of the account's review: its representative and owners are what card_payments asks for
221
+ const createPerson = async (ctx) => {
222
+ const account = at(ctx, 'account');
223
+ if (!accountRow(ctx, account))
224
+ return accountMissing(ctx, account);
225
+ const kept = keptPerson(ctx.params, { relationship: RELATIONSHIP });
226
+ const body = await created(ctx, 'person', { ...kept, account }, {
227
+ relationship: RELATIONSHIP,
228
+ requirements: { currently_due: [], eventually_due: [], past_due: [], pending_verification: [], errors: [], alternatives: [] },
229
+ verification: { status: 'unverified', document: { back: null, details: null, details_code: null, front: null } },
230
+ metadata: {}, ssn_last_4_provided: false, id_number_provided: false,
231
+ });
232
+ await review(ctx, account);
233
+ return ctx.reply(body);
234
+ };
235
+ const person = async (ctx) => {
236
+ const p = accountPerson(ctx);
237
+ return p ? ctx.reply(p) : personMissing(ctx);
238
+ };
239
+ const updatePerson = async (ctx) => {
240
+ const was = accountPerson(ctx);
241
+ if (!was)
242
+ return personMissing(ctx);
243
+ const body = await ctx.write('person', at(ctx, 'person'), keptPerson(ctx.params, was), 'person.update');
244
+ await review(ctx, at(ctx, 'account'));
245
+ return ctx.reply(body);
246
+ };
247
+ const deletePerson = async (ctx) => {
248
+ if (!accountPerson(ctx))
249
+ return personMissing(ctx);
250
+ const id = at(ctx, 'person');
251
+ await ctx.write('person', id, { deleted: true }, 'person.delete');
252
+ await review(ctx, at(ctx, 'account'));
253
+ return ctx.reply({ id, object: 'person', deleted: true });
254
+ };
255
+ // ── capabilities: the account's capability map is the truth; requesting one leaves it pending ──
256
+ /** A capability as its account stands: its status, and the account's requirements that capability asks for (transfers
257
+ * leaves out what only card_payments asks for). Where the documentation stops and the twin decides: the account-wide
258
+ * fields (the bank account, the terms) belong to every capability. */
259
+ const capabilityBody = (acct, id, status, requested = true, requestedAt = null) => {
260
+ const req = acct.requirements ?? {};
261
+ const mine = (list) => (Array.isArray(list) ? list : []).filter((f) => id === 'card_payments' || !cardOnly(f));
262
+ const due = mine(req.currently_due);
263
+ return {
264
+ id, object: 'capability', account: acct.id, status, requested, requested_at: requestedAt,
265
+ requirements: { currently_due: due, eventually_due: mine(req.eventually_due), past_due: [], pending_verification: [], errors: [], alternatives: [], current_deadline: null, disabled_reason: due.length ? 'requirements.past_due' : null },
266
+ };
267
+ };
268
+ const capabilities = async (ctx) => {
269
+ const account = at(ctx, 'account');
270
+ const acct = ctx.get('account', account);
271
+ if (!acct)
272
+ return accountMissing(ctx, account);
273
+ const caps = acct.capabilities ?? {};
274
+ return ctx.reply({ object: 'list', url: path(ctx), has_more: false, data: Object.entries(caps).map(([id, status]) => capabilityBody(acct, id, status)) });
275
+ };
276
+ const capability = async (ctx) => {
277
+ const account = at(ctx, 'account');
278
+ const acct = ctx.get('account', account);
279
+ if (!acct)
280
+ return accountMissing(ctx, account);
281
+ const caps = acct.capabilities ?? {};
282
+ const cap = at(ctx, 'capability');
283
+ return cap in caps ? ctx.reply(capabilityBody(acct, cap, caps[cap])) : fail(ctx, `No such capability: '${cap}'`, 404, 'resource_missing');
284
+ };
285
+ const requestCapability = async (ctx) => {
286
+ const account = at(ctx, 'account');
287
+ const acct = ctx.get('account', account);
288
+ if (!acct)
289
+ return accountMissing(ctx, account);
290
+ const caps = { ...(acct.capabilities ?? {}) };
291
+ const cap = at(ctx, 'capability');
292
+ const requested = ctx.params.requested === undefined ? true : asBool(ctx.params.requested);
293
+ const to = requested ? 'pending' : 'inactive';
294
+ const refused = ctx.legal('capability', 'status', 'PostAccountsAccountCapabilitiesCapability', String(caps[cap] ?? 'unrequested'), to, cap);
295
+ if (refused)
296
+ return ctx.refuse(refused);
297
+ caps[cap] = to;
298
+ await ctx.write('account', account, { capabilities: caps }, 'account.updated');
299
+ // Stripe reviews the account against what the capability asks for (review)
300
+ await review(ctx, account);
301
+ const now = ctx.get('account', account);
302
+ return ctx.reply(capabilityBody(now, cap, now.capabilities[cap], requested, requested ? ctx.now() : null));
303
+ };
304
+ // ── external accounts: a payout destination from a token or a bank_account hash ──
305
+ const externalMissing = (ctx) => fail(ctx, `No such external account: '${at(ctx, 'id')}'`, 404, 'resource_missing');
306
+ const accountExternal = (ctx) => {
307
+ const e = ctx.get('external_account', at(ctx, 'id'));
308
+ return e && e.account === at(ctx, 'account') ? e : undefined;
309
+ };
310
+ /** Whether the platform may create, update or delete an account's external accounts: "A platform can create, update, or
311
+ * delete external accounts only for connected accounts without access to either the full Stripe or Express Dashboard
312
+ * and where the platform is responsible for negative balances" (docs.stripe.com/connect/payouts-bank-accounts); it can
313
+ * still view an Express account's ("A platform can view the external accounts of connected accounts that don’t have
314
+ * access to the full Stripe Dashboard"). Where the documentation stops and the twin decides: the refusal. Stripe's
315
+ * pages give no error code for it; the twin answers 403, which the errors page gives as "The API key doesn’t have
316
+ * permissions to perform the request" (docs.stripe.com/api/errors), with the twin's own wording. The create page says
317
+ * it too: "You can only specify connected accounts where account.controller.requirement_collection is `application`"
318
+ * (docs.stripe.com/api/external_account_bank_accounts/create). */
319
+ function refuseExternalWrite(ctx, account) {
320
+ const row = accountRow(ctx, account);
321
+ const controller = row?.controller ?? controllerOf(String(row?.type ?? 'standard'));
322
+ const dashboard = String((controller.stripe_dashboard?.type) ?? '');
323
+ const losses = String((controller.losses?.payments) ?? '');
324
+ if (dashboard === 'none' && losses === 'application')
325
+ return undefined;
326
+ return ctx.refuse({ status: 403, message: `This application does not have the required permissions to manage the external accounts of account '${account}': they can only be managed through the account's ${dashboard === 'express' ? 'Express' : 'Stripe'} Dashboard.` });
327
+ }
328
+ const createExternal = async (ctx) => {
329
+ const account = at(ctx, 'account');
330
+ if (!accountRow(ctx, account))
331
+ return accountMissing(ctx, account);
332
+ const forbidden = refuseExternalWrite(ctx, account);
333
+ if (forbidden)
334
+ return forbidden;
335
+ const ext = ctx.params.external_account ?? ctx.params.bank_account;
336
+ if (ext === undefined)
337
+ return fail(ctx, 'Missing required param: external_account.', 400, 'parameter_missing');
338
+ const ba = typeof ext === 'object' ? ext : {};
339
+ const acctNum = typeof ba.account_number === 'string' ? ba.account_number.replace(/\D/g, '') : '';
340
+ const currency = String(ba.currency ?? 'usd');
341
+ const sameCurrency = ctx.rows('external_account').filter((e) => e.account === account && String(e.currency ?? 'usd') === currency);
342
+ const isDefault = sameCurrency.length === 0 || ctx.params.default_for_currency === true || ctx.params.default_for_currency === 'true';
343
+ const made = await created(ctx, 'external_account', { account }, {
344
+ account_holder_name: ba.account_holder_name ?? null,
345
+ account_holder_type: ba.account_holder_type ?? null, bank_name: 'STRIPE TEST BANK',
346
+ country: ba.country ?? 'US', currency,
347
+ fingerprint: 'twin_ext_fp', last4: acctNum ? acctNum.slice(-4) : '6789',
348
+ routing_number: ba.routing_number ?? '110000000', status: 'new', metadata: {},
349
+ // "When set to true, or if this is the first external account added in this currency, this account becomes the
350
+ // default external account for its currency" (docs.stripe.com/api/external_account_bank_accounts/create)
351
+ default_for_currency: isDefault,
352
+ ...(ctx.params.metadata && typeof ctx.params.metadata === 'object' ? { metadata: ctx.params.metadata } : {}),
353
+ });
354
+ if (isDefault)
355
+ for (const other of sameCurrency)
356
+ if (other.default_for_currency === true)
357
+ await ctx.write('external_account', String(other.id), { default_for_currency: false }, 'external_account.update');
358
+ await syncExternals(ctx, account);
359
+ await review(ctx, account);
360
+ return ctx.reply(made);
361
+ };
362
+ const externals = async (ctx) => {
363
+ const account = at(ctx, 'account');
364
+ if (!accountRow(ctx, account))
365
+ return accountMissing(ctx, account);
366
+ return list(ctx, 'external_account', newest(ctx, 'external_account').filter((e) => e.account === account));
367
+ };
368
+ const external = async (ctx) => {
369
+ const e = accountExternal(ctx);
370
+ return e ? ctx.reply(e) : externalMissing(ctx);
371
+ };
372
+ const deleteExternal = async (ctx) => {
373
+ if (!accountExternal(ctx))
374
+ return externalMissing(ctx);
375
+ const forbidden = refuseExternalWrite(ctx, at(ctx, 'account'));
376
+ if (forbidden)
377
+ return forbidden;
378
+ const id = at(ctx, 'id');
379
+ await ctx.write('external_account', id, { deleted: true }, 'external_account.delete');
380
+ await syncExternals(ctx, at(ctx, 'account'));
381
+ await review(ctx, at(ctx, 'account'));
382
+ return ctx.reply({ id, object: 'bank_account', deleted: true });
383
+ };
384
+ // ── hosted onboarding links and embedded-component sessions ──
385
+ const accountLink = async (ctx) => {
386
+ const account = typeof ctx.params.account === 'string' ? ctx.params.account : '';
387
+ if (!account)
388
+ return fail(ctx, 'Missing required param: account.', 400, 'parameter_missing');
389
+ if (!ctx.get('account', account))
390
+ return accountMissing(ctx, account, 400);
391
+ const linkType = typeof ctx.params.type === 'string' ? ctx.params.type : '';
392
+ if (linkType !== 'account_onboarding' && linkType !== 'account_update')
393
+ return fail(ctx, 'Invalid account link type: must be account_onboarding or account_update.', 400, 'parameter_invalid_string_enum');
394
+ // the owner is sent back to return_url when done, and to refresh_url for a new link once this one is used or expired
395
+ for (const k of ['refresh_url', 'return_url'])
396
+ if (typeof ctx.params[k] !== 'string' || !ctx.params[k])
397
+ return fail(ctx, `Missing required param: ${k}.`, 400, 'parameter_missing');
398
+ const now = Number(ctx.now());
399
+ const id = ctx.mint('account_link');
400
+ const link = { object: 'account_link', created: now, expires_at: now + 300, url: `https://connect.stripe.com/setup/${id}` };
401
+ await ctx.write('account_link', id, { ...link, account, type: linkType, refresh_url: ctx.params.refresh_url, return_url: ctx.params.return_url, used: false }, 'account_link.create');
402
+ return ctx.reply(link);
403
+ };
404
+ // a session is identified by its client_secret: the row is kept for traceability, the answer has no id
405
+ /** Every embedded component an account session answers (the served spec requires all of them), each enabled only when
406
+ * the request enables it, with its features: what the request gives, else the spec's stated default, "The default
407
+ * value for this feature is `true`" (external_account_collection); disable_stripe_user_authentication "the opposite of
408
+ * the `external_account_collection` value"; edit_payout_schedule and standard_payouts "Defaults to `true` when
409
+ * `controller.losses.payments` is set to `stripe` for the account, otherwise `false`"; capture_payments, dispute_management
410
+ * and refund_management "true by default"; destination_on_behalf_of_charge_management "false by default";
411
+ * smart_disputes_management "Defaults to the value of `dispute_management`". Where the documentation stops and the twin
412
+ * decides: instant_payouts (described as `enabled` "when Stripe is responsible for negative account balances", typed
413
+ * boolean) is true exactly then, and the Issuing and financial-account features, which state no default, are false. */
414
+ const SESSION_COMPONENTS = {
415
+ account_management: ['external_account_collection', 'disable_stripe_user_authentication'],
416
+ account_onboarding: ['external_account_collection', 'disable_stripe_user_authentication'],
417
+ balance_report: [], documents: [], payout_details: [], payout_reconciliation_report: [], payouts_list: [], tax_registrations: [], tax_settings: [],
418
+ balances: ['external_account_collection', 'disable_stripe_user_authentication', 'edit_payout_schedule', 'instant_payouts', 'standard_payouts'],
419
+ payouts: ['external_account_collection', 'disable_stripe_user_authentication', 'edit_payout_schedule', 'instant_payouts', 'standard_payouts'],
420
+ disputes_list: ['capture_payments', 'destination_on_behalf_of_charge_management', 'dispute_management', 'refund_management', 'smart_disputes_management'],
421
+ payment_details: ['capture_payments', 'destination_on_behalf_of_charge_management', 'dispute_management', 'refund_management', 'smart_disputes_management'],
422
+ payments: ['capture_payments', 'destination_on_behalf_of_charge_management', 'dispute_management', 'refund_management', 'smart_disputes_management'],
423
+ payment_disputes: ['destination_on_behalf_of_charge_management', 'dispute_management', 'refund_management', 'smart_disputes_management'],
424
+ financial_account: ['external_account_collection', 'disable_stripe_user_authentication', 'send_money', 'transfer_balance'],
425
+ financial_account_transactions: ['card_spend_dispute_management'],
426
+ instant_payouts_promotion: ['external_account_collection', 'disable_stripe_user_authentication', 'instant_payouts'],
427
+ issuing_card: ['card_management', 'card_spend_dispute_management', 'cardholder_management', 'spend_control_management'],
428
+ issuing_cards_list: ['card_management', 'card_spend_dispute_management', 'cardholder_management', 'disable_stripe_user_authentication', 'spend_control_management'],
429
+ notification_banner: ['external_account_collection', 'disable_stripe_user_authentication'],
430
+ payment_method_settings: ['disable_stripe_user_authentication'],
431
+ };
432
+ function sessionComponents(account, given) {
433
+ const stripeLosses = (account.controller?.losses?.payments) === 'stripe';
434
+ const out = {};
435
+ for (const [name, featureNames] of Object.entries(SESSION_COMPONENTS)) {
436
+ const c = given[name] && typeof given[name] === 'object' ? given[name] : {};
437
+ const asked = c.features && typeof c.features === 'object' ? c.features : {};
438
+ const f = {};
439
+ const val = (k, fallback) => (asked[k] !== undefined ? asBool(asked[k]) : fallback);
440
+ for (const k of featureNames) {
441
+ if (k === 'external_account_collection')
442
+ f[k] = val(k, true);
443
+ else if (k === 'disable_stripe_user_authentication')
444
+ f[k] = val(k, !val('external_account_collection', true));
445
+ else if (k === 'edit_payout_schedule' || k === 'standard_payouts' || k === 'instant_payouts')
446
+ f[k] = val(k, stripeLosses);
447
+ else if (k === 'capture_payments' || k === 'dispute_management' || k === 'refund_management')
448
+ f[k] = val(k, true);
449
+ else if (k === 'smart_disputes_management')
450
+ f[k] = val(k, val('dispute_management', true));
451
+ else
452
+ f[k] = val(k, false);
453
+ }
454
+ out[name] = { enabled: c.enabled !== undefined ? asBool(c.enabled) : false, features: f };
455
+ }
456
+ return out;
457
+ }
458
+ const accountSession = async (ctx) => {
459
+ const account = typeof ctx.params.account === 'string' ? ctx.params.account : '';
460
+ if (!account)
461
+ return fail(ctx, 'Missing required param: account.', 400, 'parameter_missing');
462
+ if (!ctx.get('account', account))
463
+ return accountMissing(ctx, account, 400);
464
+ const comps = ctx.params.components && typeof ctx.params.components === 'object' ? ctx.params.components : undefined;
465
+ if (!comps || Object.keys(comps).length === 0)
466
+ return fail(ctx, 'Missing required param: components.', 400, 'parameter_missing');
467
+ const components = sessionComponents(ctx.get('account', account), comps);
468
+ const now = Number(ctx.now());
469
+ const seq = ctx.rowsRaw('account_session', { withDeleted: true }).length + 1;
470
+ const secret = `_twin_acct_sess_${seq}_secret`;
471
+ await created(ctx, 'account_session', { id: `accts_twin_${seq}`, account }, { livemode: false, client_secret: secret, expires_at: now + 3600, components });
472
+ return ctx.reply({ object: 'account_session', account, client_secret: secret, expires_at: now + 3600, components, livemode: false });
473
+ };
474
+ export const connect = {
475
+ GetAccount: platform,
476
+ PostAccounts: create,
477
+ GetAccounts: listAccounts,
478
+ PostAccountsAccount: update,
479
+ PostAccountsAccountLoginLinks: loginLink,
480
+ PostAccountsAccountPersons: createPerson,
481
+ GetAccountsAccountPersonsPerson: person,
482
+ PostAccountsAccountPersonsPerson: updatePerson,
483
+ DeleteAccountsAccountPersonsPerson: deletePerson,
484
+ GetAccountsAccountCapabilities: capabilities,
485
+ GetAccountsAccountCapabilitiesCapability: capability,
486
+ PostAccountsAccountCapabilitiesCapability: requestCapability,
487
+ PostAccountsAccountExternalAccounts: createExternal,
488
+ GetAccountsAccountExternalAccounts: externals,
489
+ GetAccountsAccountExternalAccountsId: external,
490
+ DeleteAccountsAccountExternalAccountsId: deleteExternal,
491
+ PostAccountLinks: accountLink,
492
+ PostAccountSessions: accountSession,
493
+ };
@@ -0,0 +1,6 @@
1
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
2
+ /** Time's lapsing of coupons, caught up to the World's clock: a valid coupon whose redeem_by has passed is no longer
3
+ * valid from that moment (manifest.ts). Where the documentation stops and the twin decides: the lapse sends no event
4
+ * (the events page names none for it). */
5
+ export declare function lapseCoupons(ctx: SemanticsContext): Promise<void>;
6
+ export declare const coupons: Record<string, Semantics>;
@@ -0,0 +1,92 @@
1
+ import { asBool } from "../stripe-twin.js";
2
+ import { at_, created, fail, list, newest, where } from "./shared.js";
3
+ /** A coupon for a fixed amount off, in its currency (docs.stripe.com/api/coupons/create#create_coupon-amount_off). */
4
+ function amountOffOf(ctx, params) {
5
+ const amountOff = Number(params.amount_off);
6
+ if (!Number.isInteger(amountOff) || amountOff <= 0)
7
+ return fail(ctx, 'Invalid integer: amount_off must be a positive integer.', 400, 'parameter_invalid_integer');
8
+ if (params.currency === undefined || params.currency === '')
9
+ return fail(ctx, 'Missing required param: currency.', 400, 'parameter_missing');
10
+ return amountOff;
11
+ }
12
+ const createCoupon = async (ctx) => {
13
+ const params = ctx.params;
14
+ const hasPct = params.percent_off !== undefined;
15
+ const hasAmt = params.amount_off !== undefined;
16
+ if (hasPct === hasAmt)
17
+ return fail(ctx, 'You must pass exactly one of `amount_off` and `percent_off`.', 400, 'parameter_missing');
18
+ let percentOff = null;
19
+ let amountOff = null;
20
+ if (hasPct) {
21
+ percentOff = Number(params.percent_off);
22
+ if (!Number.isFinite(percentOff) || percentOff <= 0 || percentOff > 100)
23
+ return fail(ctx, 'Invalid number: percent_off must be > 0 and <= 100.', 400, 'parameter_invalid_number');
24
+ }
25
+ const off = hasPct ? null : amountOffOf(ctx, params);
26
+ if (off instanceof Response)
27
+ return off;
28
+ amountOff = off;
29
+ const duration = typeof params.duration === 'string' ? params.duration : 'once';
30
+ if (!['once', 'repeating', 'forever'].includes(duration))
31
+ return fail(ctx, 'Invalid duration: must be one of once, repeating, or forever.', 400, 'parameter_invalid_string_enum');
32
+ if (duration === 'repeating' && params.duration_in_months === undefined)
33
+ return fail(ctx, 'Missing required param: duration_in_months (required when duration=repeating).', 400, 'parameter_missing');
34
+ return ctx.reply(await created(ctx, 'coupon', params, {
35
+ percent_off: percentOff, amount_off: amountOff,
36
+ currency: amountOff !== null ? params.currency : null,
37
+ duration, duration_in_months: duration === 'repeating' ? Number(params.duration_in_months) : null,
38
+ name: typeof params.name === 'string' ? params.name : null,
39
+ valid: true, livemode: false, times_redeemed: 0, metadata: {},
40
+ max_redemptions: params.max_redemptions !== undefined ? Number(params.max_redemptions) : null,
41
+ redeem_by: params.redeem_by !== undefined ? Number(params.redeem_by) : null,
42
+ applies_to: null,
43
+ }));
44
+ };
45
+ // a code the caller does not give is derived from the promotion code's id
46
+ const createPromotionCode = async (ctx) => {
47
+ // what it promotes: promotion[type]=coupon and promotion[coupon] (2025-09-30.clover,
48
+ // docs.stripe.com/changelog/clover/2025-09-30/polymorphic-coupon), or the top-level coupon a caller pinned to an
49
+ // earlier version sends; the twin keeps the coupon's id on the code for its rules
50
+ const { promotion, coupon: legacy, ...params } = ctx.params;
51
+ const promo = promotion && typeof promotion === 'object' ? promotion : undefined;
52
+ if (promo && promo.type !== 'coupon')
53
+ return fail(ctx, 'Invalid promotion[type]: must be coupon.', 400, 'parameter_invalid_string_enum');
54
+ const coupon = typeof promo?.coupon === 'string' ? promo.coupon : typeof legacy === 'string' ? legacy : '';
55
+ if (!coupon)
56
+ return fail(ctx, promo ? 'Missing required param: promotion[coupon].' : 'Missing required param: promotion.', 400, 'parameter_missing');
57
+ if (!ctx.get('coupon', coupon))
58
+ return fail(ctx, `No such coupon: '${coupon}'`, 400, 'resource_missing');
59
+ const id = ctx.mint('promotion_code');
60
+ const code = typeof params.code === 'string' && params.code ? params.code : `TWIN${id.toUpperCase().replace(/[^A-Z0-9]/g, '')}`;
61
+ return ctx.reply(await created(ctx, 'promotion_code', { ...params, coupon, promotion: { type: 'coupon', coupon }, id, code }, {
62
+ active: params.active !== undefined ? asBool(params.active) : true,
63
+ customer: typeof params.customer === 'string' ? params.customer : null,
64
+ expires_at: params.expires_at !== undefined ? Number(params.expires_at) : null,
65
+ max_redemptions: params.max_redemptions !== undefined ? Number(params.max_redemptions) : null,
66
+ times_redeemed: 0, livemode: false, metadata: {}, restrictions: { first_time_transaction: false, minimum_amount: null, minimum_amount_currency: null },
67
+ }));
68
+ };
69
+ /** Time's lapsing of coupons, caught up to the World's clock: a valid coupon whose redeem_by has passed is no longer
70
+ * valid from that moment (manifest.ts). Where the documentation stops and the twin decides: the lapse sends no event
71
+ * (the events page names none for it). */
72
+ export async function lapseCoupons(ctx) {
73
+ const now = Number(ctx.now());
74
+ for (const c of ctx.rowsRaw('coupon')) {
75
+ if (c.valid !== true || typeof c.redeem_by !== 'number' || c.redeem_by > now)
76
+ continue;
77
+ const t = await at_(ctx)(c.redeem_by);
78
+ t.legal('coupon', 'valid', ctx.call.operation.id, 'true', 'false', String(c.id), 'time');
79
+ await t.write('coupon', String(c.id), { valid: false }, 'coupon.lapsed');
80
+ }
81
+ }
82
+ const listPromotionCodes = async (ctx) => list(ctx, 'promotion_code', where(ctx, newest(ctx, 'promotion_code'), {
83
+ code: (p, v) => p.code === v,
84
+ active: (p, v) => asBool(p.active) === asBool(v),
85
+ coupon: (p, v) => p.coupon === v,
86
+ customer: (p, v) => p.customer === v,
87
+ }));
88
+ export const coupons = {
89
+ PostCoupons: createCoupon,
90
+ PostPromotionCodes: createPromotionCode,
91
+ GetPromotionCodes: listPromotionCodes,
92
+ };
@@ -0,0 +1,2 @@
1
+ import type { Semantics } from '@volter/world-core';
2
+ export declare const creditNotes: Record<string, Semantics>;