@atlasauth/emulator-core 0.1.0 → 0.3.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 (300) hide show
  1. package/dist/adopt.d.ts +50 -0
  2. package/dist/adopt.js +57 -0
  3. package/dist/adopt.js.map +1 -0
  4. package/dist/coverage.d.ts +15 -0
  5. package/dist/coverage.js +31 -0
  6. package/dist/coverage.js.map +1 -0
  7. package/dist/crypto-util.d.ts +10 -0
  8. package/dist/crypto-util.js +76 -0
  9. package/dist/crypto-util.js.map +1 -0
  10. package/dist/custom-html.d.ts +57 -0
  11. package/dist/custom-html.js +491 -0
  12. package/dist/custom-html.js.map +1 -0
  13. package/dist/engine.d.ts +34 -3
  14. package/dist/engine.js +57 -8
  15. package/dist/engine.js.map +1 -1
  16. package/dist/error-catalog.js +16 -0
  17. package/dist/error-catalog.js.map +1 -1
  18. package/dist/event-gate.d.ts +3 -0
  19. package/dist/event-gate.js +430 -0
  20. package/dist/event-gate.js.map +1 -0
  21. package/dist/generic.js +14 -2
  22. package/dist/generic.js.map +1 -1
  23. package/dist/handlers/access-signals.d.ts +48 -0
  24. package/dist/handlers/access-signals.js +76 -0
  25. package/dist/handlers/access-signals.js.map +1 -0
  26. package/dist/handlers/approvals.d.ts +2 -0
  27. package/dist/handlers/approvals.js +418 -0
  28. package/dist/handlers/approvals.js.map +1 -0
  29. package/dist/handlers/authz-data.d.ts +2 -0
  30. package/dist/handlers/authz-data.js +627 -0
  31. package/dist/handlers/authz-data.js.map +1 -0
  32. package/dist/handlers/authz-layers.d.ts +229 -0
  33. package/dist/handlers/authz-layers.js +786 -0
  34. package/dist/handlers/authz-layers.js.map +1 -0
  35. package/dist/handlers/authz-state.d.ts +80 -0
  36. package/dist/handlers/authz-state.js +175 -0
  37. package/dist/handlers/authz-state.js.map +1 -0
  38. package/dist/handlers/authz.d.ts +2 -0
  39. package/dist/handlers/authz.js +410 -0
  40. package/dist/handlers/authz.js.map +1 -0
  41. package/dist/handlers/billing.d.ts +100 -0
  42. package/dist/handlers/billing.js +877 -0
  43. package/dist/handlers/billing.js.map +1 -0
  44. package/dist/handlers/cedar-ext.d.ts +35 -0
  45. package/dist/handlers/cedar-ext.js +174 -0
  46. package/dist/handlers/cedar-ext.js.map +1 -0
  47. package/dist/handlers/cedar.d.ts +146 -0
  48. package/dist/handlers/cedar.js +689 -0
  49. package/dist/handlers/cedar.js.map +1 -0
  50. package/dist/handlers/connections.d.ts +5 -0
  51. package/dist/handlers/connections.js +561 -0
  52. package/dist/handlers/connections.js.map +1 -0
  53. package/dist/handlers/control.js +6 -1
  54. package/dist/handlers/control.js.map +1 -1
  55. package/dist/handlers/device.d.ts +47 -0
  56. package/dist/handlers/device.js +665 -0
  57. package/dist/handlers/device.js.map +1 -0
  58. package/dist/handlers/emu3-misc.d.ts +38 -0
  59. package/dist/handlers/emu3-misc.js +1056 -0
  60. package/dist/handlers/emu3-misc.js.map +1 -0
  61. package/dist/handlers/emu4-actions-access.d.ts +46 -0
  62. package/dist/handlers/emu4-actions-access.js +107 -0
  63. package/dist/handlers/emu4-actions-access.js.map +1 -0
  64. package/dist/handlers/emu4-actions-catalog-extra.d.ts +3 -0
  65. package/dist/handlers/emu4-actions-catalog-extra.js +204 -0
  66. package/dist/handlers/emu4-actions-catalog-extra.js.map +1 -0
  67. package/dist/handlers/emu4-actions-catalog.d.ts +3 -0
  68. package/dist/handlers/emu4-actions-catalog.js +143 -0
  69. package/dist/handlers/emu4-actions-catalog.js.map +1 -0
  70. package/dist/handlers/emu4-actions-def.d.ts +50 -0
  71. package/dist/handlers/emu4-actions-def.js +250 -0
  72. package/dist/handlers/emu4-actions-def.js.map +1 -0
  73. package/dist/handlers/emu4-actions-sandbox.d.ts +36 -0
  74. package/dist/handlers/emu4-actions-sandbox.js +318 -0
  75. package/dist/handlers/emu4-actions-sandbox.js.map +1 -0
  76. package/dist/handlers/emu4-actions-typegen.d.ts +5 -0
  77. package/dist/handlers/emu4-actions-typegen.js +73 -0
  78. package/dist/handlers/emu4-actions-typegen.js.map +1 -0
  79. package/dist/handlers/emu4-actions.d.ts +2 -0
  80. package/dist/handlers/emu4-actions.js +506 -0
  81. package/dist/handlers/emu4-actions.js.map +1 -0
  82. package/dist/handlers/emu4-authz-trust.d.ts +40 -0
  83. package/dist/handlers/emu4-authz-trust.js +488 -0
  84. package/dist/handlers/emu4-authz-trust.js.map +1 -0
  85. package/dist/handlers/emu4-authz.d.ts +2 -0
  86. package/dist/handlers/emu4-authz.js +752 -0
  87. package/dist/handlers/emu4-authz.js.map +1 -0
  88. package/dist/handlers/emu4-billing-core.d.ts +23 -0
  89. package/dist/handlers/emu4-billing-core.js +22 -0
  90. package/dist/handlers/emu4-billing-core.js.map +1 -0
  91. package/dist/handlers/emu4-billing-x.d.ts +2 -0
  92. package/dist/handlers/emu4-billing-x.js +489 -0
  93. package/dist/handlers/emu4-billing-x.js.map +1 -0
  94. package/dist/handlers/emu4-billing.d.ts +14 -0
  95. package/dist/handlers/emu4-billing.js +846 -0
  96. package/dist/handlers/emu4-billing.js.map +1 -0
  97. package/dist/handlers/emu4-clones.d.ts +2 -0
  98. package/dist/handlers/emu4-clones.js +322 -0
  99. package/dist/handlers/emu4-clones.js.map +1 -0
  100. package/dist/handlers/emu4-compliance.d.ts +8 -0
  101. package/dist/handlers/emu4-compliance.js +39 -0
  102. package/dist/handlers/emu4-compliance.js.map +1 -0
  103. package/dist/handlers/emu4-config.d.ts +14 -0
  104. package/dist/handlers/emu4-config.js +148 -0
  105. package/dist/handlers/emu4-config.js.map +1 -0
  106. package/dist/handlers/emu4-cookies.d.ts +8 -0
  107. package/dist/handlers/emu4-cookies.js +248 -0
  108. package/dist/handlers/emu4-cookies.js.map +1 -0
  109. package/dist/handlers/emu4-erasure.d.ts +2 -0
  110. package/dist/handlers/emu4-erasure.js +587 -0
  111. package/dist/handlers/emu4-erasure.js.map +1 -0
  112. package/dist/handlers/emu4-federation.d.ts +8 -0
  113. package/dist/handlers/emu4-federation.js +18 -0
  114. package/dist/handlers/emu4-federation.js.map +1 -0
  115. package/dist/handlers/emu4-journey-math.d.ts +42 -0
  116. package/dist/handlers/emu4-journey-math.js +99 -0
  117. package/dist/handlers/emu4-journey-math.js.map +1 -0
  118. package/dist/handlers/emu4-messaging.d.ts +21 -0
  119. package/dist/handlers/emu4-messaging.js +595 -0
  120. package/dist/handlers/emu4-messaging.js.map +1 -0
  121. package/dist/handlers/emu4-minors.d.ts +7 -0
  122. package/dist/handlers/emu4-minors.js +408 -0
  123. package/dist/handlers/emu4-minors.js.map +1 -0
  124. package/dist/handlers/emu4-oauth-audit.d.ts +5 -0
  125. package/dist/handlers/emu4-oauth-audit.js +17 -0
  126. package/dist/handlers/emu4-oauth-audit.js.map +1 -0
  127. package/dist/handlers/emu4-oauth-auth.d.ts +83 -0
  128. package/dist/handlers/emu4-oauth-auth.js +363 -0
  129. package/dist/handlers/emu4-oauth-auth.js.map +1 -0
  130. package/dist/handlers/emu4-oauth-delivery.d.ts +68 -0
  131. package/dist/handlers/emu4-oauth-delivery.js +207 -0
  132. package/dist/handlers/emu4-oauth-delivery.js.map +1 -0
  133. package/dist/handlers/emu4-oauth-keys.d.ts +11 -0
  134. package/dist/handlers/emu4-oauth-keys.js +268 -0
  135. package/dist/handlers/emu4-oauth-keys.js.map +1 -0
  136. package/dist/handlers/emu4-oauth-pub.d.ts +36 -0
  137. package/dist/handlers/emu4-oauth-pub.js +420 -0
  138. package/dist/handlers/emu4-oauth-pub.js.map +1 -0
  139. package/dist/handlers/emu4-oauth.d.ts +10 -0
  140. package/dist/handlers/emu4-oauth.js +23 -0
  141. package/dist/handlers/emu4-oauth.js.map +1 -0
  142. package/dist/handlers/emu4-policies.d.ts +2 -0
  143. package/dist/handlers/emu4-policies.js +408 -0
  144. package/dist/handlers/emu4-policies.js.map +1 -0
  145. package/dist/handlers/emu4-region.d.ts +2 -0
  146. package/dist/handlers/emu4-region.js +189 -0
  147. package/dist/handlers/emu4-region.js.map +1 -0
  148. package/dist/handlers/emu4-saml.d.ts +12 -0
  149. package/dist/handlers/emu4-saml.js +532 -0
  150. package/dist/handlers/emu4-saml.js.map +1 -0
  151. package/dist/handlers/emu4-shared.d.ts +44 -0
  152. package/dist/handlers/emu4-shared.js +90 -0
  153. package/dist/handlers/emu4-shared.js.map +1 -0
  154. package/dist/handlers/emu4-snapshots.d.ts +38 -0
  155. package/dist/handlers/emu4-snapshots.js +231 -0
  156. package/dist/handlers/emu4-snapshots.js.map +1 -0
  157. package/dist/handlers/emu4-sns.d.ts +9 -0
  158. package/dist/handlers/emu4-sns.js +21 -0
  159. package/dist/handlers/emu4-sns.js.map +1 -0
  160. package/dist/handlers/emu4-trust-binding.d.ts +27 -0
  161. package/dist/handlers/emu4-trust-binding.js +344 -0
  162. package/dist/handlers/emu4-trust-binding.js.map +1 -0
  163. package/dist/handlers/emu4-trust.d.ts +35 -0
  164. package/dist/handlers/emu4-trust.js +543 -0
  165. package/dist/handlers/emu4-trust.js.map +1 -0
  166. package/dist/handlers/federation.d.ts +2 -0
  167. package/dist/handlers/federation.js +481 -0
  168. package/dist/handlers/federation.js.map +1 -0
  169. package/dist/handlers/fga-engine.d.ts +78 -0
  170. package/dist/handlers/fga-engine.js +663 -0
  171. package/dist/handlers/fga-engine.js.map +1 -0
  172. package/dist/handlers/governance.d.ts +2 -0
  173. package/dist/handlers/governance.js +460 -0
  174. package/dist/handlers/governance.js.map +1 -0
  175. package/dist/handlers/hooks.js +17 -1
  176. package/dist/handlers/hooks.js.map +1 -1
  177. package/dist/handlers/index.d.ts +5 -1
  178. package/dist/handlers/index.js +45 -3
  179. package/dist/handlers/index.js.map +1 -1
  180. package/dist/handlers/keys.js +6 -1
  181. package/dist/handlers/keys.js.map +1 -1
  182. package/dist/handlers/machines.js +61 -8
  183. package/dist/handlers/machines.js.map +1 -1
  184. package/dist/handlers/messaging.d.ts +6 -0
  185. package/dist/handlers/messaging.js +893 -0
  186. package/dist/handlers/messaging.js.map +1 -0
  187. package/dist/handlers/oauth-adv.d.ts +191 -0
  188. package/dist/handlers/oauth-adv.js +1305 -0
  189. package/dist/handlers/oauth-adv.js.map +1 -0
  190. package/dist/handlers/oauth-flows.d.ts +52 -0
  191. package/dist/handlers/oauth-flows.js +480 -0
  192. package/dist/handlers/oauth-flows.js.map +1 -0
  193. package/dist/handlers/oauth.d.ts +96 -0
  194. package/dist/handlers/oauth.js +793 -0
  195. package/dist/handlers/oauth.js.map +1 -0
  196. package/dist/handlers/orgs.js +6 -0
  197. package/dist/handlers/orgs.js.map +1 -1
  198. package/dist/handlers/passkeys.d.ts +3 -0
  199. package/dist/handlers/passkeys.js +293 -0
  200. package/dist/handlers/passkeys.js.map +1 -0
  201. package/dist/handlers/platform.d.ts +20 -0
  202. package/dist/handlers/platform.js +472 -0
  203. package/dist/handlers/platform.js.map +1 -0
  204. package/dist/handlers/round2.d.ts +8 -0
  205. package/dist/handlers/round2.js +372 -0
  206. package/dist/handlers/round2.js.map +1 -0
  207. package/dist/handlers/scim.d.ts +4 -0
  208. package/dist/handlers/scim.js +595 -0
  209. package/dist/handlers/scim.js.map +1 -0
  210. package/dist/handlers/sessions.d.ts +9 -3
  211. package/dist/handlers/sessions.js +76 -38
  212. package/dist/handlers/sessions.js.map +1 -1
  213. package/dist/handlers/support.d.ts +20 -0
  214. package/dist/handlers/support.js +62 -0
  215. package/dist/handlers/support.js.map +1 -0
  216. package/dist/handlers/users.js +7 -0
  217. package/dist/handlers/users.js.map +1 -1
  218. package/dist/handlers/workflows.d.ts +2 -0
  219. package/dist/handlers/workflows.js +428 -0
  220. package/dist/handlers/workflows.js.map +1 -0
  221. package/dist/index.d.ts +8 -0
  222. package/dist/index.js +23 -1
  223. package/dist/index.js.map +1 -1
  224. package/dist/jwe.d.ts +18 -0
  225. package/dist/jwe.js +101 -0
  226. package/dist/jwe.js.map +1 -0
  227. package/dist/jws.d.ts +41 -0
  228. package/dist/jws.js +135 -0
  229. package/dist/jws.js.map +1 -0
  230. package/dist/messages.d.ts +7 -0
  231. package/dist/messages.js +8 -4
  232. package/dist/messages.js.map +1 -1
  233. package/dist/messaging-callbacks.d.ts +100 -0
  234. package/dist/messaging-callbacks.js +767 -0
  235. package/dist/messaging-callbacks.js.map +1 -0
  236. package/dist/openapi.js +41 -1
  237. package/dist/openapi.js.map +1 -1
  238. package/dist/parity-bin.js +3 -1
  239. package/dist/parity-bin.js.map +1 -1
  240. package/dist/parity-emu3-authz.d.ts +3 -0
  241. package/dist/parity-emu3-authz.js +61 -0
  242. package/dist/parity-emu3-authz.js.map +1 -0
  243. package/dist/parity-emu3-billing.d.ts +3 -0
  244. package/dist/parity-emu3-billing.js +25 -0
  245. package/dist/parity-emu3-billing.js.map +1 -0
  246. package/dist/parity-emu3-messaging.d.ts +3 -0
  247. package/dist/parity-emu3-messaging.js +27 -0
  248. package/dist/parity-emu3-messaging.js.map +1 -0
  249. package/dist/parity-emu3-misc.d.ts +2 -0
  250. package/dist/parity-emu3-misc.js +63 -0
  251. package/dist/parity-emu3-misc.js.map +1 -0
  252. package/dist/parity-emu3-oauth.d.ts +2 -0
  253. package/dist/parity-emu3-oauth.js +39 -0
  254. package/dist/parity-emu3-oauth.js.map +1 -0
  255. package/dist/parity-emu4-actions.d.ts +2 -0
  256. package/dist/parity-emu4-actions.js +38 -0
  257. package/dist/parity-emu4-actions.js.map +1 -0
  258. package/dist/parity-emu4-authz.d.ts +3 -0
  259. package/dist/parity-emu4-authz.js +68 -0
  260. package/dist/parity-emu4-authz.js.map +1 -0
  261. package/dist/parity-emu4-billing.d.ts +6 -0
  262. package/dist/parity-emu4-billing.js +44 -0
  263. package/dist/parity-emu4-billing.js.map +1 -0
  264. package/dist/parity-emu4-compliance.d.ts +2 -0
  265. package/dist/parity-emu4-compliance.js +92 -0
  266. package/dist/parity-emu4-compliance.js.map +1 -0
  267. package/dist/parity-emu4-federation.d.ts +2 -0
  268. package/dist/parity-emu4-federation.js +33 -0
  269. package/dist/parity-emu4-federation.js.map +1 -0
  270. package/dist/parity-emu4-messaging.d.ts +3 -0
  271. package/dist/parity-emu4-messaging.js +42 -0
  272. package/dist/parity-emu4-messaging.js.map +1 -0
  273. package/dist/parity-emu4-oauth.d.ts +2 -0
  274. package/dist/parity-emu4-oauth.js +55 -0
  275. package/dist/parity-emu4-oauth.js.map +1 -0
  276. package/dist/parity-emu4-trust.d.ts +2 -0
  277. package/dist/parity-emu4-trust.js +41 -0
  278. package/dist/parity-emu4-trust.js.map +1 -0
  279. package/dist/parity-routes.d.ts +14 -0
  280. package/dist/parity-routes.js +439 -0
  281. package/dist/parity-routes.js.map +1 -0
  282. package/dist/parity.d.ts +10 -3
  283. package/dist/parity.js +14 -5
  284. package/dist/parity.js.map +1 -1
  285. package/dist/sns-cert.d.ts +9 -0
  286. package/dist/sns-cert.js +101 -0
  287. package/dist/sns-cert.js.map +1 -0
  288. package/dist/tokens.d.ts +85 -3
  289. package/dist/tokens.js +196 -24
  290. package/dist/tokens.js.map +1 -1
  291. package/dist/webauthn-virtual.d.ts +70 -0
  292. package/dist/webauthn-virtual.js +186 -0
  293. package/dist/webauthn-virtual.js.map +1 -0
  294. package/dist/webhook-ext.d.ts +16 -0
  295. package/dist/webhook-ext.js +218 -0
  296. package/dist/webhook-ext.js.map +1 -0
  297. package/dist/webhooks.d.ts +3 -0
  298. package/dist/webhooks.js +37 -3
  299. package/dist/webhooks.js.map +1 -1
  300. package/package.json +2 -2
