@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,154 @@
1
+ // Product and Price semantics, with the entitlement features granted to a product. Retrieve of a
2
+ // product and the product and price updates (archiving and restoring by `active`, the machines in
3
+ // ../manifest.ts) are the derived core's.
4
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
5
+ import { asBool } from '../stripe-twin.ts';
6
+ import { at, created, fail, kept, list, newest, where, type Row } from './shared.ts';
7
+
8
+ const productMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such product: '${id}'`, 404, 'resource_missing');
9
+
10
+ const createProduct: Semantics = async (ctx) =>
11
+ ctx.reply(await created(ctx, 'product', ctx.params, { active: true, livemode: false, images: [], marketing_features: [], metadata: {}, updated: ctx.now() }));
12
+
13
+ const listProducts: Semantics = async (ctx) =>
14
+ list(ctx, 'product', where(ctx, newest(ctx, 'product'), {
15
+ active: (p, v) => asBool(p.active) === asBool(v),
16
+ ids: (p, v) => (Array.isArray(v) ? v.map(String).includes(String(p.id)) : String(p.id) === String(v)),
17
+ }));
18
+
19
+ // a product with prices cannot be deleted; Stripe says to deactivate it instead
20
+ /** A product deleted while it has prices: "Deleting a product is only possible if it has no prices associated with it"
21
+ * (docs.stripe.com/api/products/delete). */
22
+ function pricedProductRefused(ctx: SemanticsContext): Response {
23
+ return fail(ctx, 'You cannot delete the product because it has one or more prices. Deactivate the product instead.', 400, 'resource_already_exists');
24
+ }
25
+
26
+ const deleteProduct: Semantics = async (ctx) => {
27
+ const id = at(ctx, 'id');
28
+ if (!ctx.get('product', id)) return productMissing(ctx, id);
29
+ if (ctx.rows('price').some((p) => p.product === id)) return pricedProductRefused(ctx);
30
+ await ctx.write('product', id, { deleted: true }, 'product.delete');
31
+ return ctx.reply({ id, object: 'product', deleted: true });
32
+ };
33
+
34
+ // ── features: an entitlements feature granted to a product, at most once ──
35
+
36
+ /** A feature attached to a product that already has it. */
37
+ function alreadyAttached(ctx: SemanticsContext): Response {
38
+ return fail(ctx, 'This feature is already attached to the product.', 400, 'resource_already_exists');
39
+ }
40
+
41
+ const attachFeature: Semantics = async (ctx) => {
42
+ const product = at(ctx, 'product');
43
+ if (!ctx.get('product', product)) return productMissing(ctx, product);
44
+ const feature = typeof ctx.params.entitlement_feature === 'string' ? ctx.params.entitlement_feature : '';
45
+ if (!feature) return fail(ctx, 'Missing required param: entitlement_feature.', 400, 'parameter_missing');
46
+ const feat = ctx.get('entitlements_feature', feature);
47
+ if (!feat) return fail(ctx, `No such feature: '${feature}'`, 400, 'resource_missing');
48
+ if (ctx.rowsRaw('product_feature').some((pf) => pf._product === product && pf._feature === feature)) return alreadyAttached(ctx);
49
+ // Stripe answers the feature itself as the product feature's entitlement_feature (docs.stripe.com/api/product-feature/object)
50
+ return ctx.reply(
51
+ await created(ctx, 'product_feature', { _product: product, _feature: feature }, {
52
+ livemode: false,
53
+ entitlement_feature: { id: feature, object: 'entitlements.feature', name: feat.name, lookup_key: feat.lookup_key, active: feat.active, metadata: feat.metadata ?? {}, livemode: false },
54
+ }),
55
+ );
56
+ };
57
+
58
+ const features: Semantics = async (ctx) => {
59
+ const product = at(ctx, 'product');
60
+ if (!ctx.get('product', product)) return productMissing(ctx, product);
61
+ return list(ctx, 'product_feature', newest(ctx, 'product_feature').filter((pf) => kept(ctx, 'product_feature', pf, '_product') === product));
62
+ };
63
+
64
+ const productFeature = (ctx: SemanticsContext): Row | undefined => {
65
+ const pf = ctx.get('product_feature', at(ctx, 'id'));
66
+ // the product a feature is attached to is its address, not a field Stripe answers
67
+ return pf && kept(ctx, 'product_feature', pf, '_product') === at(ctx, 'product') ? pf : undefined;
68
+ };
69
+ const featureMissing = (ctx: SemanticsContext): Response => fail(ctx, `No such product feature: '${at(ctx, 'id')}'`, 404, 'resource_missing');
70
+
71
+ const feature: Semantics = async (ctx) => {
72
+ const pf = productFeature(ctx);
73
+ return pf ? ctx.reply(pf) : featureMissing(ctx);
74
+ };
75
+
76
+ const detachFeature: Semantics = async (ctx) => {
77
+ if (!productFeature(ctx)) return featureMissing(ctx);
78
+ const id = at(ctx, 'id');
79
+ // when it came off the product: a subscription keeps it until its next period (semantics/entitlements.ts)
80
+ await ctx.write('product_feature', id, { deleted: true, _removed_at: Number(ctx.now()) }, 'product_feature.delete');
81
+ return ctx.reply({ id, object: 'product_feature', deleted: true });
82
+ };
83
+
84
+ // ── prices ──
85
+
86
+ /** A tiered price: tiers_mode and tiers, each tier with up_to and a unit or flat amount; no single unit_amount, and
87
+ * the last tier's up_to null (∞) (docs.stripe.com/products-prices/pricing-models#tiered-pricing). */
88
+ async function tieredPrice(ctx: SemanticsContext, params: Row): Promise<Response> {
89
+ const mode = typeof params.tiers_mode === 'string' ? params.tiers_mode : '';
90
+ if (mode !== 'graduated' && mode !== 'volume') return fail(ctx, 'Missing required param: tiers_mode (graduated or volume).', 400, 'parameter_missing');
91
+ const rawTiers = Array.isArray(params.tiers) ? (params.tiers as Row[]) : [];
92
+ if (rawTiers.length === 0) return fail(ctx, 'Missing required param: tiers.', 400, 'parameter_missing');
93
+ const tiers = rawTiers.map((t) => {
94
+ const upToRaw = t.up_to;
95
+ return {
96
+ up_to: upToRaw === 'inf' || upToRaw === undefined || upToRaw === null ? null : Math.trunc(Number(upToRaw)),
97
+ unit_amount: t.unit_amount !== undefined ? Math.trunc(Number(t.unit_amount)) : null,
98
+ unit_amount_decimal: t.unit_amount !== undefined ? String(Math.trunc(Number(t.unit_amount))) : null,
99
+ flat_amount: t.flat_amount !== undefined ? Math.trunc(Number(t.flat_amount)) : null,
100
+ flat_amount_decimal: t.flat_amount !== undefined ? String(Math.trunc(Number(t.flat_amount))) : null,
101
+ };
102
+ });
103
+ if (tiers.some((t) => t.unit_amount === null && t.flat_amount === null)) return fail(ctx, 'Each tier must specify a unit_amount or flat_amount.', 400, 'parameter_invalid_empty');
104
+ const { tiers: _t, ...rest } = params;
105
+ return ctx.reply(await created(ctx, 'price', { ...rest, billing_scheme: 'tiered', tiers_mode: mode, tiers, unit_amount: null }, { active: true, livemode: false, type: rest.recurring ? 'recurring' : 'one_time', metadata: {} }));
106
+ }
107
+
108
+ const createPrice: Semantics = async (ctx) => {
109
+ const params = ctx.params;
110
+ // the product must exist
111
+ const product = typeof params.product === 'string' ? params.product : '';
112
+ if (!product || !ctx.get('product', product)) return fail(ctx, `No such product: '${product}'`, 400, 'resource_missing');
113
+ // a tiered price needs tiers_mode and tiers, each tier with up_to and a unit or flat amount; it
114
+ // has no single unit_amount, and its last tier's up_to is null (∞)
115
+ const scheme = typeof params.billing_scheme === 'string' ? params.billing_scheme : 'per_unit';
116
+ if (scheme === 'tiered') return tieredPrice(ctx, params);
117
+ // a price that recurs is billed by subscriptions; any other is paid once. Its recurring block answers what the create
118
+ // left out as Stripe does: `usage_type` "Defaults to `licensed`", and the create example's answer
119
+ // (docs.stripe.com/api/prices/create) carries `"interval_count": 1, "trial_period_days": null`; a meter only a metered
120
+ // price names
121
+ if (params.recurring && typeof params.recurring === 'object') {
122
+ const r = params.recurring as Row;
123
+ params.recurring = { ...r, interval_count: r.interval_count !== undefined ? Math.trunc(Number(r.interval_count)) : 1, usage_type: r.usage_type ?? 'licensed', trial_period_days: r.trial_period_days !== undefined ? Math.trunc(Number(r.trial_period_days)) : null, meter: r.meter ?? null };
124
+ }
125
+ return ctx.reply(await created(ctx, 'price', params, { active: true, livemode: false, billing_scheme: 'per_unit', type: params.recurring ? 'recurring' : 'one_time', metadata: {} }));
126
+ };
127
+
128
+ // `type` is not stored (it collides with the tree's discriminator), so `recurring` stands for it
129
+ const listPrices: Semantics = async (ctx) =>
130
+ list(ctx, 'price', where(ctx, newest(ctx, 'price'), {
131
+ active: (p, v) => asBool(p.active) === asBool(v),
132
+ product: (p, v) => p.product === v,
133
+ currency: (p, v) => p.currency === v,
134
+ type: (p, v) => (v === 'recurring' ? !!p.recurring : !p.recurring),
135
+ lookup_keys: (p, v) => (Array.isArray(v) ? v.map(String) : [String(v)]).includes(String(p.lookup_key)),
136
+ }));
137
+
138
+ const retrievePrice: Semantics = async (ctx) => {
139
+ const p = ctx.get('price', at(ctx, 'price'));
140
+ return p ? ctx.reply(p) : fail(ctx, `No such price: '${at(ctx, 'price')}'`, 404, 'resource_missing');
141
+ };
142
+
143
+ export const products: Record<string, Semantics> = {
144
+ PostProducts: createProduct,
145
+ GetProducts: listProducts,
146
+ DeleteProductsId: deleteProduct,
147
+ PostProductsProductFeatures: attachFeature,
148
+ GetProductsProductFeatures: features,
149
+ GetProductsProductFeaturesId: feature,
150
+ DeleteProductsProductFeaturesId: detachFeature,
151
+ PostPrices: createPrice,
152
+ GetPrices: listPrices,
153
+ GetPricesPrice: retrievePrice,
154
+ };
@@ -0,0 +1,85 @@
1
+ // Radar semantics: reviews of payments Radar held (approving closes one, the machine in
2
+ // ../manifest.ts) and the value lists custom rules reference, with their items. Review list and
3
+ // retrieve, value-list list and retrieve, and value-list-item retrieve are the derived core's.
4
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
5
+ import { at, created, fail, list, newest } from './shared.ts';
6
+
7
+ const approve: Semantics = async (ctx) => {
8
+ const id = at(ctx, 'review');
9
+ const r = ctx.get('review', id);
10
+ if (!r) return fail(ctx, `No such review: '${id}'`, 404, 'resource_missing');
11
+ const refused = ctx.legal('review', 'open', 'PostReviewsReviewApprove', r.open === true, undefined, id);
12
+ if (refused) return ctx.refuse(refused);
13
+ return ctx.reply(await ctx.write('review', id, { open: false, closed_reason: 'approved', reason: 'approved' }, 'review.closed'));
14
+ };
15
+
16
+ const VL = 'radar.value_list';
17
+ const VLI = 'radar.value_list_item';
18
+ const listMissing = (ctx: SemanticsContext, id: string, status = 404): Response => fail(ctx, `No such radar value list: '${id}'`, status, 'resource_missing');
19
+
20
+ const createList: Semantics = async (ctx) => {
21
+ const params = ctx.params;
22
+ if (params.alias === undefined || params.alias === '') return fail(ctx, 'Missing required param: alias.', 400, 'parameter_missing');
23
+ if (params.name === undefined || params.name === '') return fail(ctx, 'Missing required param: name.', 400, 'parameter_missing');
24
+ const made = await created(ctx, VL, params, {
25
+ item_type: typeof params.item_type === 'string' ? params.item_type : 'string', livemode: false, metadata: {},
26
+ created_by: 'twin', list_items: { object: 'list', data: [], has_more: false, url: '' },
27
+ });
28
+ await syncItems(ctx, String(made.id));
29
+ return ctx.reply(ctx.get(VL, String(made.id))!);
30
+ };
31
+
32
+ /** A value list's list_items, "List of items contained within this value list" (the served spec), kept in step with
33
+ * its items, at the address the fixture names (/v1/radar/value_list_items?value_list=…). */
34
+ async function syncItems(ctx: SemanticsContext, listId: string): Promise<void> {
35
+ const data = newest(ctx, VLI).filter((i) => i.value_list === listId);
36
+ await ctx.write(VL, listId, { list_items: { object: 'list', data, has_more: false, url: `/v1/radar/value_list_items?value_list=${listId}` } }, 'radar.value_list.items_synced');
37
+ }
38
+
39
+ const updateList: Semantics = async (ctx) => {
40
+ const id = at(ctx, 'value_list');
41
+ if (!ctx.row(VL, id, { withDeleted: true })) return listMissing(ctx, id);
42
+ return ctx.reply(await ctx.write(VL, id, ctx.params, 'radar.value_list.updated'));
43
+ };
44
+
45
+ const deleteList: Semantics = async (ctx) => {
46
+ const id = at(ctx, 'value_list');
47
+ if (!ctx.row(VL, id, { withDeleted: true })) return listMissing(ctx, id);
48
+ await ctx.write(VL, id, { deleted: true }, 'radar.value_list.deleted');
49
+ return ctx.reply({ id, object: 'radar.value_list', deleted: true });
50
+ };
51
+
52
+ const addItem: Semantics = async (ctx) => {
53
+ const listId = typeof ctx.params.value_list === 'string' ? ctx.params.value_list : '';
54
+ if (!listId) return fail(ctx, 'Missing required param: value_list.', 400, 'parameter_missing');
55
+ if (!ctx.row(VL, listId, { withDeleted: true })) return listMissing(ctx, listId, 400);
56
+ if (ctx.params.value === undefined || ctx.params.value === '') return fail(ctx, 'Missing required param: value.', 400, 'parameter_missing');
57
+ const item = await created(ctx, VLI, { value_list: listId, value: ctx.params.value }, { livemode: false, created_by: 'twin' });
58
+ await syncItems(ctx, listId);
59
+ return ctx.reply(item);
60
+ };
61
+
62
+ const items: Semantics = async (ctx) => {
63
+ const listId = typeof ctx.params.value_list === 'string' ? ctx.params.value_list : '';
64
+ if (!listId) return fail(ctx, 'Missing required param: value_list.', 400, 'parameter_missing');
65
+ return list(ctx, VLI, newest(ctx, VLI).filter((i) => i.value_list === listId));
66
+ };
67
+
68
+ const removeItem: Semantics = async (ctx) => {
69
+ const id = at(ctx, 'item');
70
+ const was = ctx.row(VLI, id, { withDeleted: true });
71
+ if (!was) return fail(ctx, `No such radar value list item: '${id}'`, 404, 'resource_missing');
72
+ await ctx.write(VLI, id, { deleted: true }, 'radar.value_list_item.deleted');
73
+ if (typeof was.value_list === 'string' && ctx.get(VL, was.value_list)) await syncItems(ctx, was.value_list);
74
+ return ctx.reply({ id, object: 'radar.value_list_item', deleted: true });
75
+ };
76
+
77
+ export const radar: Record<string, Semantics> = {
78
+ PostReviewsReviewApprove: approve,
79
+ PostRadarValueLists: createList,
80
+ PostRadarValueListsValueList: updateList,
81
+ DeleteRadarValueListsValueList: deleteList,
82
+ PostRadarValueListItems: addItem,
83
+ GetRadarValueListItems: items,
84
+ DeleteRadarValueListItemsItem: removeItem,
85
+ };
@@ -0,0 +1,218 @@
1
+ // Refund semantics. A refund always names what it took money out of and lands there: a refund
2
+ // naming nothing, an id nobody minted, or more than the charge has left is refused as Stripe
3
+ // refuses it. The machine in ../manifest.ts says only a refund waiting on the customer
4
+ // (requires_action) may be canceled. List and update are the derived core's.
5
+ //
6
+ // Refunding a destination charge can take back the connected account's share (reverse_transfer: a reversal of the
7
+ // charge's transfer, in proportion to the refund) and the platform's (refund_application_fee: a refund of the
8
+ // application fee, in proportion) (docs.stripe.com/api/refunds/create#create_refund-reverse_transfer,
9
+ // docs.stripe.com/connect/destination-charges#issue-refunds). Where the documentation stops and the twin decides: the
10
+ // twin's destination charge transfers the amount less the fee (the fee is kept back rather than collected from the
11
+ // connected account), so a fee refund moves no money of its own; the reversal and the refund settle both shares.
12
+ //
13
+ // A payment made by bank transfer (from the customer's cash balance) is refunded to the customer's bank account: Stripe
14
+ // emails the customer (instructions_email, else the customer's email) for their bank details and the refund waits in
15
+ // requires_action, moving no money, until they answer; only then can it still be canceled. With origin=customer_balance
16
+ // it goes back to the cash balance at once (docs.stripe.com/payments/customer-balance/refunding,
17
+ // docs.stripe.com/api/refunds/cancel). Where the documentation stops and the twin decides: the customer never answers
18
+ // the email in the World, and its link expires 30 days on.
19
+ //
20
+ // An uncaptured charge holds an authorization, not money. A PaymentIntent's is not refunded: "the charge attached to the
21
+ // PaymentIntent remains uncaptured and can't be refunded directly. You must cancel the PaymentIntent"
22
+ // (docs.stripe.com/refunds), which releases it (charges.ts cancelAuthorization). A charge made with capture=false is
23
+ // released by its refund, as its authorization would be "automatically refunded if uncaptured" (spec/openapi.json.gz,
24
+ // `capture_before`): refunded in full, no balance moved, and its capture refused afterwards. Where the documentation
25
+ // stops and the twin decides: the refusal's code (payment_intent_unexpected_state) and wording, and that a refund of a
26
+ // capture=false charge releases the whole authorization, so an `amount` short of it is refused.
27
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
28
+ import { releaseAuthorization } from './charges.ts';
29
+ import { actingAccount, availableAt, fundsDueBetween, settleRefund, settleTransferReversal } from './ledger.ts';
30
+ import { asBool } from '../stripe-twin.ts';
31
+ import { at, at_, created, fail, newest, refundDestination, type Row } from './shared.ts';
32
+
33
+ // the reasons a caller may give; `expired_uncaptured_charge` is Stripe's own
34
+ const REFUND_REASONS = new Set(['duplicate', 'fraudulent', 'requested_by_customer']);
35
+
36
+ /**
37
+ * Create a refund and land it on its charge. An omitted amount is the whole remaining balance. The
38
+ * charge's write records `charge.refunded`, the event Stripe sends carrying the charge's new totals;
39
+ * `refunded` is true only at the full amount.
40
+ */
41
+ /** A reason Stripe does not take (docs.stripe.com/api/refunds/create#create_refund-reason). */
42
+ function badReason(ctx: SemanticsContext): Response {
43
+ return fail(ctx, `Invalid refund reason: must be one of ${[...REFUND_REASONS].join(', ')}.`, 400, 'parameter_invalid_string_enum');
44
+ }
45
+
46
+ /** A bank-transfer payment refunded with origin=customer_balance goes back to the cash balance
47
+ * (docs.stripe.com/payments/customer-balance/refunding). */
48
+ async function backToCashBalance(ctx: SemanticsContext, customer: string, currency: string, amount: number, refundId: string): Promise<void> {
49
+ const before = ctx.rows('customer_cash_balance_transaction').filter((t) => t.customer === customer && String(t.currency) === currency).reduce((n, t) => n + (Number(t.net_amount) || 0), 0);
50
+ await created(ctx, 'customer_cash_balance_transaction', { customer }, { currency, type: 'refunded_from_payment', net_amount: amount, ending_balance: before + amount, refunded_from_payment: { refund: refundId }, livemode: false });
51
+ }
52
+
53
+ /** A capture=false charge's refund, which releases its whole authorization (see the header). */
54
+ async function refundUncaptured(ctx: SemanticsContext, charge: Row, amount: number, remaining: number, params: Row): Promise<Response> {
55
+ if (amount < remaining) return fail(ctx, `Charge ${String(charge.id)} is uncaptured: refunding it releases the whole authorization (${remaining}).`, 400, 'amount_too_small');
56
+ const { reason, metadata } = params;
57
+ return ctx.reply(await releaseAuthorization(ctx, charge, ctx.call.operation.id, { ...(reason !== undefined ? { reason } : {}), ...(metadata !== undefined ? { metadata } : {}) }));
58
+ }
59
+
60
+ export async function refundCharge(ctx: SemanticsContext, charge: Row, params: Row): Promise<Response> {
61
+ const chargeId = String(charge.id);
62
+ const total = Number(charge.amount) || 0;
63
+ const already = Number(charge.amount_refunded) || 0;
64
+ const remaining = Math.max(0, total - already);
65
+ const amount = params.amount === undefined ? remaining : Math.trunc(Number(params.amount));
66
+ if (!Number.isFinite(amount) || amount <= 0) return fail(ctx, 'Invalid integer: amount must be a positive integer.', 400, 'parameter_invalid_integer');
67
+ if (amount > remaining) return fail(ctx, `Refund amount (${amount}) is greater than unrefunded amount on charge (${remaining}).`, 400, 'amount_too_large');
68
+ if (params.reason !== undefined && !REFUND_REASONS.has(String(params.reason))) return badReason(ctx);
69
+ // an authorization is released, never refunded out of the balance: a PaymentIntent's by cancelling the intent, a
70
+ // capture=false charge's by its refund (refundUncaptured)
71
+ if (charge.captured === false && typeof charge.payment_intent === 'string') return fail(ctx, `Charge ${chargeId} is uncaptured and can't be refunded directly; cancel its PaymentIntent (${charge.payment_intent}) to release the authorization.`, 400, 'payment_intent_unexpected_state');
72
+ if (charge.captured === false) return refundUncaptured(ctx, charge, amount, remaining, params);
73
+ const refunded = already + amount;
74
+ // the refund that takes the rest refunds the charge: the charge machine's move under this operation
75
+ if (refunded >= total && charge.refunded !== true) {
76
+ const refused = ctx.legal('charge', 'refunded', ctx.call.operation.id, 'false', 'true', chargeId);
77
+ if (refused) return ctx.refuse(refused);
78
+ }
79
+ // a refund has no `livemode` field; it debits the balance at once
80
+ const refundId = ctx.mint('refund');
81
+ const currency = String(charge.currency ?? 'usd');
82
+ const pi = typeof charge.payment_intent === 'string' ? ctx.get('payment_intent', charge.payment_intent) : undefined;
83
+ const byTransfer = ((pi?.payment_method_types as unknown[] | undefined) ?? []).includes('customer_balance');
84
+ const toCashBalance = byTransfer && params.origin === 'customer_balance';
85
+ const awaitsBank = byTransfer && !toCashBalance;
86
+ const now = Number(ctx.now());
87
+ const customer = typeof charge.customer === 'string' ? charge.customer : typeof pi?.customer === 'string' ? pi.customer : undefined;
88
+ const emailTo = typeof params.instructions_email === 'string' ? params.instructions_email : (customer ? ctx.get('customer', customer)?.email : undefined) ?? null;
89
+ // "Refunds use your available Stripe balance (not including pending amounts). If your available balance doesn't
90
+ // cover the amount of the refund, Stripe holds the refund as pending for card transactions ... until your Stripe
91
+ // balance becomes sufficient" (docs.stripe.com/refunds): the refund waits, moving no money, until the balance covers
92
+ // it (settleHeldRefunds). Where the documentation stops and the twin decides: a refund of another payment method
93
+ // short of balance is not modelled (Stripe fails it); the twin makes it as a card's.
94
+ const account = actingAccount(ctx);
95
+ const isCard = (charge.payment_method_details as Row | null | undefined)?.type === 'card';
96
+ const held = !awaitsBank && !toCashBalance && isCard && availableAt(ctx, account, currency, now) < amount;
97
+ if (held) ctx.legal('refund', 'status', ctx.call.operation.id, 'succeeded', 'pending', refundId);
98
+ const bt = awaitsBank || held ? null : await settleRefund(ctx, refundId, amount, currency);
99
+ if (toCashBalance && customer) await backToCashBalance(ctx, customer, currency, amount, refundId);
100
+ const { reverse_transfer: reverseTransfer, refund_application_fee: refundFee, instructions_email: _email, origin: _origin, ...stored } = params;
101
+ const share = (whole: number): number => Math.round((whole * amount) / (total || 1));
102
+ let transferReversal: string | null = null;
103
+ const transfer = typeof charge.transfer === 'string' ? ctx.get('transfer', charge.transfer) : undefined;
104
+ if (reverseTransfer !== undefined && asBool(reverseTransfer) && transfer) {
105
+ const back = Math.min(share(Number(transfer.amount) || 0), (Number(transfer.amount) || 0) - (Number(transfer.amount_reversed) || 0));
106
+ const trr = await created(ctx, 'transfer_reversal', { amount: back, currency, transfer: transfer.id }, { balance_transaction: null, destination_payment_refund: null, source_refund: refundId, metadata: {} });
107
+ const trrBt = await settleTransferReversal(ctx, String(trr.id), back, currency, String(transfer.destination));
108
+ const reversal = await ctx.write('transfer_reversal', String(trr.id), { balance_transaction: trrBt }, 'transfer_reversal.updated');
109
+ const reversed = (Number(transfer.amount_reversed) || 0) + back;
110
+ const list = (transfer.reversals as Row | undefined) ?? { object: 'list', data: [], has_more: false, total_count: 0 };
111
+ const data = [...(((list.data as unknown[]) ?? [])), reversal];
112
+ await ctx.write('transfer', String(transfer.id), { amount_reversed: reversed, reversed: reversed >= (Number(transfer.amount) || 0), reversals: { ...list, data, total_count: data.length, url: `/v1/transfers/${String(transfer.id)}/reversals` } }, 'transfer.reversed');
113
+ transferReversal = String(trr.id);
114
+ }
115
+ const fee = typeof charge.application_fee === 'string' ? ctx.get('application_fee', charge.application_fee) : undefined;
116
+ if (refundFee !== undefined && asBool(refundFee) && fee) {
117
+ const feeTotal = Number(fee.amount) || 0;
118
+ const back = Math.min(share(feeTotal), feeTotal - (Number(fee.amount_refunded) || 0));
119
+ const done = (Number(fee.amount_refunded) || 0) + back;
120
+ if (!(done >= feeTotal && fee.refunded !== true && ctx.legal('application_fee', 'refunded', ctx.call.operation.id, 'false', 'true', String(fee.id)))) {
121
+ const fr = await created(ctx, 'fee_refund', { amount: back, currency, fee: fee.id }, { balance_transaction: null, metadata: {} });
122
+ const list = (fee.refunds as Row | undefined) ?? { object: 'list', data: [], has_more: false, total_count: 0 };
123
+ const data = [...(((list.data as unknown[]) ?? [])), fr];
124
+ await ctx.write('application_fee', String(fee.id), { amount_refunded: done, refunded: done >= feeTotal, refunds: { ...list, data, total_count: data.length, url: `/v1/application_fees/${String(fee.id)}/refunds` } }, 'application_fee.refunded');
125
+ }
126
+ }
127
+ const body = await created(ctx, 'refund', {
128
+ ...stored, id: refundId, charge: chargeId, amount, currency, transfer_reversal: transferReversal,
129
+ ...(typeof charge.payment_intent === 'string' ? { payment_intent: charge.payment_intent } : {}),
130
+ }, {
131
+ status: awaitsBank ? 'requires_action' : held ? 'pending' : 'succeeded', metadata: {}, reason: null, receipt_number: null,
132
+ ...(held ? { pending_reason: 'insufficient_funds', _account: account ?? null } : {}),
133
+ balance_transaction: bt, source_transfer_reversal: null,
134
+ // a bank transfer's refund goes to the customer's bank (a us_bank_transfer) or back to its cash balance
135
+ // (customer_cash_balance): "If this is a `us_bank_transfer` refund, this hash contains the transaction specific details",
136
+ // "If this is a `customer_cash_balance` refund ..." (docs.stripe.com/api/refunds/object, destination_details)
137
+ ...(byTransfer
138
+ ? { destination_details: toCashBalance ? { type: 'customer_cash_balance', customer_cash_balance: {} } : { type: 'us_bank_transfer', us_bank_transfer: { reference: null, reference_status: null } } }
139
+ : refundDestination(charge, 'refund')),
140
+ ...(awaitsBank ? { next_action: { type: 'display_details', display_details: { email_sent: { email_sent_at: now, email_sent_to: emailTo }, expires_at: now + 30 * 86_400 } } } : {}),
141
+ });
142
+ await ctx.write('charge', chargeId, { amount_refunded: refunded, refunded: refunded >= total }, 'charge.refunded');
143
+ return ctx.reply(body);
144
+ }
145
+
146
+ /** Time's settling of held refunds, caught up to the World's clock: each refund held for want of balance, oldest first,
147
+ * is made at the first moment the account's available balance covers it (the funds that come due then), its debit
148
+ * written then, as `refund.updated`. Where the documentation stops and the twin decides: a held refund is made the
149
+ * moment the balance covers it. */
150
+ export async function settleHeldRefunds(ctx: SemanticsContext): Promise<void> {
151
+ const now = Number(ctx.now());
152
+ const heldRefunds = ctx.rowsRaw('refund').filter((r) => r.status === 'pending' && r.pending_reason === 'insufficient_funds').sort((a, b) => Number(a.created) - Number(b.created));
153
+ for (const r of heldRefunds) {
154
+ const account = typeof r._account === 'string' ? r._account : undefined;
155
+ const currency = String(r.currency ?? 'usd');
156
+ const amount = Number(r.amount) || 0;
157
+ const when = [Number(r.created), ...fundsDueBetween(ctx, account, currency, Number(r.created), now)].find((t) => availableAt(ctx, account, currency, t) >= amount);
158
+ if (when === undefined) continue;
159
+ const c = await at_(ctx)(when);
160
+ c.legal('refund', 'status', ctx.call.operation.id, 'pending', 'succeeded', String(r.id), 'vendor');
161
+ const bt = await settleRefund(c, String(r.id), amount, currency, account ?? null);
162
+ await c.write('refund', String(r.id), { status: 'succeeded', pending_reason: null, balance_transaction: bt }, 'refund.updated');
163
+ }
164
+ }
165
+
166
+ /** The charge a refund aims at: named directly, or reached through its PaymentIntent. */
167
+ function target(ctx: SemanticsContext): Row | Response {
168
+ const chargeId = typeof ctx.params.charge === 'string' ? ctx.params.charge : '';
169
+ const piId = typeof ctx.params.payment_intent === 'string' ? ctx.params.payment_intent : '';
170
+ if (!chargeId && !piId) return fail(ctx, 'One of `charge` or `payment_intent` is required.', 400, 'parameter_missing');
171
+ if (chargeId) return ctx.get('charge', chargeId) ?? fail(ctx, `No such charge: '${chargeId}'`, 404, 'resource_missing');
172
+ const pi = ctx.get('payment_intent', piId);
173
+ if (!pi) return fail(ctx, `No such payment_intent: '${piId}'`, 404, 'resource_missing');
174
+ const latest = typeof pi.latest_charge === 'string' ? pi.latest_charge : undefined;
175
+ const ch = (latest ? ctx.get('charge', latest) : undefined) ?? newest(ctx, 'charge').find((c) => c.payment_intent === piId);
176
+ return ch ?? fail(ctx, `This PaymentIntent does not have a successful charge to refund. Its status is ${String(pi.status)}.`, 400, 'payment_intent_unexpected_state');
177
+ }
178
+
179
+ const create: Semantics = async (ctx) => {
180
+ const ch = target(ctx);
181
+ return ch instanceof Response ? ch : refundCharge(ctx, ch, ctx.params);
182
+ };
183
+
184
+ const refundMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such refund: '${id}'`, 404, 'resource_missing');
185
+
186
+ const retrieve: Semantics = async (ctx) => {
187
+ const r = ctx.get('refund', at(ctx, 'refund'));
188
+ return r ? ctx.reply(r) : refundMissing(ctx, at(ctx, 'refund'));
189
+ };
190
+
191
+ // a canceled pending refund gives its money back to the charge: pending funds were already taken out
192
+ const cancel: Semantics = async (ctx) => {
193
+ const id = at(ctx, 'refund');
194
+ const r = ctx.get('refund', id);
195
+ if (!r) return refundMissing(ctx, id);
196
+ const refused = ctx.legal('refund', 'status', 'PostRefundsRefundCancel', r.status, undefined, id);
197
+ if (refused) return ctx.refuse(refused);
198
+ // next_action says what a refund "needs to continue processing" "If the refund has a status of requires_action"
199
+ // (docs.stripe.com/api/refunds/object): a canceled one needs nothing
200
+ const canceled = await ctx.write('refund', id, { status: 'canceled', next_action: null }, 'refund.updated');
201
+ const chargeId = typeof r.charge === 'string' ? r.charge : '';
202
+ const ch = chargeId ? ctx.get('charge', chargeId) : undefined;
203
+ if (ch) {
204
+ const total = Number(ch.amount) || 0;
205
+ const back = Math.max(0, (Number(ch.amount_refunded) || 0) - (Number(r.amount) || 0));
206
+ const refunded = back > 0 && back >= total;
207
+ // a charge the canceled refund had fully refunded is no longer refunded
208
+ if (refunded !== (ch.refunded === true)) ctx.legal('charge', 'refunded', 'PostRefundsRefundCancel', String(ch.refunded === true), String(refunded), chargeId);
209
+ await ctx.write('charge', chargeId, { amount_refunded: back, refunded }, 'charge.update');
210
+ }
211
+ return ctx.reply(canceled);
212
+ };
213
+
214
+ export const refunds: Record<string, Semantics> = {
215
+ PostRefunds: create,
216
+ GetRefundsRefund: retrieve,
217
+ PostRefundsRefundCancel: cancel,
218
+ };