@volter/twin-stripe 0.1.2 → 2.0.0

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