@@ -0,0 +1,877 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.billingInternals = void 0;
4
+ exports.billingHandlers = billingHandlers;
5
+ const errors_1 = require("../errors");
6
+ const store_1 = require("../store");
7
+ const support_1 = require("./support");
8
+ const util_1 = require("./util");
9
+ /**
10
+ * Billing on top of the engine's own state: subscriptions (imported, with trials that can be extended), invoices and
11
+ * refunds, the usage ledger (single report, batch, read-back) and entitlement consume and refund. One ledger holds usage:
12
+ * a report is a positive row, a consume a positive row, a refund a negative row dated like the consume it undoes, so the
13
+ * read-back and the limit check always agree. Plans, coupons and the rest of the catalogue are the generic CRUD layer's;
14
+ * a plan's numeric `limits` are what a consume checks. Invoices come from the provider in production: create them here
15
+ * with `POST /__emulator/billing/invoices`.
16
+ */
17
+ const DAY = 86_400_000;
18
+ const MAX_TRIAL_MS = 365 * DAY;
19
+ const BATCH_MAX = 100;
20
+ const SUBJECTS = ['user', 'org'];
21
+ const st = (0, support_1.engineState)((e) => ({ subs: new store_1.Table('billing_subscriptions', e.clock.now, 'sub'), invoices: new store_1.Table('billing_invoices', e.clock.now, 'in'), refunds: new store_1.Table('billing_refunds', e.clock.now, 're'), usage: new store_1.Table('billing_usage', e.clock.now, 'bu'), extensions: new Map(), consumes: new Map(), balances: new Map(), catalogue: new Map(), customerBalance: new Map() }));
22
+ const MONTH = 30 * DAY;
23
+ const PROVIDERS = ['stripe', 'polar', 'dodo', 'creem', 'chargebee', 'autumn', 'commet', 'paddle', 'lemonsqueezy', 'revenuecat', 'appstore', 'googleplay'];
24
+ const ENT_PROVIDERS = new Set(['autumn', 'commet']); // providers that own feature balances
25
+ const SCHEDULING = new Set(['stripe', 'chargebee']); // providers that can schedule a plan change for the period end
26
+ const STORE_OWNED = new Set(['revenuecat', 'appstore', 'googleplay']); // the store owns change, cancel and restore: 422 unsupported
27
+ const NO_RESTORE = new Set(['creem', ...STORE_OWNED]);
28
+ const FEATURE = /^[A-Za-z0-9_.:-]{1,100}$/;
29
+ const MAX_AMOUNT = 1_000_000_000;
30
+ const bad = (message, param) => new errors_1.EmulatorError('VALIDATION_FAILED', message, param);
31
+ /** The real mapping of a refused billing operation: 409 no_subscription / conflict, 422 unsupported, 400 invalid. */
32
+ const noSub = (m) => new errors_1.EmulatorError('CONFLICT', m, undefined, { meta: { reason: 'no_subscription' } });
33
+ const conflict = (m, meta) => new errors_1.EmulatorError('CONFLICT', m, undefined, meta ? { meta } : {});
34
+ const unsupported = (m) => Object.assign(new errors_1.EmulatorError('VALIDATION_FAILED', m, undefined, { meta: { reason: 'unsupported' } }), { status: 422 });
35
+ /** A body key outside `allowed` is refused (the real routes are strict). */
36
+ function known(body, allowed) {
37
+ const k = Object.keys(body ?? {}).find((x) => !allowed.includes(x));
38
+ if (k)
39
+ throw new errors_1.EmulatorError('UNKNOWN_FIELD', `Unknown field: ${k}.`, k);
40
+ }
41
+ function subject(e, body) {
42
+ const type = (0, support_1.oneOf)(body, 'subject_type', SUBJECTS);
43
+ const id = body.subject_id;
44
+ if (typeof id !== 'string' || !id)
45
+ throw bad('subject_id is required.', 'subject_id');
46
+ const exists = type === 'user' ? e.db.users.get(id) && !e.db.users.get(id).deleted : e.db.organizations.get(id);
47
+ if (!exists)
48
+ throw new errors_1.EmulatorError('NOT_FOUND', `${type === 'user' ? 'User' : 'Organization'} not found.`, 'subject_id');
49
+ return { type, id };
50
+ }
51
+ const planOf = (e, id) => (id ? e.generic.get('billing/plans')?.get(id) : undefined);
52
+ const subView = (e, s) => e.shape('Subscription', { object: 'billing_subscription', id: s.id, subject_type: s.subject_type, subject_id: s.subject_id, plan_id: s.plan_id, status: s.status, stripe_subscription_id: s.stripe_subscription_id ?? null, stripe_customer_id: s.stripe_customer_id ?? null, current_period_end: s.current_period_end, provider: s.provider, provider_subscription_id: s.stripe_subscription_id ?? null, provider_customer_id: s.stripe_customer_id ?? null, account: 'instance', interval: s.interval ?? 'month', quantity: s.quantity ?? 1, cancel_at_period_end: Boolean(s.cancel_at_period_end), scheduled_plan_id: s.scheduled_plan_id ?? null, scheduled_interval: s.scheduled_interval ?? null, scheduled_change_at: s.scheduled_change_at ?? null, trial_end: s.trial_end ?? null, canceled_at: s.canceled_at ?? null, entitlements: s.entitlements ?? null, created_at: s.created_at, updated_at: s.updated_at });
53
+ // ── events: the subscription payload, the trial and the entitlement transition ──
54
+ const moved = (r) => ({ plan_id: r.plan_id, status: r.status, interval: r.interval ?? 'month', quantity: r.quantity ?? 1, cancel_at_period_end: Boolean(r.cancel_at_period_end), current_period_end: r.current_period_end, scheduled_plan_id: r.scheduled_plan_id ?? null });
55
+ function emitSub(e, type, s, prev, reason, extra = {}) {
56
+ e.webhooks.emit(type, { ...subView(e, s), plan: planOf(e, s.plan_id)?.slug ?? null, ...(reason ? { reason } : {}), ...extra, previous: prev ? moved(prev) : null });
57
+ }
58
+ /** What a subject is entitled to now: its live (active or trialing) subscriptions merged; undefined when none. */
59
+ function entSnap(e, type, id) {
60
+ const live = st(e).subs.filter((s) => s.subject_type === type && s.subject_id === id && (s.status === 'active' || s.status === 'trialing'));
61
+ if (!live.length)
62
+ return undefined;
63
+ const primary = live.find((s) => planOf(e, s.plan_id)?.kind !== 'addon') ?? live[0];
64
+ const features = new Set();
65
+ const limits = {};
66
+ for (const s of live) {
67
+ const p = planOf(e, s.plan_id);
68
+ for (const f of Array.isArray(p?.features) ? p.features : [])
69
+ features.add(f);
70
+ for (const [k, v] of Object.entries((p?.limits ?? {})))
71
+ limits[k] = typeof v === 'number' && typeof limits[k] === 'number' ? Math.max(limits[k], v) : (limits[k] ?? v);
72
+ }
73
+ return { planId: primary.plan_id, plan: planOf(e, primary.plan_id)?.slug ?? null, status: primary.status, features: [...features], limits };
74
+ }
75
+ const entSig = (x) => (x ? JSON.stringify({ p: x.planId, f: [...x.features].sort(), l: Object.entries(x.limits).sort(([a], [b]) => (a < b ? -1 : 1)) }) : 'none');
76
+ /** entitlement.created / updated / deleted, only when the resolved entitlement moved. */
77
+ function emitEnt(e, type, id, before) {
78
+ const after = entSnap(e, type, id);
79
+ if (entSig(before) === entSig(after))
80
+ return;
81
+ const v = (x) => ({ plan_id: x?.planId ?? null, plan: x?.plan ?? null, status: x?.status ?? null, entitled: Boolean(x), features: x?.features ?? [] });
82
+ e.webhooks.emit(!before && after ? 'entitlement.created' : before && !after ? 'entitlement.deleted' : 'entitlement.updated', { object: 'entitlement', subject_type: type, subject_id: id, ...v(after), previous: v(before) });
83
+ }
84
+ // ── prices and invoices of a renewal or plan change ──
85
+ /** shortcut: a plan's `amount` is display text ("$9.00"); its digits read as major units, 0 when absent. */
86
+ function priceCents(plan, interval) {
87
+ const raw = interval === 'year' && plan?.interval !== 'year' ? plan?.annual_amount : plan?.amount;
88
+ const n = parseFloat(String(raw ?? '').replace(/[^0-9.]/g, ''));
89
+ return Number.isFinite(n) ? Math.round(n * 100) : 0;
90
+ }
91
+ const periodLen = (interval) => (interval === 'year' ? 365 * DAY : MONTH);
92
+ const lineView = (currency, l) => ({ object: 'billing_invoice_line', description: l.description, amount: l.amount, currency, quantity: l.quantity, proration: l.proration, plan_id: l.plan_id, period: { start: l.period_start, end: l.period_end } });
93
+ const balKey2 = (s) => `${s.subject_type}|${s.subject_id}`;
94
+ const day = (t) => new Date(t).toISOString().slice(0, 10);
95
+ /** The invoice the subscription's next billing would produce: renewal at the plan's price plus the proration items waiting, less customer balance. */
96
+ function draft(e, s, at) {
97
+ const plan = planOf(e, s.plan_id);
98
+ const price = priceCents(plan, s.interval ?? 'month') * (s.quantity ?? 1);
99
+ const lines = [];
100
+ if (price > 0)
101
+ lines.push({ description: `${plan?.name ?? s.plan_id} (${s.interval ?? 'month'}ly)`, amount: price, quantity: s.quantity ?? 1, proration: false, plan_id: s.plan_id, period_start: at, period_end: at + periodLen(s.interval) });
102
+ lines.push(...(s.pending_lines ?? []));
103
+ const total = lines.reduce((n, l) => n + l.amount, 0);
104
+ const applied = Math.min(Math.max(total, 0), st(e).customerBalance.get(balKey2(s)) ?? 0);
105
+ return { lines, total, applied, due: Math.max(total, 0) - applied, currency: plan?.currency || 'usd' };
106
+ }
107
+ /**
108
+ * An invoice for the plan's price plus the proration items waiting from earlier plan changes. A negative total (a downgrade)
109
+ * is not paid out: it becomes customer balance and reduces the next invoice, as Stripe does. Open, with a payment_failed
110
+ * event, when the subscription's payment is set to fail and something is due; no invoice when there is nothing to bill.
111
+ */
112
+ function bill(e, s, at) {
113
+ if (!(s.pending_lines?.length) && priceCents(planOf(e, s.plan_id), s.interval ?? 'month') <= 0)
114
+ return true;
115
+ const d = draft(e, s, at);
116
+ s.pending_lines = [];
117
+ const key = balKey2(s);
118
+ const balance = st(e).customerBalance;
119
+ const fail = Boolean(s.payment_failing) && d.due > 0;
120
+ const lines = [...d.lines];
121
+ if (d.total < 0)
122
+ balance.set(key, (balance.get(key) ?? 0) - d.total);
123
+ else if (d.applied > 0 && !fail)
124
+ balance.set(key, (balance.get(key) ?? 0) - d.applied);
125
+ if (d.applied > 0)
126
+ lines.push({ description: 'Applied customer balance', amount: -d.applied, quantity: 1, proration: false, plan_id: null, period_start: at, period_end: at });
127
+ const due = d.due;
128
+ const inv = st(e).invoices.insert({ subject_type: s.subject_type, subject_id: s.subject_id, subscription_id: s.id, status: fail ? 'open' : 'paid', currency: d.currency, amount_due: due, amount_paid: fail ? 0 : due, amount_refunded: 0, provider: s.provider, lines, created_at: at });
129
+ if (fail)
130
+ e.webhooks.emit('billing_invoice.payment_failed', invoiceView(e, inv));
131
+ return !fail;
132
+ }
133
+ /**
134
+ * The period lifecycle, applied lazily on every billing read and write so advancing the clock does the work: a trial that
135
+ * has ended becomes active (past_due when the subscription's payment is set to fail), and each passed period end either
136
+ * ends a subscription set to cancel, applies a scheduled plan change, or renews for another period; each billable event
137
+ * gets an invoice.
138
+ */
139
+ function settle(e) {
140
+ for (const s of st(e).subs.all()) {
141
+ if (s.external)
142
+ continue; // a webhook-driven subscription is moved by its provider, never by the clock
143
+ for (let guard = 0; guard < 1200 && (s.status === 'trialing' || s.status === 'active'); guard++) {
144
+ const now = e.clock.now();
145
+ const before = entSnap(e, s.subject_type, s.subject_id);
146
+ const prev = { ...s };
147
+ if (s.status === 'trialing') {
148
+ if (!s.will_end_sent && s.trial_end - 3 * DAY <= now) {
149
+ s.will_end_sent = true;
150
+ emitSub(e, 'billing_trial.will_end', s, null);
151
+ }
152
+ if (s.trial_end > now)
153
+ break;
154
+ s.updated_at = s.trial_end;
155
+ if (s.cancel_at_period_end) {
156
+ s.status = 'canceled';
157
+ clearSchedule(s);
158
+ emitSub(e, 'billing_subscription.canceled', s, prev);
159
+ }
160
+ else {
161
+ s.status = s.payment_failing && priceCents(planOf(e, s.plan_id), s.interval ?? 'month') > 0 ? 'past_due' : 'active';
162
+ s.current_period_end = s.trial_end + periodLen(s.interval);
163
+ bill(e, s, s.trial_end);
164
+ }
165
+ emitSub(e, 'billing_trial.ended', s, prev, undefined, { converted: s.status === 'active' });
166
+ }
167
+ else {
168
+ if (s.current_period_end > now)
169
+ break;
170
+ const at = s.current_period_end;
171
+ s.updated_at = at;
172
+ if (s.cancel_at_period_end) {
173
+ s.status = 'canceled';
174
+ clearSchedule(s);
175
+ emitSub(e, 'billing_subscription.canceled', s, prev);
176
+ }
177
+ else {
178
+ const reason = s.scheduled_plan_id ? 'plan_change_applied' : 'renewed';
179
+ if (s.scheduled_plan_id)
180
+ Object.assign(s, { plan_id: s.scheduled_plan_id, interval: s.scheduled_interval ?? s.interval, scheduled_plan_id: null, scheduled_interval: null, scheduled_change_at: null });
181
+ s.current_period_end = at + periodLen(s.interval);
182
+ if (!bill(e, s, at))
183
+ s.status = 'past_due';
184
+ if (reason === 'plan_change_applied' || s.status !== 'active')
185
+ emitSub(e, 'billing_subscription.updated', s, prev, reason);
186
+ }
187
+ }
188
+ emitEnt(e, s.subject_type, s.subject_id, before);
189
+ }
190
+ }
191
+ }
192
+ /** The live subscription of a subject (trialing or active), newest first. */
193
+ const liveSub = (e, type, id) => st(e).subs.find((s) => s.subject_type === type && s.subject_id === id && (s.status === 'active' || s.status === 'trialing'));
194
+ const monthStart = (now) => {
195
+ const d = new Date(now);
196
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
197
+ };
198
+ /** The window a consume counts in: the subscription's period, else the UTC month. */
199
+ const windowOf = (e, sub) => {
200
+ const now = e.clock.now();
201
+ if (sub?.current_period_end && sub.current_period_end > now)
202
+ return { from: sub.current_period_end - 30 * DAY, to: sub.current_period_end };
203
+ const from = monthStart(now);
204
+ const d = new Date(from);
205
+ return { from, to: Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1) };
206
+ };
207
+ const used = (e, type, id, feature, w) => st(e).usage.filter((r) => r.subject_type === type && r.subject_id === id && r.feature === feature && r.created_at >= w.from && r.created_at < w.to).reduce((n, r) => n + r.quantity, 0);
208
+ function recordUsage(e, input) {
209
+ const s = st(e);
210
+ const sub = subject(e, input);
211
+ if (typeof input.feature !== 'string' || !input.feature)
212
+ throw bad('feature is required.', 'feature');
213
+ if (typeof input.quantity !== 'number' || !Number.isFinite(input.quantity) || input.quantity <= 0)
214
+ throw bad('quantity must be a positive number.', 'quantity');
215
+ if (input.idempotency_key !== undefined && (typeof input.idempotency_key !== 'string' || !input.idempotency_key || input.idempotency_key.length > 200))
216
+ throw bad('idempotency_key must be a non-empty string of at most 200 characters.', 'idempotency_key');
217
+ if (input.idempotency_key) {
218
+ const have = s.usage.find((r) => r.source === 'report' && r.subject_type === sub.type && r.subject_id === sub.id && r.ref_key === input.idempotency_key);
219
+ if (have)
220
+ return { row: have, duplicate: true };
221
+ }
222
+ const row = s.usage.insert({ subject_type: sub.type, subject_id: sub.id, feature: input.feature, quantity: input.quantity, source: 'report', ref_key: input.idempotency_key ?? null, subscription_id: input.subscription_id ?? liveSub(e, sub.type, sub.id)?.id ?? null, created_at: e.clock.now() });
223
+ return { row, duplicate: false };
224
+ }
225
+ const usageView = (r, duplicate = false) => ({ object: 'billing_usage', id: r.id, subject_type: r.subject_type, subject_id: r.subject_id, feature: r.feature, quantity: r.quantity, source: r.source, subscription_id: r.subscription_id, recorded: !duplicate, duplicate, created_at: r.created_at });
226
+ const invoiceView = (e, i) => ({ object: 'billing_invoice', id: i.id, subject_type: i.subject_type, subject_id: i.subject_id, subscription_id: i.subscription_id ?? null, status: i.status, currency: i.currency, amount_due: i.amount_due, amount_paid: i.amount_paid, amount_refunded: i.amount_refunded, provider: i.provider, refunds: st(e).refunds.filter((r) => r.invoice_id === i.id).reverse().map(refundView), created_at: i.created_at });
227
+ const refundView = (r) => ({ object: 'billing_refund', id: r.id, invoice_id: r.invoice_id, amount: r.amount, currency: r.currency, reason: r.reason ?? null, status: 'succeeded', idempotency_key: r.idempotency_key, created_at: r.created_at });
228
+ const LIVE = new Set(['active', 'trialing', 'past_due', 'unpaid', 'paused']);
229
+ const invalid = (m) => new errors_1.EmulatorError('VALIDATION_FAILED', m);
230
+ const clearSchedule = (s) => void Object.assign(s, { scheduled_plan_id: null, scheduled_interval: null, scheduled_change_at: null });
231
+ /** Move a live subscription to another plan and/or interval, now or at the period end. Returns whether it was scheduled. */
232
+ function changePlan(e, s, plan, interval, atEnd) {
233
+ const next = interval ?? s.interval ?? 'month';
234
+ if (!LIVE.has(s.status))
235
+ throw noSub('There is no live subscription to change. Start a checkout instead.');
236
+ if (STORE_OWNED.has(s.provider))
237
+ throw unsupported(`${s.provider} subscriptions are changed in the store, not through Atlas.`);
238
+ if ((plan.provider ?? s.provider) !== s.provider)
239
+ throw invalid('That plan is sold through a different payment provider than this subscription.');
240
+ if (plan.audience && plan.audience !== s.subject_type)
241
+ throw invalid(`That plan is for a ${plan.audience} subject.`);
242
+ const cur = planOf(e, s.plan_id);
243
+ if (cur && (cur.kind === 'addon') !== (plan.kind === 'addon'))
244
+ throw invalid(plan.kind === 'addon' ? 'That is an add-on plan: buy it with a checkout alongside this subscription.' : 'This subscription is an add-on; it can only move to another add-on plan.');
245
+ if (plan.id === s.plan_id && next === (s.interval ?? 'month') && !s.scheduled_plan_id)
246
+ throw conflict('The subscription is already on that plan.');
247
+ if (next === 'year' && plan.interval !== 'year' && plan.amount !== undefined && plan.amount !== null && !plan.annual_amount && !plan.annual_price_id)
248
+ throw invalid('This plan has no annual price.');
249
+ if (atEnd && !SCHEDULING.has(s.provider))
250
+ throw unsupported(`${s.provider} cannot schedule a plan change for the period end.`);
251
+ // shortcut: a plan needs no provider price reference here (the real route refuses a free plan with none).
252
+ const before = entSnap(e, s.subject_type, s.subject_id);
253
+ const prev = { ...s };
254
+ const now = e.clock.now();
255
+ s.updated_at = now;
256
+ if (atEnd) {
257
+ Object.assign(s, { scheduled_plan_id: plan.id, scheduled_interval: next, scheduled_change_at: s.current_period_end });
258
+ emitSub(e, 'billing_subscription.updated', s, prev, 'plan_change_scheduled');
259
+ return true;
260
+ }
261
+ // Proration as the provider does it (`create_prorations`): the unused part of the old plan is credited and the remaining part of the
262
+ // new plan charged, as items waiting for the next invoice. An interval change resets the billing cycle: a new period starts now and
263
+ // is invoiced now (renewal at the new price plus the unused-time credit). A trial is not prorated and not invoiced.
264
+ const oldInterval = s.interval ?? 'month';
265
+ const trialing = s.status === 'trialing';
266
+ const qty = s.quantity ?? 1;
267
+ const end = s.current_period_end;
268
+ const frac = trialing ? 0 : Math.max(0, Math.min(1, (end - now) / periodLen(oldInterval)));
269
+ const items = [];
270
+ if (frac > 0) {
271
+ const unused = Math.round(priceCents(cur, oldInterval) * qty * frac);
272
+ if (unused > 0)
273
+ items.push({ description: `Unused time on ${cur?.name ?? s.plan_id} after ${day(now)}`, amount: -unused, quantity: qty, proration: true, plan_id: s.plan_id, period_start: now, period_end: end });
274
+ if (next === oldInterval) {
275
+ const remaining = Math.round(priceCents(plan, next) * qty * frac);
276
+ if (remaining > 0)
277
+ items.push({ description: `Remaining time on ${plan.name ?? plan.id} after ${day(now)}`, amount: remaining, quantity: qty, proration: true, plan_id: plan.id, period_start: now, period_end: end });
278
+ }
279
+ }
280
+ Object.assign(s, { plan_id: plan.id, interval: next, pending_lines: [...(s.pending_lines ?? []), ...items] });
281
+ clearSchedule(s);
282
+ if (!trialing && next !== oldInterval) {
283
+ s.current_period_end = now + periodLen(next);
284
+ bill(e, s, now);
285
+ }
286
+ emitSub(e, 'billing_subscription.updated', s, prev);
287
+ emitEnt(e, s.subject_type, s.subject_id, before);
288
+ return false;
289
+ }
290
+ /** Cancel at the period end (default) or immediately; a pending scheduled change is released. */
291
+ function cancelSub(e, s, immediately) {
292
+ if (!LIVE.has(s.status))
293
+ throw noSub('There is no live subscription to cancel.');
294
+ if (STORE_OWNED.has(s.provider))
295
+ throw unsupported(`${s.provider} subscriptions are canceled in the store, not through Atlas.`);
296
+ if (!immediately && s.cancel_at_period_end)
297
+ throw conflict('The subscription is already set to cancel at the period end.');
298
+ const before = entSnap(e, s.subject_type, s.subject_id);
299
+ const prev = { ...s };
300
+ const now = e.clock.now();
301
+ clearSchedule(s);
302
+ Object.assign(s, { canceled_at: now, updated_at: now, cancel_at_period_end: !immediately });
303
+ if (immediately) {
304
+ s.status = 'canceled';
305
+ emitSub(e, 'billing_subscription.canceled', s, prev);
306
+ if (prev.status === 'trialing')
307
+ emitSub(e, 'billing_trial.ended', s, prev, undefined, { converted: false });
308
+ }
309
+ else
310
+ emitSub(e, 'billing_subscription.updated', s, prev);
311
+ emitEnt(e, s.subject_type, s.subject_id, before);
312
+ }
313
+ /** Undo a cancellation scheduled for the period end. */
314
+ function restoreSub(e, s) {
315
+ if (s.status === 'canceled')
316
+ throw noSub('An ended subscription cannot be restored. Start a new checkout.');
317
+ if (!s.cancel_at_period_end)
318
+ throw conflict('The subscription is not set to cancel.');
319
+ if (NO_RESTORE.has(s.provider))
320
+ throw unsupported(`${s.provider} cannot restore a canceled subscription.`);
321
+ const prev = { ...s };
322
+ Object.assign(s, { cancel_at_period_end: false, canceled_at: null, updated_at: e.clock.now() });
323
+ emitSub(e, 'billing_subscription.updated', s, prev);
324
+ }
325
+ /** The body of a change, validated as the real routes do. */
326
+ function changeInput(e, body) {
327
+ if (typeof body.plan_id !== 'string' || !body.plan_id.trim())
328
+ throw bad('A plan_id is required.', 'plan_id');
329
+ const plan = planOf(e, body.plan_id.trim());
330
+ if (!plan)
331
+ throw (0, errors_1.notFound)('Billing plan'); // shortcut: a plan row's `active` is not enforced here, a generic-created plan starts inactive
332
+ if (body.interval !== undefined && body.interval !== null && body.interval !== 'month' && body.interval !== 'year')
333
+ throw bad('interval must be month or year.', 'interval');
334
+ return { plan, interval: body.interval ?? undefined };
335
+ }
336
+ /** The subscription that owns a subject's feature balances: a live one on an entitlement provider. */
337
+ const providerOwner = (e, type, id) => st(e).subs.find((s) => s.subject_type === type && s.subject_id === id && (s.status === 'active' || s.status === 'trialing') && ENT_PROVIDERS.has(s.provider));
338
+ const balKey = (type, id, feature) => `${type}|${id}|${feature}`;
339
+ const balView = (k, b) => {
340
+ const [subject_type, subject_id, feature] = k.split('|');
341
+ return { object: 'emulator_provider_balance', subject_type, subject_id, feature, granted: b.granted, usage: b.usage, remaining: b.unlimited || b.granted === null ? null : Math.max(0, b.granted - b.usage), unlimited: b.unlimited };
342
+ };
343
+ const featureOf = (raw) => {
344
+ if (typeof raw !== 'string' || !FEATURE.test(raw))
345
+ throw bad('feature must be a feature key (letters, digits, "_", ".", ":" or "-", at most 100).', 'feature');
346
+ return raw;
347
+ };
348
+ const keyOf = (raw, field) => {
349
+ if (typeof raw !== 'string' || raw.trim() === '' || raw.length > 200)
350
+ throw bad(`${field} is required (at most 200 characters).`, field);
351
+ return raw.trim();
352
+ };
353
+ const amountOf = (raw, fallback) => {
354
+ if (raw === undefined || raw === null)
355
+ return fallback;
356
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1 || raw > MAX_AMOUNT)
357
+ throw bad(`amount must be a whole number from 1 to ${MAX_AMOUNT}.`, 'amount');
358
+ return raw;
359
+ };
360
+ const consumeBody = (feature, amount, r) => ({ object: 'entitlement_consumption', allowed: r.allowed, reason: r.reason, feature, amount, source: r.source, provider: r.provider, used: r.used, limit: r.limit, remaining: r.remaining, unlimited: r.unlimited, replayed: r.replayed, window_start: r.window_start });
361
+ /** Spend units. The provider that owns the subject's balance (if any) decides; otherwise Atlas counts against the plan limit. */
362
+ function consume(e, body) {
363
+ const sub = subject(e, body);
364
+ const feature = featureOf(body.feature);
365
+ const amount = amountOf(body.amount ?? body.quantity, 1);
366
+ const key = keyOf(body.idempotency_key ?? body.key, 'idempotency_key'); // `key` and `quantity` are the older spellings
367
+ const s = st(e);
368
+ const live = liveSub(e, sub.type, sub.id);
369
+ const window_start = windowOf(e, live).from;
370
+ const prior = s.usage.find((r) => r.source === 'consume' && r.subject_type === sub.type && r.subject_id === sub.id && r.ref_key === key);
371
+ if (prior && (prior.feature !== feature || prior.quantity !== amount))
372
+ throw new errors_1.EmulatorError('IDEMPOTENCY_CONFLICT', 'That idempotency_key was already used for a different consume.');
373
+ const owner = providerOwner(e, sub.type, sub.id);
374
+ const base = { source: 'atlas', provider: null, used: null, limit: null, remaining: null, unlimited: false, replayed: false, window_start };
375
+ if (owner) {
376
+ const own = { ...base, source: 'provider', provider: owner.provider };
377
+ if (prior)
378
+ return consumeBody(feature, amount, { ...own, allowed: true, reason: null, replayed: true });
379
+ const bal = s.balances.get(balKey(sub.type, sub.id, feature));
380
+ if (!bal)
381
+ return consumeBody(feature, amount, { ...own, allowed: false, reason: 'not_entitled' });
382
+ const room = bal.unlimited ? Infinity : (bal.granted ?? 0) - bal.usage;
383
+ const view = () => ({ ...own, allowed: true, reason: null, used: bal.usage, limit: bal.granted, remaining: bal.unlimited ? null : Math.max(0, (bal.granted ?? 0) - bal.usage), unlimited: bal.unlimited });
384
+ if (amount > room)
385
+ return consumeBody(feature, amount, { ...view(), allowed: false, reason: 'limit_reached' });
386
+ bal.usage += amount; // the provider's own balance moves; the ledger keeps the read-back
387
+ s.usage.insert({ subject_type: sub.type, subject_id: sub.id, feature, quantity: amount, source: 'consume', ref_key: key, subscription_id: owner.id, created_at: e.clock.now() });
388
+ const out = view();
389
+ if (!bal.unlimited && out.remaining === 0)
390
+ e.webhooks.emit('entitlement.limit_reached', { object: 'entitlement_limit', subject_type: sub.type, subject_id: sub.id, feature, limit: out.limit, used: out.used, source: 'provider', window_start });
391
+ return consumeBody(feature, amount, out);
392
+ }
393
+ const cacheKey = `${sub.type}|${sub.id}|${key}`;
394
+ const seen = s.consumes.get(cacheKey);
395
+ if (prior && seen)
396
+ return consumeBody(feature, amount, { ...seen.out, replayed: true });
397
+ const ent = entSnap(e, sub.type, sub.id);
398
+ const plan = live ? planOf(e, live.plan_id) : undefined;
399
+ // a subscription whose plan the catalogue does not know grants everything (fixtures often import against a made-up plan id)
400
+ if (!ent || (plan && !ent.features.includes(feature) && !(feature in ent.limits)))
401
+ return consumeBody(feature, amount, { ...base, allowed: false, reason: 'not_entitled' });
402
+ const raw = ent.limits[feature];
403
+ const unlimited = typeof raw !== 'number' || raw < 0;
404
+ const limit = unlimited ? null : raw;
405
+ const w = windowOf(e, live);
406
+ const before = used(e, sub.type, sub.id, feature, w);
407
+ if (limit !== null && before + amount > limit)
408
+ return consumeBody(feature, amount, { ...base, allowed: false, reason: 'limit_reached', limit, used: before, remaining: Math.max(0, limit - before) });
409
+ s.usage.insert({ subject_type: sub.type, subject_id: sub.id, feature, quantity: amount, source: 'consume', ref_key: key, subscription_id: live.id, created_at: e.clock.now() });
410
+ const after = before + amount;
411
+ const out = { ...base, allowed: true, reason: null, used: after, limit, remaining: limit === null ? null : Math.max(0, limit - after), unlimited };
412
+ s.consumes.set(cacheKey, { out });
413
+ if (limit !== null && after >= limit && before < limit)
414
+ e.webhooks.emit('entitlement.limit_reached', { object: 'entitlement_limit', subject_type: sub.type, subject_id: sub.id, feature, limit, used: after, source: 'atlas', window_start });
415
+ return consumeBody(feature, amount, out);
416
+ }
417
+ /** Give back part or all of an Atlas-counted consume; a negative ledger row dated like the consume, so no refund crosses a window. */
418
+ function refundConsume(e, body) {
419
+ const sub = subject(e, body);
420
+ const feature = featureOf(body.feature);
421
+ const modern = body.consume_idempotency_key !== undefined;
422
+ const consumeKey = keyOf(body.consume_idempotency_key ?? body.key, 'consume_idempotency_key');
423
+ const refundKey = modern || body.idempotency_key !== undefined ? keyOf(body.idempotency_key, 'idempotency_key') : typeof body.refund_key === 'string' && body.refund_key ? body.refund_key : null;
424
+ const requested = amountOf(body.amount ?? body.quantity, null);
425
+ if (providerOwner(e, sub.type, sub.id))
426
+ throw unsupported("This subject's balances are held by its billing provider; give units back there.");
427
+ const s = st(e);
428
+ const orig = s.usage.find((r) => r.source === 'consume' && r.subject_type === sub.type && r.subject_id === sub.id && r.feature === feature && r.ref_key === consumeKey);
429
+ if (refundKey) {
430
+ const other = s.usage.find((r) => r.source === 'refund' && r.subject_type === sub.type && r.subject_id === sub.id && r.ref_key === refundKey);
431
+ if (other && other.refund_of !== orig?.id)
432
+ throw new errors_1.EmulatorError('IDEMPOTENCY_CONFLICT', 'That idempotency_key was already used for a different refund.');
433
+ }
434
+ if (!orig)
435
+ throw new errors_1.EmulatorError('NOT_FOUND', 'No consume with that key for this subject and feature.');
436
+ const w = windowOf(e, liveSub(e, sub.type, sub.id));
437
+ const reply = (refunded, replayed) => ({ object: 'entitlement_consume_refund', refunded, feature, used: used(e, sub.type, sub.id, feature, w), replayed });
438
+ if (refundKey) {
439
+ const same = s.usage.find((r) => r.source === 'refund' && r.refund_of === orig.id && r.ref_key === refundKey);
440
+ if (same)
441
+ return reply(-same.quantity, true);
442
+ }
443
+ const refunded = s.usage.filter((r) => r.source === 'refund' && r.refund_of === orig.id).reduce((n, r) => n + -r.quantity, 0);
444
+ const refundable = orig.quantity - refunded;
445
+ if (refundable <= 0 && requested === null)
446
+ throw conflict('This consume was already fully refunded.', { refundable: 0 });
447
+ const quantity = requested ?? refundable;
448
+ if (quantity > refundable)
449
+ throw conflict('That is more than the consume still has outstanding.', { refundable });
450
+ s.usage.insert({ subject_type: sub.type, subject_id: sub.id, feature, quantity: -quantity, source: 'refund', ref_key: refundKey ?? `refund:${consumeKey}:${refunded}`, refund_of: orig.id, subscription_id: orig.subscription_id, created_at: orig.created_at });
451
+ return reply(quantity, false);
452
+ }
453
+ function billingHandlers() {
454
+ const subOf = (e, id) => {
455
+ const s = st(e).subs.get(id);
456
+ if (!s)
457
+ throw (0, errors_1.notFound)('Subscription');
458
+ return s;
459
+ };
460
+ const invoiceOf = (e, id) => {
461
+ const i = st(e).invoices.get(id);
462
+ if (!i)
463
+ throw (0, errors_1.notFound)('Invoice');
464
+ return i;
465
+ };
466
+ const routes = [
467
+ // ── subscriptions (imported) ──
468
+ {
469
+ method: 'POST',
470
+ pattern: '/v1/billing/subscriptions/import',
471
+ handler: (c) => {
472
+ const e = c.engine;
473
+ const sub = subject(e, c.body);
474
+ if (typeof c.body.plan_id !== 'string' || !c.body.plan_id)
475
+ throw bad('plan_id is required.', 'plan_id');
476
+ const plans = e.generic.get('billing/plans');
477
+ if (plans && plans.size && !plans.get(c.body.plan_id))
478
+ throw new errors_1.EmulatorError('NOT_FOUND', 'Plan not found.', 'plan_id');
479
+ if (liveSub(e, sub.type, sub.id) && !c.body.add_on)
480
+ throw new errors_1.EmulatorError('CONFLICT', 'This subject already has a live subscription.');
481
+ const trialDays = c.body.trial_days === undefined ? 0 : (0, support_1.intIn)(c.body.trial_days, 'trial_days', 0, 365);
482
+ const plan = planOf(e, c.body.plan_id);
483
+ const provider = (0, support_1.oneOf)(c.body, 'provider', PROVIDERS, PROVIDERS.includes(plan?.provider) ? plan.provider : 'stripe');
484
+ const interval = (0, support_1.oneOf)(c.body, 'interval', ['month', 'year'], 'month');
485
+ const now = e.clock.now();
486
+ const cat = ENT_PROVIDERS.has(provider) ? st(e).catalogue.get(`${provider}|${plan?.provider_price_id || plan?.stripe_price_id || ''}`) : undefined;
487
+ const before = entSnap(e, sub.type, sub.id);
488
+ const row = st(e).subs.insert({ subject_type: sub.type, subject_id: sub.id, plan_id: c.body.plan_id, status: trialDays ? 'trialing' : 'active', stripe_subscription_id: c.body.stripe_subscription_id ?? null, stripe_customer_id: c.body.stripe_customer_id ?? null, current_period_end: now + periodLen(interval), provider, interval, quantity: 1, cancel_at_period_end: false, canceled_at: null, scheduled_plan_id: null, scheduled_interval: null, scheduled_change_at: null, payment_failing: c.body.payment_failing === true, entitlements: cat ? { features: cat.features, limits: cat.limits } : null, trial_start: trialDays ? now : null, trial_end: trialDays ? now + trialDays * DAY : null, created_at: now, updated_at: now });
489
+ emitSub(e, 'billing_subscription.created', row, null);
490
+ if (trialDays)
491
+ e.webhooks.emit('billing_trial.started', { object: 'billing_trial', subscription_id: row.id, trial_end: row.trial_end });
492
+ emitEnt(e, sub.type, sub.id, before);
493
+ return { status: 201, body: subView(e, row) };
494
+ },
495
+ },
496
+ { method: 'GET', pattern: '/v1/billing/subscriptions', handler: (c) => (0, util_1.listReply)(c, st(c.engine).subs.filter((s) => (!c.query.get('subject_id') || s.subject_id === c.query.get('subject_id')) && (!c.query.get('status') || s.status === c.query.get('status'))), (s) => subView(c.engine, s)) },
497
+ { method: 'GET', pattern: '/v1/billing/subscriptions/:id', handler: (c) => ({ body: subView(c.engine, subOf(c.engine, c.params.id)) }) },
498
+ {
499
+ // `days` xor `trial_end`; only a trialing subscription; 365 days from the trial start at most; idempotent on `idempotency_key`.
500
+ method: 'POST',
501
+ pattern: '/v1/billing/subscriptions/:id/extend_trial',
502
+ handler: (c) => {
503
+ const e = c.engine;
504
+ const s = subOf(e, c.params.id);
505
+ const key = c.body.idempotency_key;
506
+ if (key !== undefined && (typeof key !== 'string' || !key || key.length > 200))
507
+ throw bad('idempotency_key must be a non-empty string of at most 200 characters.', 'idempotency_key');
508
+ const cacheKey = key ? `${s.id}|${key}` : null;
509
+ const seen = cacheKey ? st(e).extensions.get(cacheKey) : undefined;
510
+ if (seen)
511
+ return { body: subView(e, s) };
512
+ if ((c.body.days === undefined) === (c.body.trial_end === undefined))
513
+ throw bad('Send days or trial_end, not both and not neither.', 'days');
514
+ if (s.status !== 'trialing')
515
+ throw new errors_1.EmulatorError('CONFLICT', `Only a trialing subscription can be extended; this one is ${s.status}.`);
516
+ const target = c.body.days !== undefined ? s.trial_end + (0, support_1.intIn)(c.body.days, 'days', 1, 365) * DAY : Number(c.body.trial_end);
517
+ if (!Number.isFinite(target) || target <= s.trial_end)
518
+ throw bad('The new trial end must be later than the current one.', 'trial_end');
519
+ if (target - s.trial_start > MAX_TRIAL_MS)
520
+ throw bad('A trial can last at most 365 days in total.', 'days');
521
+ const before = s.trial_end;
522
+ s.trial_end = target;
523
+ s.current_period_end = Math.max(s.current_period_end, target);
524
+ s.will_end_sent = false;
525
+ s.updated_at = e.clock.now();
526
+ if (cacheKey)
527
+ st(e).extensions.set(cacheKey, { at: s.updated_at });
528
+ e.webhooks.emit('billing_trial.extended', { object: 'billing_trial', subscription_id: s.id, previous_trial_end: before, trial_end: target });
529
+ return { body: subView(e, s) };
530
+ },
531
+ },
532
+ // ── invoices and refunds ──
533
+ {
534
+ method: 'POST',
535
+ pattern: '/__emulator/billing/invoices',
536
+ public: true,
537
+ handler: (c) => {
538
+ const e = c.engine;
539
+ const sub = subject(e, c.body);
540
+ const paid = (0, support_1.intIn)(c.body.amount_paid, 'amount_paid', 0, 100_000_000);
541
+ const status = (0, support_1.oneOf)(c.body, 'status', ['draft', 'open', 'paid', 'void', 'uncollectible'], 'paid');
542
+ const inv = st(e).invoices.insert({ subject_type: sub.type, subject_id: sub.id, subscription_id: c.body.subscription_id ?? liveSub(e, sub.type, sub.id)?.id ?? null, status, currency: c.body.currency ?? 'usd', amount_due: c.body.amount_due ?? paid, amount_paid: status === 'paid' ? paid : 0, amount_refunded: 0, provider: c.body.provider ?? 'stripe', created_at: e.clock.now() });
543
+ return { status: 201, body: invoiceView(e, inv) };
544
+ },
545
+ },
546
+ {
547
+ method: 'GET',
548
+ pattern: '/v1/billing/invoices',
549
+ handler: (c) => {
550
+ const q = (k) => c.query.get(k) ?? undefined;
551
+ const rows = st(c.engine).invoices.all().filter((i) => (!q('subject_type') || i.subject_type === q('subject_type')) && (!q('subject_id') || i.subject_id === q('subject_id')) && (!q('status') || i.status === q('status')));
552
+ return (0, util_1.listReply)(c, rows, (i) => invoiceView(c.engine, i));
553
+ },
554
+ },
555
+ { method: 'GET', pattern: '/v1/billing/invoices/:id', handler: (c) => ({ body: invoiceView(c.engine, invoiceOf(c.engine, c.params.id)) }) },
556
+ {
557
+ // Partial or full. `idempotency_key` is required; the same key returns the same refund. Never refunds more than was paid.
558
+ method: 'POST',
559
+ pattern: '/v1/billing/invoices/:id/refund',
560
+ handler: (c) => {
561
+ const e = c.engine;
562
+ const inv = invoiceOf(e, c.params.id);
563
+ const key = c.body.idempotency_key;
564
+ if (typeof key !== 'string' || !key || key.length > 200)
565
+ throw bad('idempotency_key is required (a non-empty string of at most 200 characters).', 'idempotency_key');
566
+ const seen = st(e).refunds.find((r) => r.invoice_id === inv.id && r.idempotency_key === key);
567
+ if (seen)
568
+ return { body: refundView(seen) };
569
+ if (inv.provider !== 'stripe')
570
+ throw bad(`The ${inv.provider} provider does not support refunds.`, 'provider');
571
+ if (inv.status !== 'paid')
572
+ throw new errors_1.EmulatorError('CONFLICT', `Only a paid invoice can be refunded; this one is ${inv.status}.`);
573
+ const room = inv.amount_paid - inv.amount_refunded;
574
+ if (room <= 0)
575
+ throw new errors_1.EmulatorError('CONFLICT', 'This invoice is already fully refunded.');
576
+ const amount = c.body.amount === undefined ? room : (0, support_1.intIn)(c.body.amount, 'amount', 1, room);
577
+ if (c.body.reason !== undefined && (typeof c.body.reason !== 'string' || c.body.reason.length > 500))
578
+ throw bad('reason is text up to 500 characters.', 'reason');
579
+ inv.amount_refunded += amount;
580
+ const r = st(e).refunds.insert({ invoice_id: inv.id, amount, currency: inv.currency, reason: c.body.reason ?? null, idempotency_key: key, created_at: e.clock.now() });
581
+ e.webhooks.emit('billing_refund.created', { ...refundView(r), subject_type: inv.subject_type, subject_id: inv.subject_id });
582
+ return { status: 201, body: refundView(r) };
583
+ },
584
+ },
585
+ // ── usage ──
586
+ {
587
+ method: 'POST',
588
+ pattern: '/v1/billing/usage',
589
+ handler: (c) => {
590
+ const r = recordUsage(c.engine, c.body);
591
+ return { body: usageView(r.row, r.duplicate) };
592
+ },
593
+ },
594
+ {
595
+ // Per-event idempotency and result; not all-or-nothing.
596
+ method: 'POST',
597
+ pattern: '/v1/billing/usage/batch',
598
+ handler: (c) => {
599
+ const events = c.body.events;
600
+ if (!Array.isArray(events) || !events.length || events.length > BATCH_MAX)
601
+ throw bad(`events is a list of 1 to ${BATCH_MAX} usage events.`, 'events');
602
+ const results = events.map((ev, index) => {
603
+ try {
604
+ if (!ev || typeof ev !== 'object' || Array.isArray(ev))
605
+ throw bad('Each event is an object.', `events[${index}]`);
606
+ const r = recordUsage(c.engine, ev);
607
+ return { index, status: r.duplicate ? 'duplicate' : 'recorded', id: r.row.id };
608
+ }
609
+ catch (err) {
610
+ if (!(err instanceof errors_1.EmulatorError))
611
+ throw err;
612
+ return { index, status: 'error', error: { code: err.code, message: err.message, ...(err.param ? { param: err.param } : {}) } };
613
+ }
614
+ });
615
+ const count = (s) => results.filter((r) => r.status === s).length;
616
+ return { body: { object: 'billing_usage_batch', results, recorded: count('recorded'), duplicates: count('duplicate'), failed: count('error') } };
617
+ },
618
+ },
619
+ {
620
+ method: 'GET',
621
+ pattern: '/v1/billing/usage',
622
+ handler: (c) => {
623
+ const e = c.engine;
624
+ const sub = subject(e, { subject_type: c.query.get('subject_type'), subject_id: c.query.get('subject_id') });
625
+ const feature = c.query.get('feature');
626
+ const t = (k) => {
627
+ const v = c.query.get(k);
628
+ if (v === null)
629
+ return undefined;
630
+ const n = /^\d+$/.test(v) ? Number(v) : Date.parse(v);
631
+ if (Number.isNaN(n))
632
+ throw bad(`${k} must be epoch milliseconds or an ISO 8601 time.`, k);
633
+ return n;
634
+ };
635
+ let [since, until] = [t('since'), t('until')];
636
+ const period = c.query.get('period');
637
+ if (period !== null && period !== 'current')
638
+ throw bad('period must be current.', 'period');
639
+ if (period === 'current')
640
+ ({ from: since, to: until } = windowOf(e, liveSub(e, sub.type, sub.id)));
641
+ const rows = st(e).usage.all().filter((r) => r.subject_type === sub.type && r.subject_id === sub.id && (!feature || r.feature === feature) && (since === undefined || r.created_at >= since) && (until === undefined || r.created_at < until));
642
+ const byFeature = {};
643
+ for (const r of rows)
644
+ byFeature[r.feature] = (byFeature[r.feature] ?? 0) + r.quantity;
645
+ const body = { object: 'billing_usage_summary', subject_type: sub.type, subject_id: sub.id, since: since ?? null, until: until ?? null, total: rows.reduce((n, r) => n + r.quantity, 0), by_feature: byFeature };
646
+ if (c.query.get('events') === 'true') {
647
+ const page = (0, store_1.paginate)(rows, (0, util_1.pageOf)(c));
648
+ Object.assign(body, { events: page.data.map((r) => usageView(r)), has_more: page.has_more, next_cursor: page.next_cursor });
649
+ }
650
+ return { body };
651
+ },
652
+ },
653
+ // ── entitlement consume and refund ──
654
+ { method: 'POST', pattern: '/v1/entitlements/consume', handler: (c) => ({ body: consume(c.engine, c.body) }) },
655
+ { method: 'POST', pattern: '/v1/entitlements/consume/refund', handler: (c) => ({ body: refundConsume(c.engine, c.body) }) },
656
+ // ── change, cancel, restore ──
657
+ {
658
+ method: 'POST',
659
+ pattern: '/v1/billing/subscriptions/:id/change',
660
+ scope: 'billing:write',
661
+ handler: (c) => {
662
+ const e = c.engine;
663
+ known(c.body, ['plan_id', 'interval', 'schedule_at_period_end']);
664
+ const s = subOf(e, c.params.id);
665
+ const { plan, interval } = changeInput(e, c.body);
666
+ const scheduled = changePlan(e, s, plan, interval, c.body.schedule_at_period_end === true);
667
+ return { body: { ...subView(e, s), scheduled } };
668
+ },
669
+ },
670
+ {
671
+ method: 'POST',
672
+ pattern: '/v1/billing/subscriptions/:id/cancel',
673
+ scope: 'billing:write',
674
+ handler: (c) => {
675
+ known(c.body, ['immediately']);
676
+ const s = subOf(c.engine, c.params.id);
677
+ cancelSub(c.engine, s, c.body.immediately === true);
678
+ return { body: subView(c.engine, s) };
679
+ },
680
+ },
681
+ {
682
+ method: 'POST',
683
+ pattern: '/v1/billing/subscriptions/:id/restore',
684
+ scope: 'billing:write',
685
+ handler: (c) => {
686
+ const s = subOf(c.engine, c.params.id);
687
+ restoreSub(c.engine, s);
688
+ return { body: subView(c.engine, s) };
689
+ },
690
+ },
691
+ // ── the signed-in user's own routes (client side): the subject is the user, or an organization they administer ──
692
+ ...['change', 'cancel', 'restore'].flatMap((op) => ['/v1/client/billing/subscription/' + op, '/v1/client/billing/subscriptions/:id/' + op].map((pattern) => ({
693
+ method: 'POST',
694
+ pattern,
695
+ public: true,
696
+ handler: async (c) => {
697
+ const e = c.engine;
698
+ const { userId } = await (0, support_1.sessionFromBearer)(c);
699
+ known(c.body, ['org_id', 'subscription_id', ...(op === 'change' ? ['plan_id', 'interval', 'schedule_at_period_end'] : op === 'cancel' ? ['immediately'] : [])]);
700
+ const orgId = c.body.org_id;
701
+ if (orgId !== undefined && typeof orgId !== 'string')
702
+ throw bad('org_id must be a string.', 'org_id');
703
+ const type = orgId ? 'org' : 'user';
704
+ const id = orgId ?? userId;
705
+ if (orgId) {
706
+ // a non-member gets a 404, never a 403 (no membership oracle); only an organization admin manages billing
707
+ const m = e.db.memberships.find((x) => x.organization_id === orgId && x.user_id === userId);
708
+ if (!m)
709
+ throw (0, errors_1.notFound)('Organization');
710
+ if (m.role !== 'org:admin')
711
+ throw new errors_1.EmulatorError('FORBIDDEN', 'Only an organization-wide admin can manage its billing.');
712
+ }
713
+ const named = c.params.id ?? c.body.subscription_id;
714
+ const mine = st(e).subs.filter((x) => x.subject_type === type && x.subject_id === id);
715
+ let s;
716
+ if (named !== undefined) {
717
+ if (typeof named !== 'string' || !named)
718
+ throw bad('subscription_id must be a string.', 'subscription_id');
719
+ s = mine.find((x) => x.id === named);
720
+ if (!s)
721
+ throw (0, errors_1.notFound)('Subscription');
722
+ }
723
+ else
724
+ s = mine.find((x) => LIVE.has(x.status) && planOf(e, x.plan_id)?.kind !== 'addon') ?? mine.find((x) => LIVE.has(x.status)) ?? mine[0];
725
+ if (!s)
726
+ throw noSub('There is no subscription to manage. Start a checkout instead.');
727
+ let scheduled;
728
+ if (op === 'change') {
729
+ const { plan, interval } = changeInput(e, c.body);
730
+ scheduled = changePlan(e, s, plan, interval, c.body.schedule_at_period_end === true);
731
+ }
732
+ else if (op === 'cancel')
733
+ cancelSub(e, s, c.body.immediately === true);
734
+ else
735
+ restoreSub(e, s);
736
+ const plan = planOf(e, s.plan_id);
737
+ const { _parent, ...pub } = (plan ?? {});
738
+ return { body: { object: 'billing_subscription', id: s.id, subject_type: s.subject_type, subject_id: s.subject_id, status: s.status, plan: plan ? pub : null, current_period_end: s.current_period_end, provider: s.provider, interval: s.interval ?? 'month', quantity: s.quantity ?? 1, cancel_at_period_end: Boolean(s.cancel_at_period_end), scheduled_plan_id: s.scheduled_plan_id ?? null, scheduled_interval: s.scheduled_interval ?? null, scheduled_change_at: s.scheduled_change_at ?? null, trial_end: s.trial_end ?? null, canceled_at: s.canceled_at ?? null, ...(scheduled === undefined ? {} : { scheduled }) } };
739
+ },
740
+ }))),
741
+ // ── plans owned by an entitlement provider: mirror the provider's features and limits ──
742
+ {
743
+ method: 'POST',
744
+ pattern: '/v1/billing/plans/:id/sync_entitlements',
745
+ scope: 'billing:write',
746
+ handler: (c) => {
747
+ const e = c.engine;
748
+ const plan = planOf(e, c.params.id);
749
+ if (!plan)
750
+ throw (0, errors_1.notFound)('Billing plan');
751
+ const provider = String(plan.provider ?? 'stripe');
752
+ if (!ENT_PROVIDERS.has(provider))
753
+ throw unsupported(`${provider} plans carry no feature definitions to sync; set features and limits on the plan.`);
754
+ const ref = plan.provider_price_id || plan.stripe_price_id;
755
+ if (typeof ref !== 'string' || !ref)
756
+ throw invalid('A free plan has no provider plan to sync from.');
757
+ const cat = st(e).catalogue.get(`${provider}|${ref}`);
758
+ if (!cat)
759
+ throw new errors_1.EmulatorError('NOT_FOUND', `The ${provider} catalogue has no plan ${ref}. Set it with PUT /__emulator/billing/provider_catalogue.`);
760
+ const table = e.generic.get('billing/plans');
761
+ const before = [...st(e).subs.filter((x) => x.plan_id === plan.id && (x.status === 'active' || x.status === 'trialing'))].map((x) => ({ x, snap: entSnap(e, x.subject_type, x.subject_id) }));
762
+ table.update(plan.id, { features: [...cat.features], limits: { ...cat.limits }, updated_at: e.clock.now() });
763
+ for (const { x, snap } of before)
764
+ emitEnt(e, x.subject_type, x.subject_id, snap);
765
+ const { _parent, ...out } = table.get(plan.id);
766
+ return { body: out };
767
+ },
768
+ },
769
+ // ── emulator controls: what an entitlement provider holds ──
770
+ {
771
+ // PUT sets one balance of a subject's feature on the provider that owns its subscription (autumn or commet).
772
+ method: 'PUT',
773
+ pattern: '/__emulator/billing/provider_balances',
774
+ public: true,
775
+ handler: (c) => {
776
+ const e = c.engine;
777
+ const sub = subject(e, c.body);
778
+ const feature = featureOf(c.body.feature);
779
+ const unlimited = c.body.unlimited === true;
780
+ const granted = c.body.granted === undefined || c.body.granted === null ? null : (0, support_1.intIn)(c.body.granted, 'granted', 0, 1_000_000_000_000);
781
+ if (!unlimited && granted === null)
782
+ throw bad('Send granted (a whole number) or unlimited: true.', 'granted');
783
+ const k = balKey(sub.type, sub.id, feature);
784
+ const usage = c.body.usage === undefined ? (st(e).balances.get(k)?.usage ?? 0) : (0, support_1.intIn)(c.body.usage, 'usage', 0, 1_000_000_000_000);
785
+ const b = { granted, usage, unlimited };
786
+ st(e).balances.set(k, b);
787
+ return { body: balView(k, b) };
788
+ },
789
+ },
790
+ {
791
+ method: 'GET',
792
+ pattern: '/__emulator/billing/provider_balances',
793
+ public: true,
794
+ handler: (c) => {
795
+ const q = (n) => c.query.get(n);
796
+ const data = [...st(c.engine).balances].filter(([k]) => { const [t, i, f] = k.split('|'); return (!q('subject_type') || t === q('subject_type')) && (!q('subject_id') || i === q('subject_id')) && (!q('feature') || f === q('feature')); }).map(([k, b]) => balView(k, b));
797
+ return { body: { object: 'list', data, has_more: false } };
798
+ },
799
+ },
800
+ {
801
+ // PUT sets the features and limits an entitlement provider reports for a plan reference; `sync_entitlements` mirrors them.
802
+ method: 'PUT',
803
+ pattern: '/__emulator/billing/provider_catalogue',
804
+ public: true,
805
+ handler: (c) => {
806
+ const provider = (0, support_1.oneOf)(c.body, 'provider', ['autumn', 'commet']);
807
+ if (typeof c.body.price_ref !== 'string' || !c.body.price_ref)
808
+ throw bad('price_ref is required (the plan reference on the provider).', 'price_ref');
809
+ const features = c.body.features ?? [];
810
+ const limits = c.body.limits ?? {};
811
+ if (!Array.isArray(features) || features.some((f) => typeof f !== 'string'))
812
+ throw bad('features must be a list of strings.', 'features');
813
+ if (!limits || typeof limits !== 'object' || Array.isArray(limits) || Object.values(limits).some((v) => typeof v !== 'number'))
814
+ throw bad('limits must be an object of numbers.', 'limits');
815
+ st(c.engine).catalogue.set(`${provider}|${c.body.price_ref}`, { features, limits });
816
+ return { body: { object: 'emulator_provider_plan', provider, price_ref: c.body.price_ref, features, limits } };
817
+ },
818
+ },
819
+ {
820
+ method: 'GET',
821
+ pattern: '/__emulator/billing/provider_catalogue',
822
+ public: true,
823
+ handler: (c) => ({ body: { object: 'list', data: [...st(c.engine).catalogue].map(([k, v]) => ({ object: 'emulator_provider_plan', provider: k.split('|')[0], price_ref: k.slice(k.indexOf('|') + 1), ...v })), has_more: false } }),
824
+ },
825
+ // ── coupons: the wider update (expiry, plan scope, first-time-only) ──
826
+ {
827
+ method: 'PATCH',
828
+ pattern: '/v1/billing/coupons/:id',
829
+ handler: (c) => {
830
+ const e = c.engine;
831
+ const t = e.generic.get('billing/coupons');
832
+ const row = t?.get(c.params.id);
833
+ if (!t || !row)
834
+ throw (0, errors_1.notFound)('Coupon');
835
+ const b = c.body;
836
+ const patch = {};
837
+ if (b.name !== undefined) {
838
+ if (typeof b.name !== 'string' || !b.name || b.name.length > 200)
839
+ throw bad('name must be 1 to 200 characters.', 'name');
840
+ patch.name = b.name;
841
+ }
842
+ if (b.expires_at !== undefined) {
843
+ if (b.expires_at !== null && (!Number.isInteger(b.expires_at) || b.expires_at <= e.clock.now()))
844
+ throw bad('expires_at must be a future time in epoch milliseconds, or null.', 'expires_at');
845
+ patch.expires_at = b.expires_at;
846
+ }
847
+ if (b.plan_ids !== undefined) {
848
+ if (!Array.isArray(b.plan_ids) || b.plan_ids.some((p) => typeof p !== 'string'))
849
+ throw bad('plan_ids must be a list of plan ids.', 'plan_ids');
850
+ const plans = e.generic.get('billing/plans');
851
+ const unknown = plans && plans.size ? b.plan_ids.find((p) => !plans.get(p)) : undefined;
852
+ if (unknown)
853
+ throw bad(`Unknown plan ${unknown}.`, 'plan_ids');
854
+ patch.plan_ids = b.plan_ids;
855
+ }
856
+ if (b.first_time_only !== undefined) {
857
+ if (typeof b.first_time_only !== 'boolean')
858
+ throw bad('first_time_only must be true or false.', 'first_time_only');
859
+ patch.first_time_only = b.first_time_only;
860
+ }
861
+ if (b.max_redemptions !== undefined) {
862
+ if (!Number.isInteger(b.max_redemptions) || b.max_redemptions < (row.times_redeemed ?? 0))
863
+ throw bad('max_redemptions must be a whole number not below the redemptions so far.', 'max_redemptions');
864
+ patch.max_redemptions = b.max_redemptions;
865
+ }
866
+ t.update(row.id, { ...patch, updated_at: e.clock.now() });
867
+ const { _parent, ...out } = t.get(row.id);
868
+ return { body: out };
869
+ },
870
+ },
871
+ ];
872
+ // every billing read and write first lets the clock catch the subscriptions up
873
+ return routes.map((r) => ({ ...r, handler: (c) => (settle(c.engine), r.handler(c)) }));
874
+ }
875
+ /** The state and helpers the provider-webhook, store-link, Connect and referral-reward routes (emu4-billing.ts) share with this module. */
876
+ exports.billingInternals = { st, planOf, subView, emitSub, emitEnt, entSnap, settle, liveSub, invoiceView, draft, lineView, balKey2, day, DAY, MONTH, periodLen, priceCents, bad, conflict, unsupported, known, subject, PROVIDERS, STORE_OWNED };
877
+ //# sourceMappingURL=billing.js.map