@atlasauth/emulator-core 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/LICENSE +21 -0
  2. package/dist/custom-html.d.ts +57 -0
  3. package/dist/custom-html.js +491 -0
  4. package/dist/custom-html.js.map +1 -0
  5. package/dist/engine.d.ts +1 -1
  6. package/dist/engine.js +5 -1
  7. package/dist/engine.js.map +1 -1
  8. package/dist/error-catalog.js +8 -0
  9. package/dist/error-catalog.js.map +1 -1
  10. package/dist/event-gate.js +60 -2
  11. package/dist/event-gate.js.map +1 -1
  12. package/dist/generic.js +5 -1
  13. package/dist/generic.js.map +1 -1
  14. package/dist/handlers/access-signals.d.ts +48 -0
  15. package/dist/handlers/access-signals.js +76 -0
  16. package/dist/handlers/access-signals.js.map +1 -0
  17. package/dist/handlers/approvals.js +54 -1
  18. package/dist/handlers/approvals.js.map +1 -1
  19. package/dist/handlers/authz-data.d.ts +2 -0
  20. package/dist/handlers/authz-data.js +627 -0
  21. package/dist/handlers/authz-data.js.map +1 -0
  22. package/dist/handlers/authz-layers.d.ts +229 -0
  23. package/dist/handlers/authz-layers.js +786 -0
  24. package/dist/handlers/authz-layers.js.map +1 -0
  25. package/dist/handlers/authz-state.d.ts +80 -0
  26. package/dist/handlers/authz-state.js +175 -0
  27. package/dist/handlers/authz-state.js.map +1 -0
  28. package/dist/handlers/authz.js +240 -177
  29. package/dist/handlers/authz.js.map +1 -1
  30. package/dist/handlers/billing.d.ts +99 -1
  31. package/dist/handlers/billing.js +556 -77
  32. package/dist/handlers/billing.js.map +1 -1
  33. package/dist/handlers/cedar-ext.d.ts +35 -0
  34. package/dist/handlers/cedar-ext.js +174 -0
  35. package/dist/handlers/cedar-ext.js.map +1 -0
  36. package/dist/handlers/cedar.d.ts +146 -0
  37. package/dist/handlers/cedar.js +689 -0
  38. package/dist/handlers/cedar.js.map +1 -0
  39. package/dist/handlers/connections.d.ts +4 -1
  40. package/dist/handlers/connections.js +267 -38
  41. package/dist/handlers/connections.js.map +1 -1
  42. package/dist/handlers/control.js +6 -1
  43. package/dist/handlers/control.js.map +1 -1
  44. package/dist/handlers/device.js +18 -1
  45. package/dist/handlers/device.js.map +1 -1
  46. package/dist/handlers/emu3-misc.d.ts +38 -0
  47. package/dist/handlers/emu3-misc.js +1056 -0
  48. package/dist/handlers/emu3-misc.js.map +1 -0
  49. package/dist/handlers/emu4-actions-access.d.ts +46 -0
  50. package/dist/handlers/emu4-actions-access.js +107 -0
  51. package/dist/handlers/emu4-actions-access.js.map +1 -0
  52. package/dist/handlers/emu4-actions-catalog-extra.d.ts +3 -0
  53. package/dist/handlers/emu4-actions-catalog-extra.js +204 -0
  54. package/dist/handlers/emu4-actions-catalog-extra.js.map +1 -0
  55. package/dist/handlers/emu4-actions-catalog.d.ts +3 -0
  56. package/dist/handlers/emu4-actions-catalog.js +143 -0
  57. package/dist/handlers/emu4-actions-catalog.js.map +1 -0
  58. package/dist/handlers/emu4-actions-def.d.ts +50 -0
  59. package/dist/handlers/emu4-actions-def.js +250 -0
  60. package/dist/handlers/emu4-actions-def.js.map +1 -0
  61. package/dist/handlers/emu4-actions-sandbox.d.ts +36 -0
  62. package/dist/handlers/emu4-actions-sandbox.js +318 -0
  63. package/dist/handlers/emu4-actions-sandbox.js.map +1 -0
  64. package/dist/handlers/emu4-actions-typegen.d.ts +5 -0
  65. package/dist/handlers/emu4-actions-typegen.js +73 -0
  66. package/dist/handlers/emu4-actions-typegen.js.map +1 -0
  67. package/dist/handlers/emu4-actions.d.ts +2 -0
  68. package/dist/handlers/emu4-actions.js +506 -0
  69. package/dist/handlers/emu4-actions.js.map +1 -0
  70. package/dist/handlers/emu4-authz-trust.d.ts +40 -0
  71. package/dist/handlers/emu4-authz-trust.js +488 -0
  72. package/dist/handlers/emu4-authz-trust.js.map +1 -0
  73. package/dist/handlers/emu4-authz.d.ts +2 -0
  74. package/dist/handlers/emu4-authz.js +752 -0
  75. package/dist/handlers/emu4-authz.js.map +1 -0
  76. package/dist/handlers/emu4-billing-core.d.ts +23 -0
  77. package/dist/handlers/emu4-billing-core.js +22 -0
  78. package/dist/handlers/emu4-billing-core.js.map +1 -0
  79. package/dist/handlers/emu4-billing-x.d.ts +2 -0
  80. package/dist/handlers/emu4-billing-x.js +489 -0
  81. package/dist/handlers/emu4-billing-x.js.map +1 -0
  82. package/dist/handlers/emu4-billing.d.ts +14 -0
  83. package/dist/handlers/emu4-billing.js +846 -0
  84. package/dist/handlers/emu4-billing.js.map +1 -0
  85. package/dist/handlers/emu4-clones.d.ts +2 -0
  86. package/dist/handlers/emu4-clones.js +322 -0
  87. package/dist/handlers/emu4-clones.js.map +1 -0
  88. package/dist/handlers/emu4-compliance.d.ts +8 -0
  89. package/dist/handlers/emu4-compliance.js +39 -0
  90. package/dist/handlers/emu4-compliance.js.map +1 -0
  91. package/dist/handlers/emu4-config.d.ts +14 -0
  92. package/dist/handlers/emu4-config.js +148 -0
  93. package/dist/handlers/emu4-config.js.map +1 -0
  94. package/dist/handlers/emu4-cookies.d.ts +8 -0
  95. package/dist/handlers/emu4-cookies.js +248 -0
  96. package/dist/handlers/emu4-cookies.js.map +1 -0
  97. package/dist/handlers/emu4-erasure.d.ts +2 -0
  98. package/dist/handlers/emu4-erasure.js +587 -0
  99. package/dist/handlers/emu4-erasure.js.map +1 -0
  100. package/dist/handlers/emu4-federation.d.ts +8 -0
  101. package/dist/handlers/emu4-federation.js +18 -0
  102. package/dist/handlers/emu4-federation.js.map +1 -0
  103. package/dist/handlers/emu4-journey-math.d.ts +42 -0
  104. package/dist/handlers/emu4-journey-math.js +99 -0
  105. package/dist/handlers/emu4-journey-math.js.map +1 -0
  106. package/dist/handlers/emu4-messaging.d.ts +21 -0
  107. package/dist/handlers/emu4-messaging.js +595 -0
  108. package/dist/handlers/emu4-messaging.js.map +1 -0
  109. package/dist/handlers/emu4-minors.d.ts +7 -0
  110. package/dist/handlers/emu4-minors.js +408 -0
  111. package/dist/handlers/emu4-minors.js.map +1 -0
  112. package/dist/handlers/emu4-oauth-audit.d.ts +5 -0
  113. package/dist/handlers/emu4-oauth-audit.js +17 -0
  114. package/dist/handlers/emu4-oauth-audit.js.map +1 -0
  115. package/dist/handlers/emu4-oauth-auth.d.ts +83 -0
  116. package/dist/handlers/emu4-oauth-auth.js +363 -0
  117. package/dist/handlers/emu4-oauth-auth.js.map +1 -0
  118. package/dist/handlers/emu4-oauth-delivery.d.ts +68 -0
  119. package/dist/handlers/emu4-oauth-delivery.js +207 -0
  120. package/dist/handlers/emu4-oauth-delivery.js.map +1 -0
  121. package/dist/handlers/emu4-oauth-keys.d.ts +11 -0
  122. package/dist/handlers/emu4-oauth-keys.js +268 -0
  123. package/dist/handlers/emu4-oauth-keys.js.map +1 -0
  124. package/dist/handlers/emu4-oauth-pub.d.ts +36 -0
  125. package/dist/handlers/emu4-oauth-pub.js +420 -0
  126. package/dist/handlers/emu4-oauth-pub.js.map +1 -0
  127. package/dist/handlers/emu4-oauth.d.ts +10 -0
  128. package/dist/handlers/emu4-oauth.js +23 -0
  129. package/dist/handlers/emu4-oauth.js.map +1 -0
  130. package/dist/handlers/emu4-policies.d.ts +2 -0
  131. package/dist/handlers/emu4-policies.js +408 -0
  132. package/dist/handlers/emu4-policies.js.map +1 -0
  133. package/dist/handlers/emu4-region.d.ts +2 -0
  134. package/dist/handlers/emu4-region.js +189 -0
  135. package/dist/handlers/emu4-region.js.map +1 -0
  136. package/dist/handlers/emu4-saml.d.ts +12 -0
  137. package/dist/handlers/emu4-saml.js +532 -0
  138. package/dist/handlers/emu4-saml.js.map +1 -0
  139. package/dist/handlers/emu4-shared.d.ts +44 -0
  140. package/dist/handlers/emu4-shared.js +90 -0
  141. package/dist/handlers/emu4-shared.js.map +1 -0
  142. package/dist/handlers/emu4-snapshots.d.ts +38 -0
  143. package/dist/handlers/emu4-snapshots.js +231 -0
  144. package/dist/handlers/emu4-snapshots.js.map +1 -0
  145. package/dist/handlers/emu4-sns.d.ts +9 -0
  146. package/dist/handlers/emu4-sns.js +21 -0
  147. package/dist/handlers/emu4-sns.js.map +1 -0
  148. package/dist/handlers/emu4-trust-binding.d.ts +27 -0
  149. package/dist/handlers/emu4-trust-binding.js +344 -0
  150. package/dist/handlers/emu4-trust-binding.js.map +1 -0
  151. package/dist/handlers/emu4-trust.d.ts +35 -0
  152. package/dist/handlers/emu4-trust.js +543 -0
  153. package/dist/handlers/emu4-trust.js.map +1 -0
  154. package/dist/handlers/fga-engine.d.ts +78 -0
  155. package/dist/handlers/fga-engine.js +663 -0
  156. package/dist/handlers/fga-engine.js.map +1 -0
  157. package/dist/handlers/governance.js +3 -2
  158. package/dist/handlers/governance.js.map +1 -1
  159. package/dist/handlers/hooks.js +13 -0
  160. package/dist/handlers/hooks.js.map +1 -1
  161. package/dist/handlers/index.js +14 -1
  162. package/dist/handlers/index.js.map +1 -1
  163. package/dist/handlers/machines.js +6 -0
  164. package/dist/handlers/machines.js.map +1 -1
  165. package/dist/handlers/messaging.d.ts +3 -1
  166. package/dist/handlers/messaging.js +455 -48
  167. package/dist/handlers/messaging.js.map +1 -1
  168. package/dist/handlers/oauth-adv.d.ts +191 -0
  169. package/dist/handlers/oauth-adv.js +1305 -0
  170. package/dist/handlers/oauth-adv.js.map +1 -0
  171. package/dist/handlers/oauth-flows.d.ts +25 -1
  172. package/dist/handlers/oauth-flows.js +111 -50
  173. package/dist/handlers/oauth-flows.js.map +1 -1
  174. package/dist/handlers/oauth.d.ts +24 -3
  175. package/dist/handlers/oauth.js +245 -60
  176. package/dist/handlers/oauth.js.map +1 -1
  177. package/dist/handlers/orgs.js +6 -0
  178. package/dist/handlers/orgs.js.map +1 -1
  179. package/dist/handlers/platform.d.ts +5 -0
  180. package/dist/handlers/platform.js +33 -15
  181. package/dist/handlers/platform.js.map +1 -1
  182. package/dist/handlers/scim.d.ts +2 -0
  183. package/dist/handlers/scim.js +198 -11
  184. package/dist/handlers/scim.js.map +1 -1
  185. package/dist/handlers/sessions.d.ts +2 -0
  186. package/dist/handlers/sessions.js +24 -16
  187. package/dist/handlers/sessions.js.map +1 -1
  188. package/dist/handlers/users.js +3 -0
  189. package/dist/handlers/users.js.map +1 -1
  190. package/dist/jwe.d.ts +18 -0
  191. package/dist/jwe.js +101 -0
  192. package/dist/jwe.js.map +1 -0
  193. package/dist/jws.js +2 -0
  194. package/dist/jws.js.map +1 -1
  195. package/dist/messaging-callbacks.d.ts +100 -0
  196. package/dist/messaging-callbacks.js +767 -0
  197. package/dist/messaging-callbacks.js.map +1 -0
  198. package/dist/openapi.d.ts +1 -0
  199. package/dist/openapi.js +44 -2
  200. package/dist/openapi.js.map +1 -1
  201. package/dist/parity-emu3-authz.d.ts +3 -0
  202. package/dist/parity-emu3-authz.js +61 -0
  203. package/dist/parity-emu3-authz.js.map +1 -0
  204. package/dist/parity-emu3-billing.d.ts +3 -0
  205. package/dist/parity-emu3-billing.js +25 -0
  206. package/dist/parity-emu3-billing.js.map +1 -0
  207. package/dist/parity-emu3-messaging.d.ts +3 -0
  208. package/dist/parity-emu3-messaging.js +27 -0
  209. package/dist/parity-emu3-messaging.js.map +1 -0
  210. package/dist/parity-emu3-misc.d.ts +2 -0
  211. package/dist/parity-emu3-misc.js +63 -0
  212. package/dist/parity-emu3-misc.js.map +1 -0
  213. package/dist/parity-emu3-oauth.d.ts +2 -0
  214. package/dist/parity-emu3-oauth.js +39 -0
  215. package/dist/parity-emu3-oauth.js.map +1 -0
  216. package/dist/parity-emu4-actions.d.ts +2 -0
  217. package/dist/parity-emu4-actions.js +38 -0
  218. package/dist/parity-emu4-actions.js.map +1 -0
  219. package/dist/parity-emu4-authz.d.ts +3 -0
  220. package/dist/parity-emu4-authz.js +68 -0
  221. package/dist/parity-emu4-authz.js.map +1 -0
  222. package/dist/parity-emu4-billing.d.ts +6 -0
  223. package/dist/parity-emu4-billing.js +44 -0
  224. package/dist/parity-emu4-billing.js.map +1 -0
  225. package/dist/parity-emu4-compliance.d.ts +2 -0
  226. package/dist/parity-emu4-compliance.js +92 -0
  227. package/dist/parity-emu4-compliance.js.map +1 -0
  228. package/dist/parity-emu4-federation.d.ts +2 -0
  229. package/dist/parity-emu4-federation.js +33 -0
  230. package/dist/parity-emu4-federation.js.map +1 -0
  231. package/dist/parity-emu4-messaging.d.ts +3 -0
  232. package/dist/parity-emu4-messaging.js +42 -0
  233. package/dist/parity-emu4-messaging.js.map +1 -0
  234. package/dist/parity-emu4-oauth.d.ts +2 -0
  235. package/dist/parity-emu4-oauth.js +55 -0
  236. package/dist/parity-emu4-oauth.js.map +1 -0
  237. package/dist/parity-emu4-trust.d.ts +2 -0
  238. package/dist/parity-emu4-trust.js +41 -0
  239. package/dist/parity-emu4-trust.js.map +1 -0
  240. package/dist/parity-routes.js +19 -1
  241. package/dist/parity-routes.js.map +1 -1
  242. package/dist/sns-cert.d.ts +9 -0
  243. package/dist/sns-cert.js +101 -0
  244. package/dist/sns-cert.js.map +1 -0
  245. package/dist/tokens.d.ts +69 -4
  246. package/dist/tokens.js +164 -38
  247. package/dist/tokens.js.map +1 -1
  248. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.billingInternals = void 0;
3
4
  exports.billingHandlers = billingHandlers;
4
5
  const errors_1 = require("../errors");
5
6
  const store_1 = require("../store");
@@ -17,8 +18,26 @@ const DAY = 86_400_000;
17
18
  const MAX_TRIAL_MS = 365 * DAY;
18
19
  const BATCH_MAX = 100;
19
20
  const SUBJECTS = ['user', 'org'];
20
- 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(), limitFired: new Set(), consumes: new Map() }));
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;
21
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
+ }
22
41
  function subject(e, body) {
23
42
  const type = (0, support_1.oneOf)(body, 'subject_type', SUBJECTS);
24
43
  const id = body.subject_id;
@@ -29,14 +48,149 @@ function subject(e, body) {
29
48
  throw new errors_1.EmulatorError('NOT_FOUND', `${type === 'user' ? 'User' : 'Organization'} not found.`, 'subject_id');
30
49
  return { type, id };
31
50
  }
32
- 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, cancel_at_period_end: false, entitlements: null, trial_end: s.trial_end ?? null, created_at: s.created_at, updated_at: s.updated_at });
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
+ }
33
192
  /** The live subscription of a subject (trialing or active), newest first. */
34
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'));
35
- const planLimit = (e, planId, feature) => {
36
- const plan = e.generic.get('billing/plans')?.get(planId);
37
- const v = plan?.limits?.[feature];
38
- return typeof v === 'number' && v >= 0 ? v : null; // none or -1 = unlimited
39
- };
40
194
  const monthStart = (now) => {
41
195
  const d = new Date(now);
42
196
  return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
@@ -71,6 +225,231 @@ function recordUsage(e, input) {
71
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 });
72
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 });
73
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
+ }
74
453
  function billingHandlers() {
75
454
  const subOf = (e, id) => {
76
455
  const s = st(e).subs.get(id);
@@ -84,8 +463,7 @@ function billingHandlers() {
84
463
  throw (0, errors_1.notFound)('Invoice');
85
464
  return i;
86
465
  };
87
- const consumeResult = (e, k) => ({ object: 'entitlement_consume', ...k });
88
- return [
466
+ const routes = [
89
467
  // ── subscriptions (imported) ──
90
468
  {
91
469
  method: 'POST',
@@ -101,11 +479,17 @@ function billingHandlers() {
101
479
  if (liveSub(e, sub.type, sub.id) && !c.body.add_on)
102
480
  throw new errors_1.EmulatorError('CONFLICT', 'This subject already has a live subscription.');
103
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');
104
485
  const now = e.clock.now();
105
- 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 + 30 * DAY, provider: 'stripe', trial_start: trialDays ? now : null, trial_end: trialDays ? now + trialDays * DAY : null, created_at: now, updated_at: now });
106
- e.webhooks.emit('billing_subscription.created', subView(e, row));
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);
107
490
  if (trialDays)
108
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);
109
493
  return { status: 201, body: subView(e, row) };
110
494
  },
111
495
  },
@@ -137,6 +521,7 @@ function billingHandlers() {
137
521
  const before = s.trial_end;
138
522
  s.trial_end = target;
139
523
  s.current_period_end = Math.max(s.current_period_end, target);
524
+ s.will_end_sent = false;
140
525
  s.updated_at = e.clock.now();
141
526
  if (cacheKey)
142
527
  st(e).extensions.set(cacheKey, { at: s.updated_at });
@@ -266,87 +651,177 @@ function billingHandlers() {
266
651
  },
267
652
  },
268
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 ──
269
657
  {
270
- // Atlas-metered: limit = the plan's numeric limit (none or -1 = unlimited), window = subscription period else UTC month.
271
658
  method: 'POST',
272
- pattern: '/v1/entitlements/consume',
659
+ pattern: '/v1/billing/subscriptions/:id/change',
660
+ scope: 'billing:write',
273
661
  handler: (c) => {
274
662
  const e = c.engine;
275
- const sub = subject(e, c.body);
276
- if (typeof c.body.feature !== 'string' || !c.body.feature)
277
- throw bad('feature is required.', 'feature');
278
- const key = c.body.key;
279
- if (typeof key !== 'string' || !key || key.length > 200)
280
- throw bad('key is required: an idempotency key of at most 200 characters.', 'key');
281
- const quantity = c.body.quantity === undefined ? 1 : c.body.quantity;
282
- if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity <= 0)
283
- throw bad('quantity must be a positive number.', 'quantity');
284
- const cacheKey = `${sub.type}|${sub.id}|${c.body.feature}|${key}`;
285
- const seen = st(e).consumes.get(cacheKey);
286
- if (seen)
287
- return { body: consumeResult(e, { ...seen, duplicate: true }) };
288
- const live = liveSub(e, sub.type, sub.id);
289
- const feature = c.body.feature;
290
- const base = { subject_type: sub.type, subject_id: sub.id, feature, key, quantity };
291
- if (!live) {
292
- const out = { ...base, allowed: false, reason: 'no_subscription', limit: null, used: null, remaining: null, duplicate: false };
293
- return { body: consumeResult(e, out) };
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.');
294
712
  }
295
- const w = windowOf(e, live);
296
- const limit = planLimit(e, live.plan_id, feature);
297
- const before = used(e, sub.type, sub.id, feature, w);
298
- if (limit !== null && before + quantity > limit) {
299
- const out = { ...base, allowed: false, reason: 'limit_reached', limit, used: before, remaining: Math.max(0, limit - before), duplicate: false };
300
- return { body: consumeResult(e, out) };
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');
301
722
  }
302
- st(e).usage.insert({ subject_type: sub.type, subject_id: sub.id, feature, quantity, source: 'consume', ref_key: key, subscription_id: live.id, created_at: e.clock.now() });
303
- const after = before + quantity;
304
- const out = { ...base, allowed: true, reason: null, limit, used: after, remaining: limit === null ? null : limit - after, duplicate: false };
305
- st(e).consumes.set(cacheKey, out);
306
- if (limit !== null && after >= limit) {
307
- const fired = `${cacheKey.split('|').slice(0, 3).join('|')}|${w.from}`;
308
- if (!st(e).limitFired.has(fired)) {
309
- st(e).limitFired.add(fired);
310
- e.webhooks.emit('entitlement.limit_reached', { object: 'entitlement_limit', subject_type: sub.type, subject_id: sub.id, feature, limit, used: after, window_start: w.from });
311
- }
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);
312
731
  }
313
- return { body: consumeResult(e, out) };
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 }) } };
314
739
  },
315
- },
740
+ }))),
741
+ // ── plans owned by an entitlement provider: mirror the provider's features and limits ──
316
742
  {
317
- // A negative row dated like the consume it undoes, so a refund never moves usage into another window.
318
743
  method: 'POST',
319
- pattern: '/v1/entitlements/consume/refund',
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,
320
775
  handler: (c) => {
321
776
  const e = c.engine;
322
777
  const sub = subject(e, c.body);
323
- const feature = c.body.feature;
324
- const key = c.body.key;
325
- if (typeof feature !== 'string' || !feature)
326
- throw bad('feature is required.', 'feature');
327
- if (typeof key !== 'string' || !key)
328
- throw bad('key is required: the key of the consume to refund.', 'key');
329
- const orig = st(e).usage.find((r) => r.source === 'consume' && r.subject_type === sub.type && r.subject_id === sub.id && r.feature === feature && r.ref_key === key);
330
- if (!orig)
331
- throw (0, errors_1.notFound)('Consume with that key');
332
- const refunded = st(e).usage.filter((r) => r.source === 'refund' && r.refund_of === orig.id).reduce((n, r) => n + -r.quantity, 0);
333
- if (c.body.quantity === undefined && orig.quantity - refunded <= 0)
334
- throw new errors_1.EmulatorError('CONFLICT', 'This consume was already fully refunded.');
335
- const quantity = c.body.quantity === undefined ? orig.quantity - refunded : c.body.quantity;
336
- if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity <= 0)
337
- throw bad('quantity must be a positive number.', 'quantity');
338
- const refundKey = typeof c.body.refund_key === 'string' && c.body.refund_key ? c.body.refund_key : null;
339
- if (refundKey) {
340
- const same = st(e).usage.find((r) => r.source === 'refund' && r.refund_of === orig.id && r.ref_key === refundKey);
341
- if (same)
342
- return { body: { object: 'entitlement_refund', key, feature, refunded: -same.quantity, duplicate: true } };
343
- }
344
- if (quantity > orig.quantity - refunded)
345
- throw new errors_1.EmulatorError('CONFLICT', `Only ${orig.quantity - refunded} of this consume can still be refunded.`, 'quantity');
346
- st(e).usage.insert({ subject_type: sub.type, subject_id: sub.id, feature, quantity: -quantity, source: 'refund', ref_key: refundKey ?? `refund:${key}:${refunded}`, refund_of: orig.id, subscription_id: orig.subscription_id, created_at: orig.created_at });
347
- return { body: { object: 'entitlement_refund', key, feature, refunded: quantity, duplicate: false } };
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) };
348
788
  },
349
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
+ },
350
825
  // ── coupons: the wider update (expiry, plan scope, first-time-only) ──
351
826
  {
352
827
  method: 'PATCH',
@@ -394,5 +869,9 @@ function billingHandlers() {
394
869
  },
395
870
  },
396
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)) }));
397
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 };
398
877
  //# sourceMappingURL=billing.js.map