alepha 0.20.5 → 0.20.7

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 (367) hide show
  1. package/AGENTS.md +0 -1
  2. package/CLAUDE.md +0 -1
  3. package/assets/agents-template.md +0 -1
  4. package/dist/api/audits/index.browser.js +1 -0
  5. package/dist/api/audits/index.browser.js.map +1 -1
  6. package/dist/api/audits/index.d.ts +701 -654
  7. package/dist/api/audits/index.d.ts.map +1 -1
  8. package/dist/api/audits/index.js +24 -1
  9. package/dist/api/audits/index.js.map +1 -1
  10. package/dist/api/files/index.browser.js +1 -0
  11. package/dist/api/files/index.browser.js.map +1 -1
  12. package/dist/api/files/index.d.ts +193 -166
  13. package/dist/api/files/index.d.ts.map +1 -1
  14. package/dist/api/files/index.js +52 -0
  15. package/dist/api/files/index.js.map +1 -1
  16. package/dist/api/jobs/index.browser.js +40 -14
  17. package/dist/api/jobs/index.browser.js.map +1 -1
  18. package/dist/api/jobs/index.d.ts +639 -333
  19. package/dist/api/jobs/index.d.ts.map +1 -1
  20. package/dist/api/jobs/index.js +495 -162
  21. package/dist/api/jobs/index.js.map +1 -1
  22. package/dist/api/keys/index.d.ts +222 -188
  23. package/dist/api/keys/index.d.ts.map +1 -1
  24. package/dist/api/keys/index.js +54 -0
  25. package/dist/api/keys/index.js.map +1 -1
  26. package/dist/api/notifications/index.d.ts +265 -236
  27. package/dist/api/notifications/index.d.ts.map +1 -1
  28. package/dist/api/notifications/index.js +55 -13
  29. package/dist/api/notifications/index.js.map +1 -1
  30. package/dist/api/organizations/index.d.ts +100 -97
  31. package/dist/api/organizations/index.d.ts.map +1 -1
  32. package/dist/api/organizations/index.js.map +1 -1
  33. package/dist/api/parameters/index.d.ts +332 -314
  34. package/dist/api/parameters/index.d.ts.map +1 -1
  35. package/dist/api/parameters/index.js +37 -0
  36. package/dist/api/parameters/index.js.map +1 -1
  37. package/dist/api/payments/index.d.ts +431 -376
  38. package/dist/api/payments/index.d.ts.map +1 -1
  39. package/dist/api/payments/index.js +202 -87
  40. package/dist/api/payments/index.js.map +1 -1
  41. package/dist/api/subscriptions/index.d.ts +1695 -0
  42. package/dist/api/subscriptions/index.d.ts.map +1 -0
  43. package/dist/api/subscriptions/index.js +1919 -0
  44. package/dist/api/subscriptions/index.js.map +1 -0
  45. package/dist/api/users/index.d.ts +1001 -844
  46. package/dist/api/users/index.d.ts.map +1 -1
  47. package/dist/api/users/index.js +237 -28
  48. package/dist/api/users/index.js.map +1 -1
  49. package/dist/api/verifications/index.d.ts +123 -122
  50. package/dist/api/verifications/index.d.ts.map +1 -1
  51. package/dist/api/verifications/index.js.map +1 -1
  52. package/dist/batch/index.js.map +1 -1
  53. package/dist/bucket/index.d.ts +21 -2
  54. package/dist/bucket/index.d.ts.map +1 -1
  55. package/dist/bucket/index.js +47 -0
  56. package/dist/bucket/index.js.map +1 -1
  57. package/dist/bucket/index.workerd.js +24 -0
  58. package/dist/bucket/index.workerd.js.map +1 -1
  59. package/dist/cache/core/index.d.ts +134 -7
  60. package/dist/cache/core/index.d.ts.map +1 -1
  61. package/dist/cache/core/index.js +181 -15
  62. package/dist/cache/core/index.js.map +1 -1
  63. package/dist/cache/core/index.workerd.js +181 -15
  64. package/dist/cache/core/index.workerd.js.map +1 -1
  65. package/dist/cache/database/index.d.ts +156 -0
  66. package/dist/cache/database/index.d.ts.map +1 -0
  67. package/dist/cache/database/index.js +266 -0
  68. package/dist/cache/database/index.js.map +1 -0
  69. package/dist/cache/redis/index.d.ts +3 -2
  70. package/dist/cache/redis/index.d.ts.map +1 -1
  71. package/dist/cache/redis/index.js.map +1 -1
  72. package/dist/captcha/index.js.map +1 -1
  73. package/dist/cli/config/index.js.map +1 -1
  74. package/dist/cli/core/index.d.ts +142 -128
  75. package/dist/cli/core/index.d.ts.map +1 -1
  76. package/dist/cli/core/index.js +160 -13
  77. package/dist/cli/core/index.js.map +1 -1
  78. package/dist/cli/devtools/index.d.ts +3 -2
  79. package/dist/cli/devtools/index.d.ts.map +1 -1
  80. package/dist/cli/devtools/index.js.map +1 -1
  81. package/dist/cli/platform/index.d.ts +346 -290
  82. package/dist/cli/platform/index.d.ts.map +1 -1
  83. package/dist/cli/platform/index.js +106 -7
  84. package/dist/cli/platform/index.js.map +1 -1
  85. package/dist/cli/vendor/index.d.ts +12 -11
  86. package/dist/cli/vendor/index.d.ts.map +1 -1
  87. package/dist/cli/vendor/index.js.map +1 -1
  88. package/dist/command/index.d.ts +6 -5
  89. package/dist/command/index.d.ts.map +1 -1
  90. package/dist/command/index.js.map +1 -1
  91. package/dist/core/index.browser.js +1 -1
  92. package/dist/core/index.browser.js.map +1 -1
  93. package/dist/core/index.d.ts +119 -118
  94. package/dist/core/index.d.ts.map +1 -1
  95. package/dist/core/index.js +1 -1
  96. package/dist/core/index.js.map +1 -1
  97. package/dist/core/index.native.js +1 -1
  98. package/dist/core/index.native.js.map +1 -1
  99. package/dist/core/index.workerd.js +1 -1
  100. package/dist/core/index.workerd.js.map +1 -1
  101. package/dist/crypto/index.browser.js.map +1 -1
  102. package/dist/crypto/index.d.ts +3 -2
  103. package/dist/crypto/index.d.ts.map +1 -1
  104. package/dist/crypto/index.js.map +1 -1
  105. package/dist/datetime/index.js.map +1 -1
  106. package/dist/email/brevo/index.js.map +1 -1
  107. package/dist/email/core/index.d.ts +3 -2
  108. package/dist/email/core/index.d.ts.map +1 -1
  109. package/dist/email/core/index.js.map +1 -1
  110. package/dist/email/core/index.workerd.js.map +1 -1
  111. package/dist/email/smtp/index.d.ts +7 -6
  112. package/dist/email/smtp/index.d.ts.map +1 -1
  113. package/dist/email/smtp/index.js.map +1 -1
  114. package/dist/fake/index.js.map +1 -1
  115. package/dist/lock/core/index.d.ts +5 -4
  116. package/dist/lock/core/index.d.ts.map +1 -1
  117. package/dist/lock/core/index.js.map +1 -1
  118. package/dist/lock/redis/index.js.map +1 -1
  119. package/dist/logger/index.d.ts +10 -9
  120. package/dist/logger/index.d.ts.map +1 -1
  121. package/dist/logger/index.js.map +1 -1
  122. package/dist/mcp/index.d.ts +9 -8
  123. package/dist/mcp/index.d.ts.map +1 -1
  124. package/dist/mcp/index.js +1 -1
  125. package/dist/mcp/index.js.map +1 -1
  126. package/dist/orm/core/index.browser.js +9 -3
  127. package/dist/orm/core/index.browser.js.map +1 -1
  128. package/dist/orm/core/index.bun.js +31 -10
  129. package/dist/orm/core/index.bun.js.map +1 -1
  130. package/dist/orm/core/index.d.ts +33 -14
  131. package/dist/orm/core/index.d.ts.map +1 -1
  132. package/dist/orm/core/index.js +31 -10
  133. package/dist/orm/core/index.js.map +1 -1
  134. package/dist/orm/postgres/index.bun.js.map +1 -1
  135. package/dist/orm/postgres/index.d.ts +6 -5
  136. package/dist/orm/postgres/index.d.ts.map +1 -1
  137. package/dist/orm/postgres/index.js.map +1 -1
  138. package/dist/queue/core/index.d.ts +5 -4
  139. package/dist/queue/core/index.d.ts.map +1 -1
  140. package/dist/queue/core/index.js.map +1 -1
  141. package/dist/queue/core/index.workerd.js.map +1 -1
  142. package/dist/queue/redis/index.d.ts +3 -2
  143. package/dist/queue/redis/index.d.ts.map +1 -1
  144. package/dist/queue/redis/index.js.map +1 -1
  145. package/dist/react/auth/index.browser.js.map +1 -1
  146. package/dist/react/auth/index.js.map +1 -1
  147. package/dist/react/core/index.js.map +1 -1
  148. package/dist/react/form/index.d.ts +5 -0
  149. package/dist/react/form/index.d.ts.map +1 -1
  150. package/dist/react/form/index.js +8 -4
  151. package/dist/react/form/index.js.map +1 -1
  152. package/dist/react/head/index.browser.js.map +1 -1
  153. package/dist/react/head/index.js.map +1 -1
  154. package/dist/react/i18n/index.d.ts +2 -1
  155. package/dist/react/i18n/index.d.ts.map +1 -1
  156. package/dist/react/i18n/index.js.map +1 -1
  157. package/dist/react/intro/index.js.map +1 -1
  158. package/dist/react/router/index.browser.js.map +1 -1
  159. package/dist/react/router/index.d.ts +206 -205
  160. package/dist/react/router/index.d.ts.map +1 -1
  161. package/dist/react/router/index.js.map +1 -1
  162. package/dist/react/testing/index.js.map +1 -1
  163. package/dist/react/ui/index.d.ts +11 -11
  164. package/dist/react/ui/index.d.ts.map +1 -1
  165. package/dist/react/ui/index.js.map +1 -1
  166. package/dist/redis/index.bun.js.map +1 -1
  167. package/dist/redis/index.js.map +1 -1
  168. package/dist/retry/index.js.map +1 -1
  169. package/dist/router/index.js.map +1 -1
  170. package/dist/scheduler/index.d.ts +25 -2
  171. package/dist/scheduler/index.d.ts.map +1 -1
  172. package/dist/scheduler/index.js +12 -0
  173. package/dist/scheduler/index.js.map +1 -1
  174. package/dist/scheduler/index.workerd.js +12 -0
  175. package/dist/scheduler/index.workerd.js.map +1 -1
  176. package/dist/security/index.browser.js +29 -1
  177. package/dist/security/index.browser.js.map +1 -1
  178. package/dist/security/index.d.ts +82 -35
  179. package/dist/security/index.d.ts.map +1 -1
  180. package/dist/security/index.js +56 -3
  181. package/dist/security/index.js.map +1 -1
  182. package/dist/server/auth/index.d.ts +163 -158
  183. package/dist/server/auth/index.d.ts.map +1 -1
  184. package/dist/server/auth/index.js +16 -4
  185. package/dist/server/auth/index.js.map +1 -1
  186. package/dist/server/cookies/index.browser.js.map +1 -1
  187. package/dist/server/cookies/index.js.map +1 -1
  188. package/dist/server/core/index.browser.js.map +1 -1
  189. package/dist/server/core/index.d.ts +35 -34
  190. package/dist/server/core/index.d.ts.map +1 -1
  191. package/dist/server/core/index.js.map +1 -1
  192. package/dist/server/cors/index.d.ts +7 -6
  193. package/dist/server/cors/index.d.ts.map +1 -1
  194. package/dist/server/cors/index.js.map +1 -1
  195. package/dist/server/etag/index.js.map +1 -1
  196. package/dist/server/health/index.d.ts +16 -15
  197. package/dist/server/health/index.d.ts.map +1 -1
  198. package/dist/server/health/index.js.map +1 -1
  199. package/dist/server/links/index.browser.js.map +1 -1
  200. package/dist/server/links/index.d.ts +51 -50
  201. package/dist/server/links/index.d.ts.map +1 -1
  202. package/dist/server/links/index.js.map +1 -1
  203. package/dist/server/metrics/index.js.map +1 -1
  204. package/dist/server/proxy/index.js.map +1 -1
  205. package/dist/server/rate-limit/index.d.ts +6 -5
  206. package/dist/server/rate-limit/index.d.ts.map +1 -1
  207. package/dist/server/rate-limit/index.js.map +1 -1
  208. package/dist/server/static/index.js.map +1 -1
  209. package/dist/server/swagger/index.d.ts +2 -1
  210. package/dist/server/swagger/index.d.ts.map +1 -1
  211. package/dist/server/swagger/index.js.map +1 -1
  212. package/dist/sms/index.js.map +1 -1
  213. package/dist/system/index.browser.js.map +1 -1
  214. package/dist/system/index.js.map +1 -1
  215. package/dist/system/index.workerd.js.map +1 -1
  216. package/dist/topic/core/index.js.map +1 -1
  217. package/dist/topic/redis/index.d.ts +3 -2
  218. package/dist/topic/redis/index.d.ts.map +1 -1
  219. package/dist/topic/redis/index.js.map +1 -1
  220. package/package.json +33 -39
  221. package/src/api/audits/controllers/AdminAuditController.ts +29 -0
  222. package/src/api/audits/entities/audits.ts +1 -0
  223. package/src/api/files/controllers/FileController.ts +24 -0
  224. package/src/api/files/entities/files.ts +1 -0
  225. package/src/api/files/services/FileService.ts +41 -0
  226. package/src/api/jobs/__tests__/$job.spec.ts +501 -24
  227. package/src/api/jobs/entities/jobExecutionEntity.ts +4 -3
  228. package/src/api/jobs/index.ts +47 -10
  229. package/src/api/jobs/primitives/$job.ts +22 -9
  230. package/src/api/jobs/providers/DirectJobDispatcher.ts +71 -0
  231. package/src/api/jobs/providers/JobDispatcher.ts +49 -0
  232. package/src/api/jobs/providers/JobProvider.ts +385 -147
  233. package/src/api/jobs/providers/JobQueueProvider.ts +43 -18
  234. package/src/api/jobs/schemas/jobConfigAtom.ts +9 -3
  235. package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +11 -0
  236. package/src/api/jobs/schemas/jobRegistrationSchema.ts +4 -2
  237. package/src/api/jobs/services/JobService.ts +21 -11
  238. package/src/api/keys/controllers/AdminApiKeyController.ts +23 -0
  239. package/src/api/keys/entities/apiKeyEntity.ts +1 -0
  240. package/src/api/keys/services/ApiKeyService.ts +42 -0
  241. package/src/api/notifications/__tests__/AlephaApiNotifications.spec.ts +63 -0
  242. package/src/api/notifications/controllers/AdminNotificationController.ts +48 -1
  243. package/src/api/notifications/index.ts +13 -3
  244. package/src/api/notifications/jobs/NotificationJobs.ts +0 -6
  245. package/src/api/parameters/controllers/AdminParameterController.ts +26 -0
  246. package/src/api/parameters/services/ParameterProvider.ts +18 -0
  247. package/src/api/payments/controllers/MockCheckoutController.ts +146 -0
  248. package/src/api/payments/index.ts +3 -0
  249. package/src/api/payments/providers/MemoryPaymentProvider.ts +9 -4
  250. package/src/api/payments/providers/PaymentProvider.ts +25 -9
  251. package/src/api/payments/services/PaymentService.ts +3 -0
  252. package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
  253. package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
  254. package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
  255. package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
  256. package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
  257. package/src/api/subscriptions/entities/subscriptions.ts +68 -0
  258. package/src/api/subscriptions/index.ts +133 -0
  259. package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
  260. package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
  261. package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
  262. package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
  263. package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
  264. package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
  265. package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
  266. package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
  267. package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
  268. package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
  269. package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
  270. package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
  271. package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
  272. package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
  273. package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
  274. package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
  275. package/src/api/subscriptions/services/BillingService.ts +437 -0
  276. package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
  277. package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
  278. package/src/api/subscriptions/services/UsageService.ts +118 -0
  279. package/src/api/users/__tests__/Registration-emailMode.spec.ts +203 -0
  280. package/src/api/users/__tests__/UsernameSlugger.spec.ts +138 -0
  281. package/src/api/users/atoms/realmAuthSettingsAtom.ts +41 -3
  282. package/src/api/users/controllers/AdminSessionController.ts +29 -0
  283. package/src/api/users/controllers/AdminUserController.ts +32 -0
  284. package/src/api/users/index.ts +3 -0
  285. package/src/api/users/services/CredentialService.ts +5 -0
  286. package/src/api/users/services/RegistrationService.ts +49 -1
  287. package/src/api/users/services/SessionCrudService.ts +16 -0
  288. package/src/api/users/services/SessionService.ts +17 -59
  289. package/src/api/users/services/UsernameSlugger.ts +195 -0
  290. package/src/bucket/primitives/$bucket.ts +21 -0
  291. package/src/bucket/providers/CloudflareR2Provider.ts +15 -0
  292. package/src/bucket/providers/FileStorageProvider.ts +9 -0
  293. package/src/bucket/providers/LocalFileStorageProvider.ts +14 -0
  294. package/src/bucket/providers/MemoryFileStorageProvider.ts +9 -0
  295. package/src/bucket/providers/NodeS3BucketProvider.ts +35 -0
  296. package/src/cache/core/__tests__/$cache.memory.spec.ts +450 -0
  297. package/src/cache/core/__tests__/$cache.swr.spec.ts +394 -0
  298. package/src/cache/core/index.ts +16 -0
  299. package/src/cache/core/primitives/$cache.ts +367 -24
  300. package/src/cache/database/__tests__/DatabaseCacheProvider.behavior.spec.ts +203 -0
  301. package/src/cache/database/__tests__/DatabaseCacheProvider.spec.ts +110 -0
  302. package/src/cache/database/entities/cacheEntries.ts +55 -0
  303. package/src/cache/database/index.ts +36 -0
  304. package/src/cache/database/providers/DatabaseCacheProvider.ts +348 -0
  305. package/src/cli/core/services/ProjectScaffolder.ts +0 -2
  306. package/src/cli/core/tasks/BuildCloudflareTask.ts +33 -3
  307. package/src/cli/core/tasks/BuildSitemapTask.ts +7 -0
  308. package/src/cli/core/tasks/BuildVercelTask.ts +82 -3
  309. package/src/cli/core/templates/agentMd.ts +39 -4
  310. package/src/cli/core/templates/biomeJson.ts +25 -1
  311. package/src/cli/core/templates/saasAdminLayoutTsx.ts +2 -2
  312. package/src/cli/platform/__tests__/CloudflareAdapter.spec.ts +117 -0
  313. package/src/cli/platform/__tests__/detectResources.spec.ts +96 -0
  314. package/src/cli/platform/adapters/CloudflareAdapter.ts +104 -7
  315. package/src/cli/platform/atoms/platformOptions.ts +13 -0
  316. package/src/cli/platform/commands/platform.ts +7 -1
  317. package/src/cli/platform/schemas/platform.ts +1 -0
  318. package/src/cli/platform/services/CloudflareApi.ts +61 -0
  319. package/src/cli/platform/services/PlatformOrchestrator.ts +9 -4
  320. package/src/core/__tests__/$module.spec.ts +2 -2
  321. package/src/core/primitives/$module.ts +4 -4
  322. package/src/mcp/providers/McpServerProvider.ts +1 -1
  323. package/src/orm/core/providers/DatabaseTypeProvider.ts +9 -3
  324. package/src/orm/core/providers/drivers/DatabaseProvider.ts +1 -1
  325. package/src/orm/core/schemas/insertSchema.ts +10 -2
  326. package/src/orm/core/services/Repository.ts +27 -7
  327. package/src/react/form/hooks/useFormState.ts +8 -1
  328. package/src/react/form/index.ts +10 -1
  329. package/src/react/form/services/FormModel.ts +9 -3
  330. package/src/scheduler/index.ts +14 -0
  331. package/src/scheduler/providers/CronProvider.ts +13 -0
  332. package/src/security/atoms/currentTenantAtom.ts +34 -0
  333. package/src/security/index.browser.ts +1 -0
  334. package/src/security/index.ts +12 -1
  335. package/src/security/primitives/$issuer.ts +17 -1
  336. package/src/security/providers/SecurityProvider.ts +37 -0
  337. package/src/server/auth/__tests__/validateRedirectUri.spec.ts +78 -0
  338. package/src/server/auth/providers/ServerAuthProvider.ts +21 -5
  339. package/tsconfig.base.json +2 -1
  340. package/dist/react/websocket/index.d.ts +0 -117
  341. package/dist/react/websocket/index.d.ts.map +0 -1
  342. package/dist/react/websocket/index.js +0 -108
  343. package/dist/react/websocket/index.js.map +0 -1
  344. package/dist/websocket/index.browser.js +0 -844
  345. package/dist/websocket/index.browser.js.map +0 -1
  346. package/dist/websocket/index.d.ts +0 -876
  347. package/dist/websocket/index.d.ts.map +0 -1
  348. package/dist/websocket/index.js +0 -1175
  349. package/dist/websocket/index.js.map +0 -1
  350. package/src/react/websocket/hooks/useRoom.tsx +0 -251
  351. package/src/react/websocket/index.ts +0 -7
  352. package/src/websocket/__tests__/$channel.spec.ts +0 -30
  353. package/src/websocket/__tests__/$websocket-new.spec.ts +0 -195
  354. package/src/websocket/__tests__/RoomManager.spec.ts +0 -146
  355. package/src/websocket/__tests__/websocket-integration.spec.ts +0 -951
  356. package/src/websocket/errors/WebSocketError.ts +0 -34
  357. package/src/websocket/index.browser.ts +0 -25
  358. package/src/websocket/index.shared.ts +0 -8
  359. package/src/websocket/index.ts +0 -85
  360. package/src/websocket/interfaces/WebSocketInterfaces.ts +0 -252
  361. package/src/websocket/primitives/$channel.ts +0 -131
  362. package/src/websocket/primitives/$websocket.ts +0 -107
  363. package/src/websocket/providers/NodeWebSocketServerProvider.ts +0 -617
  364. package/src/websocket/providers/WebSocketServerProvider.ts +0 -56
  365. package/src/websocket/services/RoomManager.ts +0 -160
  366. package/src/websocket/services/WebSocketClient.ts +0 -642
  367. package/src/websocket/services/WebSocketTopicService.ts +0 -108
@@ -0,0 +1,1919 @@
1
+ import { $context, $hook, $inject, $module, Alepha, createMiddleware, t } from "alepha";
2
+ import { $secure } from "alepha/security";
3
+ import { $action, BadRequestError, ForbiddenError, NotFoundError, okSchema } from "alepha/server";
4
+ import { $entity, $repository, db, pageQuerySchema } from "alepha/orm";
5
+ import { $parameter } from "alepha/api/parameters";
6
+ import { DateTimeProvider } from "alepha/datetime";
7
+ import { $logger } from "alepha/logger";
8
+ import { $job } from "alepha/api/jobs";
9
+ import { PaymentService } from "alepha/api/payments";
10
+ import { $notification } from "alepha/api/notifications";
11
+ import { CacheProvider } from "alepha/cache";
12
+ //#region ../../src/api/subscriptions/schemas/cancelSubscriptionSchema.ts
13
+ const cancelSubscriptionSchema = t.object({
14
+ reason: t.optional(t.string()),
15
+ immediate: t.optional(t.boolean())
16
+ });
17
+ //#endregion
18
+ //#region ../../src/api/subscriptions/schemas/changePlanSchema.ts
19
+ const changePlanSchema = t.object({
20
+ planId: t.string(),
21
+ interval: t.optional(t.enum(["monthly", "yearly"])),
22
+ immediate: t.optional(t.boolean())
23
+ });
24
+ //#endregion
25
+ //#region ../../src/api/subscriptions/schemas/mrrSchema.ts
26
+ const mrrSchema = t.object({
27
+ total: t.integer(),
28
+ byPlan: t.record(t.text(), t.integer()),
29
+ growth: t.integer(),
30
+ newMrr: t.integer(),
31
+ expansionMrr: t.integer(),
32
+ contractionMrr: t.integer(),
33
+ churnMrr: t.integer()
34
+ });
35
+ //#endregion
36
+ //#region ../../src/api/subscriptions/schemas/subscriptionQuerySchema.ts
37
+ const subscriptionQuerySchema = t.extend(pageQuerySchema, {
38
+ status: t.optional(t.enum([
39
+ "trialing",
40
+ "active",
41
+ "past_due",
42
+ "suspended",
43
+ "cancelled",
44
+ "expired"
45
+ ])),
46
+ planId: t.optional(t.string()),
47
+ organizationId: t.optional(t.uuid())
48
+ });
49
+ //#endregion
50
+ //#region ../../src/api/subscriptions/entities/subscriptions.ts
51
+ const subscriptions = $entity({
52
+ name: "subscriptions",
53
+ schema: t.object({
54
+ id: db.primaryKey(t.uuid()),
55
+ version: db.version(),
56
+ createdAt: db.createdAt(),
57
+ updatedAt: db.updatedAt(),
58
+ organizationId: db.organization(),
59
+ planId: t.string(),
60
+ interval: t.enum(["monthly", "yearly"]),
61
+ status: t.enum([
62
+ "trialing",
63
+ "active",
64
+ "past_due",
65
+ "suspended",
66
+ "cancelled",
67
+ "expired"
68
+ ]),
69
+ currentPeriodStart: t.datetime(),
70
+ currentPeriodEnd: t.datetime(),
71
+ trialStart: t.optional(t.datetime()),
72
+ trialEnd: t.optional(t.datetime()),
73
+ cancelledAt: t.optional(t.datetime()),
74
+ cancelReason: t.optional(t.string()),
75
+ cancelAtPeriodEnd: t.boolean({ default: false }),
76
+ lastPaymentIntentId: t.optional(t.uuid()),
77
+ lastPaymentAt: t.optional(t.datetime()),
78
+ nextBillingAt: t.optional(t.datetime()),
79
+ dunningStartedAt: t.optional(t.datetime()),
80
+ dunningAttempt: t.integer({ default: 0 }),
81
+ dunningNextRetryAt: t.optional(t.datetime()),
82
+ pendingPlanId: t.optional(t.string()),
83
+ pendingInterval: t.optional(t.enum(["monthly", "yearly"])),
84
+ metadata: t.optional(t.record(t.text(), t.any()))
85
+ }),
86
+ indexes: [
87
+ {
88
+ columns: ["organizationId"],
89
+ unique: true
90
+ },
91
+ { columns: ["status"] },
92
+ { columns: ["planId", "status"] },
93
+ { columns: ["nextBillingAt"] },
94
+ { columns: ["trialEnd"] },
95
+ { columns: ["dunningNextRetryAt"] },
96
+ { columns: ["currentPeriodEnd"] }
97
+ ]
98
+ });
99
+ //#endregion
100
+ //#region ../../src/api/subscriptions/schemas/subscriptionResourceSchema.ts
101
+ const subscriptionResourceSchema = subscriptions.schema;
102
+ //#endregion
103
+ //#region ../../src/api/subscriptions/schemas/subscriptionStatsSchema.ts
104
+ const subscriptionStatsSchema = t.object({
105
+ total: t.integer(),
106
+ trialing: t.integer(),
107
+ active: t.integer(),
108
+ pastDue: t.integer(),
109
+ suspended: t.integer(),
110
+ cancelled: t.integer(),
111
+ expired: t.integer(),
112
+ trialConversionRate: t.number(),
113
+ churnRate: t.number(),
114
+ byPlan: t.record(t.text(), t.object({
115
+ active: t.integer(),
116
+ trialing: t.integer(),
117
+ total: t.integer()
118
+ }))
119
+ });
120
+ //#endregion
121
+ //#region ../../src/api/subscriptions/schemas/planDefinitionSchema.ts
122
+ const planDefinitionSchema = t.object({
123
+ /**
124
+ * Unique plan identifier (e.g., "free", "starter", "pro", "enterprise").
125
+ */
126
+ id: t.string({
127
+ minLength: 1,
128
+ maxLength: 50
129
+ }),
130
+ /**
131
+ * Display name (e.g., "Pro Plan").
132
+ */
133
+ name: t.string(),
134
+ /**
135
+ * Optional description.
136
+ */
137
+ description: t.optional(t.string()),
138
+ /**
139
+ * Whether this plan is available for new subscriptions.
140
+ */
141
+ available: t.boolean({ default: true }),
142
+ /**
143
+ * Pricing per billing interval.
144
+ * Multiple entries for monthly/yearly.
145
+ */
146
+ pricing: t.array(t.object({
147
+ interval: t.enum(["monthly", "yearly"]),
148
+ amount: t.integer({ minimum: 0 }),
149
+ currency: t.string({
150
+ minLength: 3,
151
+ maxLength: 3
152
+ })
153
+ })),
154
+ /**
155
+ * Trial configuration for this plan.
156
+ * Overrides global settings.trialDays if set.
157
+ */
158
+ trial: t.optional(t.object({
159
+ days: t.integer({
160
+ minimum: 0,
161
+ maximum: 365
162
+ }),
163
+ requirePaymentMethod: t.boolean({ default: false })
164
+ })),
165
+ /**
166
+ * Feature entitlements. Boolean flags for feature access.
167
+ * Checked via SubscriptionService.can("feature-name").
168
+ */
169
+ features: t.array(t.string()),
170
+ /**
171
+ * Usage limits. Numeric caps on resources.
172
+ * Checked via SubscriptionService.limit("resource-name").
173
+ * -1 = unlimited.
174
+ */
175
+ limits: t.record(t.text(), t.integer()),
176
+ /**
177
+ * Sort order for display (lower = first).
178
+ */
179
+ order: t.integer({ default: 0 }),
180
+ /**
181
+ * Metadata for app-specific plan data.
182
+ */
183
+ metadata: t.optional(t.record(t.text(), t.any()))
184
+ });
185
+ //#endregion
186
+ //#region ../../src/api/subscriptions/schemas/subscriptionSettingsSchema.ts
187
+ const subscriptionSettingsSchema = t.object({
188
+ /**
189
+ * Default trial days (overridden per-plan if plan.trial.days is set).
190
+ */
191
+ trialDays: t.integer({
192
+ default: 14,
193
+ minimum: 0,
194
+ maximum: 365
195
+ }),
196
+ /**
197
+ * Days after payment failure before suspension.
198
+ * During grace period, subscription remains active but flagged.
199
+ */
200
+ gracePeriodDays: t.integer({
201
+ default: 7,
202
+ minimum: 0,
203
+ maximum: 30
204
+ }),
205
+ /**
206
+ * Days after first payment failure to retry, relative to the failure date.
207
+ * e.g., [1, 3, 5, 7] means retry on day 1, 3, 5, 7 after failure.
208
+ */
209
+ dunningSchedule: t.array(t.integer({ minimum: 1 })),
210
+ /**
211
+ * When user cancels, wait until period end (true) or cancel immediately (false).
212
+ */
213
+ cancelAtPeriodEnd: t.boolean({ default: true }),
214
+ /**
215
+ * Prorate charges when changing plans mid-cycle.
216
+ */
217
+ prorateOnChange: t.boolean({ default: true })
218
+ });
219
+ //#endregion
220
+ //#region ../../src/api/subscriptions/services/SubscriptionConfig.ts
221
+ var SubscriptionConfig = class {
222
+ plans = $parameter({
223
+ name: "subscriptions.plans",
224
+ description: "Subscription plan definitions",
225
+ schema: t.object({ plans: t.array(planDefinitionSchema) }),
226
+ default: { plans: [] }
227
+ });
228
+ settings = $parameter({
229
+ name: "subscriptions.settings",
230
+ description: "Global subscription settings",
231
+ schema: subscriptionSettingsSchema,
232
+ default: {
233
+ trialDays: 14,
234
+ gracePeriodDays: 7,
235
+ dunningSchedule: [
236
+ 1,
237
+ 3,
238
+ 5,
239
+ 7
240
+ ],
241
+ cancelAtPeriodEnd: true,
242
+ prorateOnChange: true
243
+ }
244
+ });
245
+ async getPlans() {
246
+ return (await this.plans.get()).plans;
247
+ }
248
+ async getSettings() {
249
+ return this.settings.get();
250
+ }
251
+ async getPlan(planId) {
252
+ const plan = (await this.getPlans()).find((p) => p.id === planId);
253
+ if (!plan) throw new BadRequestError(`Plan '${planId}' not found`);
254
+ return plan;
255
+ }
256
+ async getPlanPricing(planId, interval) {
257
+ const pricing = (await this.getPlan(planId)).pricing.find((p) => p.interval === interval);
258
+ if (!pricing) throw new BadRequestError(`No ${interval} pricing for plan '${planId}'`);
259
+ return pricing;
260
+ }
261
+ };
262
+ //#endregion
263
+ //#region ../../src/api/subscriptions/entities/subscriptionEvents.ts
264
+ const subscriptionEvents = $entity({
265
+ name: "subscription_events",
266
+ schema: t.object({
267
+ id: db.primaryKey(t.uuid()),
268
+ createdAt: db.createdAt(),
269
+ subscriptionId: db.ref(t.uuid(), () => subscriptions.cols.id, { onDelete: "cascade" }),
270
+ organizationId: db.organization(),
271
+ type: t.enum([
272
+ "created",
273
+ "trial_started",
274
+ "trial_ended",
275
+ "activated",
276
+ "renewed",
277
+ "payment_failed",
278
+ "payment_retried",
279
+ "past_due",
280
+ "suspended",
281
+ "reactivated",
282
+ "plan_changed",
283
+ "plan_change_scheduled",
284
+ "cancelled",
285
+ "expired",
286
+ "resumed"
287
+ ]),
288
+ previousStatus: t.optional(t.string()),
289
+ newStatus: t.optional(t.string()),
290
+ previousPlanId: t.optional(t.string()),
291
+ newPlanId: t.optional(t.string()),
292
+ paymentIntentId: t.optional(t.uuid()),
293
+ amount: t.optional(t.integer()),
294
+ currency: t.optional(t.string()),
295
+ triggeredBy: t.optional(t.string()),
296
+ userId: t.optional(t.uuid()),
297
+ note: t.optional(t.string())
298
+ }),
299
+ indexes: [
300
+ { columns: ["subscriptionId", "createdAt"] },
301
+ { columns: ["organizationId", "createdAt"] },
302
+ { columns: ["type"] }
303
+ ]
304
+ });
305
+ //#endregion
306
+ //#region ../../src/api/subscriptions/services/SubscriptionService.ts
307
+ var SubscriptionService = class {
308
+ alepha = $inject(Alepha);
309
+ log = $logger();
310
+ dateTime = $inject(DateTimeProvider);
311
+ subscriptionRepo = $repository(subscriptions);
312
+ eventRepo = $repository(subscriptionEvents);
313
+ config = $inject(SubscriptionConfig);
314
+ /**
315
+ * Find a subscription by organization ID.
316
+ * Returns null if no subscription exists.
317
+ */
318
+ async getByOrganization(organizationId) {
319
+ return await this.subscriptionRepo.findOne({ where: { organizationId: { eq: organizationId } } }) ?? null;
320
+ }
321
+ /**
322
+ * Get a subscription by ID. Throws NotFoundError if not found.
323
+ */
324
+ async getSubscription(id) {
325
+ return this.subscriptionRepo.getById(id);
326
+ }
327
+ /**
328
+ * Returns true if the subscription currently grants access.
329
+ * Accessible statuses: trialing, active, past_due (grace period),
330
+ * or cancelled with cancelAtPeriodEnd before period end.
331
+ */
332
+ isAccessible(sub) {
333
+ if (sub.status === "trialing" || sub.status === "active" || sub.status === "past_due") return true;
334
+ if (sub.status === "cancelled" && sub.cancelAtPeriodEnd && this.dateTime.now().isBefore(sub.currentPeriodEnd)) return true;
335
+ return false;
336
+ }
337
+ /**
338
+ * Record a subscription event in the event log.
339
+ */
340
+ async recordEvent(subscriptionId, organizationId, type, context) {
341
+ await this.eventRepo.create({
342
+ subscriptionId,
343
+ organizationId,
344
+ type,
345
+ previousStatus: context?.previousStatus,
346
+ newStatus: context?.newStatus,
347
+ previousPlanId: context?.previousPlanId,
348
+ newPlanId: context?.newPlanId,
349
+ paymentIntentId: context?.paymentIntentId,
350
+ amount: context?.amount,
351
+ currency: context?.currency,
352
+ triggeredBy: context?.triggeredBy,
353
+ userId: context?.userId,
354
+ note: context?.note
355
+ });
356
+ }
357
+ /**
358
+ * Compute the end of a billing interval from a start date.
359
+ */
360
+ computeIntervalEnd(start, interval) {
361
+ const startDate = this.dateTime.of(start);
362
+ const unit = interval === "monthly" ? "months" : "years";
363
+ return startDate.add(1, unit).toISOString();
364
+ }
365
+ /**
366
+ * Create a new subscription for an organization.
367
+ */
368
+ async subscribe(organizationId, planId, interval, options) {
369
+ const plan = await this.config.getPlan(planId);
370
+ if (!plan.available) throw new BadRequestError(`Plan '${planId}' is not available for new subscriptions`);
371
+ await this.config.getPlanPricing(planId, interval);
372
+ if (await this.subscriptionRepo.findOne({ where: {
373
+ organizationId: { eq: organizationId },
374
+ status: { inArray: [
375
+ "trialing",
376
+ "active",
377
+ "past_due"
378
+ ] }
379
+ } })) throw new BadRequestError("Organization already has an active subscription");
380
+ const settings = await this.config.getSettings();
381
+ const trialDays = options?.trialDays ?? plan.trial?.days ?? settings.trialDays;
382
+ const skipTrial = options?.skipTrial ?? false;
383
+ const now = this.dateTime.now();
384
+ const nowISO = now.toISOString();
385
+ if (trialDays > 0 && !skipTrial) {
386
+ const trialEnd = now.add(trialDays, "days").toISOString();
387
+ const entity = await this.subscriptionRepo.create({
388
+ organizationId,
389
+ planId,
390
+ interval,
391
+ status: "trialing",
392
+ currentPeriodStart: nowISO,
393
+ currentPeriodEnd: trialEnd,
394
+ trialStart: nowISO,
395
+ trialEnd,
396
+ nextBillingAt: trialEnd,
397
+ cancelAtPeriodEnd: false,
398
+ dunningAttempt: 0,
399
+ metadata: options?.metadata
400
+ });
401
+ await this.recordEvent(entity.id, organizationId, "created", { newStatus: "trialing" });
402
+ await this.recordEvent(entity.id, organizationId, "trial_started", { newStatus: "trialing" });
403
+ this.log.info("Subscription created with trial", {
404
+ id: entity.id,
405
+ organizationId,
406
+ planId,
407
+ trialDays
408
+ });
409
+ await this.alepha.events.emit("subscription:created", { subscription: entity });
410
+ return entity;
411
+ }
412
+ const periodEnd = this.computeIntervalEnd(nowISO, interval);
413
+ const entity = await this.subscriptionRepo.create({
414
+ organizationId,
415
+ planId,
416
+ interval,
417
+ status: "active",
418
+ currentPeriodStart: nowISO,
419
+ currentPeriodEnd: periodEnd,
420
+ nextBillingAt: periodEnd,
421
+ cancelAtPeriodEnd: false,
422
+ dunningAttempt: 0,
423
+ metadata: options?.metadata
424
+ });
425
+ await this.recordEvent(entity.id, organizationId, "created", { newStatus: "active" });
426
+ this.log.info("Subscription created", {
427
+ id: entity.id,
428
+ organizationId,
429
+ planId
430
+ });
431
+ await this.alepha.events.emit("subscription:created", { subscription: entity });
432
+ return entity;
433
+ }
434
+ /**
435
+ * Cancel a subscription.
436
+ * If immediate, the subscription expires right away.
437
+ * If at period end, the subscription remains accessible until the period ends.
438
+ */
439
+ async cancel(subscriptionId, options) {
440
+ const sub = await this.subscriptionRepo.getById(subscriptionId);
441
+ const orgId = sub.organizationId;
442
+ if (sub.status !== "trialing" && sub.status !== "active" && sub.status !== "past_due") throw new BadRequestError(`Cannot cancel subscription with status '${sub.status}'`);
443
+ const settings = await this.config.getSettings();
444
+ const immediate = options?.immediate ?? !settings.cancelAtPeriodEnd;
445
+ const nowISO = this.dateTime.now().toISOString();
446
+ const previousStatus = sub.status;
447
+ if (immediate) {
448
+ await this.subscriptionRepo.updateById(subscriptionId, {
449
+ status: "expired",
450
+ cancelledAt: nowISO,
451
+ cancelReason: options?.reason,
452
+ cancelAtPeriodEnd: false
453
+ });
454
+ await this.recordEvent(subscriptionId, orgId, "cancelled", {
455
+ previousStatus,
456
+ newStatus: "expired",
457
+ triggeredBy: options?.cancelledBy ? "user" : "system",
458
+ userId: options?.cancelledBy,
459
+ note: options?.reason
460
+ });
461
+ this.log.info("Subscription cancelled immediately", {
462
+ id: subscriptionId,
463
+ organizationId: orgId
464
+ });
465
+ } else {
466
+ await this.subscriptionRepo.updateById(subscriptionId, {
467
+ status: "cancelled",
468
+ cancelledAt: nowISO,
469
+ cancelReason: options?.reason,
470
+ cancelAtPeriodEnd: true
471
+ });
472
+ await this.recordEvent(subscriptionId, orgId, "cancelled", {
473
+ previousStatus,
474
+ newStatus: "cancelled",
475
+ triggeredBy: options?.cancelledBy ? "user" : "system",
476
+ userId: options?.cancelledBy,
477
+ note: options?.reason
478
+ });
479
+ this.log.info("Subscription cancelled at period end", {
480
+ id: subscriptionId,
481
+ organizationId: orgId,
482
+ periodEnd: sub.currentPeriodEnd
483
+ });
484
+ }
485
+ await this.alepha.events.emit("subscription:cancelled", {
486
+ subscription: sub,
487
+ immediate,
488
+ reason: options?.reason
489
+ });
490
+ }
491
+ /**
492
+ * Resume a cancelled subscription before its period ends.
493
+ * Only valid for subscriptions cancelled with cancelAtPeriodEnd.
494
+ */
495
+ async resume(subscriptionId) {
496
+ const sub = await this.subscriptionRepo.getById(subscriptionId);
497
+ const orgId = sub.organizationId;
498
+ if (sub.status !== "cancelled") throw new BadRequestError(`Cannot resume subscription with status '${sub.status}', must be 'cancelled'`);
499
+ if (!sub.cancelAtPeriodEnd) throw new BadRequestError("Cannot resume a subscription that was not cancelled at period end");
500
+ if (!this.dateTime.now().isBefore(sub.currentPeriodEnd)) throw new BadRequestError("Cannot resume subscription, period has already ended");
501
+ await this.subscriptionRepo.updateById(subscriptionId, {
502
+ status: "active",
503
+ cancelledAt: void 0,
504
+ cancelReason: void 0,
505
+ cancelAtPeriodEnd: false
506
+ });
507
+ await this.recordEvent(subscriptionId, orgId, "resumed", {
508
+ previousStatus: "cancelled",
509
+ newStatus: "active"
510
+ });
511
+ this.log.info("Subscription resumed", {
512
+ id: subscriptionId,
513
+ organizationId: orgId
514
+ });
515
+ await this.alepha.events.emit("subscription:resumed", { subscription: sub });
516
+ }
517
+ /**
518
+ * Change the plan of a subscription.
519
+ * If immediate, proration is calculated and the plan changes now.
520
+ * If at period end, the change is scheduled for the next renewal.
521
+ * Returns the net proration amount (positive = charge, negative = credit).
522
+ */
523
+ async changePlan(subscriptionId, newPlanId, newInterval, options) {
524
+ const sub = await this.subscriptionRepo.getById(subscriptionId);
525
+ const orgId = sub.organizationId;
526
+ if (sub.status !== "active" && sub.status !== "trialing") throw new BadRequestError(`Cannot change plan for subscription with status '${sub.status}'`);
527
+ if (!(await this.config.getPlan(newPlanId)).available) throw new BadRequestError(`Plan '${newPlanId}' is not available for new subscriptions`);
528
+ const effectiveInterval = newInterval ?? sub.interval;
529
+ await this.config.getPlanPricing(newPlanId, effectiveInterval);
530
+ const settings = await this.config.getSettings();
531
+ if (!(options?.immediate ?? true)) {
532
+ await this.subscriptionRepo.updateById(subscriptionId, {
533
+ pendingPlanId: newPlanId,
534
+ pendingInterval: effectiveInterval
535
+ });
536
+ await this.recordEvent(subscriptionId, orgId, "plan_change_scheduled", {
537
+ previousPlanId: sub.planId,
538
+ newPlanId,
539
+ note: `Scheduled change to '${newPlanId}' (${effectiveInterval}) at period end`
540
+ });
541
+ this.log.info("Plan change scheduled for period end", {
542
+ id: subscriptionId,
543
+ organizationId: orgId,
544
+ newPlanId,
545
+ newInterval: effectiveInterval
546
+ });
547
+ await this.alepha.events.emit("subscription:plan_changed", {
548
+ subscription: sub,
549
+ previousPlanId: sub.planId,
550
+ newPlanId,
551
+ immediate: false
552
+ });
553
+ return 0;
554
+ }
555
+ const shouldProrate = options?.prorate ?? settings.prorateOnChange;
556
+ let netAmount = 0;
557
+ if (shouldProrate && sub.status === "active") netAmount = await this.calculateProration(sub, newPlanId, effectiveInterval);
558
+ const previousPlanId = sub.planId;
559
+ await this.subscriptionRepo.updateById(subscriptionId, {
560
+ planId: newPlanId,
561
+ interval: effectiveInterval,
562
+ pendingPlanId: void 0,
563
+ pendingInterval: void 0,
564
+ metadata: netAmount < 0 ? {
565
+ ...sub.metadata,
566
+ credit: Math.abs(netAmount)
567
+ } : sub.metadata
568
+ });
569
+ await this.recordEvent(subscriptionId, orgId, "plan_changed", {
570
+ previousPlanId,
571
+ newPlanId,
572
+ amount: netAmount !== 0 ? Math.abs(netAmount) : void 0,
573
+ note: netAmount > 0 ? `Proration charge: ${netAmount}` : netAmount < 0 ? `Proration credit: ${Math.abs(netAmount)}` : void 0
574
+ });
575
+ this.log.info("Plan changed immediately", {
576
+ id: subscriptionId,
577
+ organizationId: orgId,
578
+ previousPlanId,
579
+ newPlanId,
580
+ netAmount
581
+ });
582
+ await this.alepha.events.emit("subscription:plan_changed", {
583
+ subscription: sub,
584
+ previousPlanId,
585
+ newPlanId,
586
+ immediate: true,
587
+ netAmount
588
+ });
589
+ return netAmount;
590
+ }
591
+ /**
592
+ * Reactivate a suspended subscription (admin action).
593
+ * Resets dunning state and starts a new billing period.
594
+ */
595
+ async reactivate(subscriptionId) {
596
+ const sub = await this.subscriptionRepo.getById(subscriptionId);
597
+ const orgId = sub.organizationId;
598
+ if (sub.status !== "suspended") throw new BadRequestError(`Cannot reactivate subscription with status '${sub.status}', must be 'suspended'`);
599
+ const nowISO = this.dateTime.now().toISOString();
600
+ const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
601
+ await this.subscriptionRepo.updateById(subscriptionId, {
602
+ status: "active",
603
+ currentPeriodStart: nowISO,
604
+ currentPeriodEnd: periodEnd,
605
+ nextBillingAt: periodEnd,
606
+ dunningStartedAt: void 0,
607
+ dunningAttempt: 0,
608
+ dunningNextRetryAt: void 0
609
+ });
610
+ await this.recordEvent(subscriptionId, orgId, "reactivated", {
611
+ previousStatus: "suspended",
612
+ newStatus: "active"
613
+ });
614
+ this.log.info("Subscription reactivated", {
615
+ id: subscriptionId,
616
+ organizationId: orgId
617
+ });
618
+ await this.alepha.events.emit("subscription:reactivated", { subscription: sub });
619
+ }
620
+ /**
621
+ * Extend the trial period of a trialing subscription.
622
+ */
623
+ async extendTrial(subscriptionId, days) {
624
+ const sub = await this.subscriptionRepo.getById(subscriptionId);
625
+ if (sub.status !== "trialing") throw new BadRequestError(`Cannot extend trial for subscription with status '${sub.status}', must be 'trialing'`);
626
+ if (!sub.trialEnd) throw new BadRequestError("Subscription has no trial end date set");
627
+ const newTrialEnd = this.dateTime.of(sub.trialEnd).add(days, "days").toISOString();
628
+ await this.subscriptionRepo.updateById(subscriptionId, {
629
+ trialEnd: newTrialEnd,
630
+ currentPeriodEnd: newTrialEnd,
631
+ nextBillingAt: newTrialEnd
632
+ });
633
+ this.log.info("Trial extended", {
634
+ id: subscriptionId,
635
+ organizationId: sub.organizationId,
636
+ days,
637
+ newTrialEnd
638
+ });
639
+ }
640
+ /**
641
+ * Check if an organization has access to a specific feature.
642
+ */
643
+ async can(organizationId, feature) {
644
+ const sub = await this.getByOrganization(organizationId);
645
+ if (!sub || !this.isAccessible(sub)) return false;
646
+ return (await this.config.getPlan(sub.planId)).features.includes(feature);
647
+ }
648
+ /**
649
+ * Get the usage limit for a resource.
650
+ * Returns -1 for unlimited, 0 for no access.
651
+ */
652
+ async limit(organizationId, resource) {
653
+ const sub = await this.getByOrganization(organizationId);
654
+ if (!sub || !this.isAccessible(sub)) return 0;
655
+ return (await this.config.getPlan(sub.planId)).limits[resource] ?? 0;
656
+ }
657
+ /**
658
+ * Get the full entitlements snapshot for an organization.
659
+ */
660
+ async getEntitlements(organizationId) {
661
+ const sub = await this.getByOrganization(organizationId);
662
+ if (!sub) throw new NotFoundError(`No subscription found for organization '${organizationId}'`);
663
+ const plan = await this.config.getPlan(sub.planId);
664
+ return {
665
+ planId: plan.id,
666
+ planName: plan.name,
667
+ status: sub.status,
668
+ features: plan.features,
669
+ limits: plan.limits,
670
+ trialEndsAt: sub.trialEnd,
671
+ periodEndsAt: sub.currentPeriodEnd,
672
+ cancelledAt: sub.cancelledAt
673
+ };
674
+ }
675
+ /**
676
+ * Find subscriptions with pagination and filtering.
677
+ */
678
+ async findSubscriptions(query = {}) {
679
+ query.sort ??= "-createdAt";
680
+ const where = this.subscriptionRepo.createQueryWhere();
681
+ if (query.status) where.status = { eq: query.status };
682
+ if (query.planId) where.planId = { eq: query.planId };
683
+ if (query.organizationId) where.organizationId = { eq: query.organizationId };
684
+ return this.subscriptionRepo.paginate(query, { where }, { count: true });
685
+ }
686
+ /**
687
+ * Get the event history for a subscription, ordered by most recent first.
688
+ */
689
+ async getHistory(subscriptionId) {
690
+ return this.eventRepo.findMany({
691
+ where: { subscriptionId: { eq: subscriptionId } },
692
+ orderBy: {
693
+ column: "createdAt",
694
+ direction: "desc"
695
+ }
696
+ });
697
+ }
698
+ /**
699
+ * Get aggregated subscription statistics.
700
+ */
701
+ async getStats() {
702
+ const [trialing, active, pastDue, suspended, cancelled, expired] = await Promise.all([
703
+ this.subscriptionRepo.count({ status: { eq: "trialing" } }),
704
+ this.subscriptionRepo.count({ status: { eq: "active" } }),
705
+ this.subscriptionRepo.count({ status: { eq: "past_due" } }),
706
+ this.subscriptionRepo.count({ status: { eq: "suspended" } }),
707
+ this.subscriptionRepo.count({ status: { eq: "cancelled" } }),
708
+ this.subscriptionRepo.count({ status: { eq: "expired" } })
709
+ ]);
710
+ const total = trialing + active + pastDue + suspended + cancelled + expired;
711
+ const trialEndedEvents = await this.eventRepo.count({ type: { eq: "trial_ended" } });
712
+ const activatedEvents = await this.eventRepo.count({ type: { eq: "activated" } });
713
+ const trialConversionRate = trialEndedEvents > 0 ? activatedEvents / trialEndedEvents : 0;
714
+ const cancelledEvents = await this.eventRepo.count({ type: { eq: "cancelled" } });
715
+ const totalSubscribed = active + trialing + pastDue;
716
+ const churnRate = totalSubscribed + cancelledEvents > 0 ? cancelledEvents / (totalSubscribed + cancelledEvents) : 0;
717
+ const plans = await this.config.getPlans();
718
+ const byPlan = {};
719
+ for (const plan of plans) {
720
+ const [planActive, planTrialing] = await Promise.all([this.subscriptionRepo.count({
721
+ planId: { eq: plan.id },
722
+ status: { eq: "active" }
723
+ }), this.subscriptionRepo.count({
724
+ planId: { eq: plan.id },
725
+ status: { eq: "trialing" }
726
+ })]);
727
+ byPlan[plan.id] = {
728
+ active: planActive,
729
+ trialing: planTrialing,
730
+ total: planActive + planTrialing
731
+ };
732
+ }
733
+ return {
734
+ total,
735
+ trialing,
736
+ active,
737
+ pastDue,
738
+ suspended,
739
+ cancelled,
740
+ expired,
741
+ trialConversionRate,
742
+ churnRate,
743
+ byPlan
744
+ };
745
+ }
746
+ /**
747
+ * Get revenue data from recent subscription events.
748
+ * Sums amounts from renewed and activated events within the specified window.
749
+ */
750
+ async getRevenue(days = 30) {
751
+ const cutoff = this.dateTime.now().subtract(days, "days").toISOString();
752
+ const events = await this.eventRepo.findMany({ where: {
753
+ type: { inArray: ["renewed", "activated"] },
754
+ createdAt: { gt: cutoff }
755
+ } });
756
+ let total = 0;
757
+ for (const event of events) total += event.amount ?? 0;
758
+ return {
759
+ total,
760
+ count: events.length
761
+ };
762
+ }
763
+ /**
764
+ * Calculate proration for a mid-cycle plan change.
765
+ * Returns the net amount: positive = charge, negative = credit.
766
+ */
767
+ async calculateProration(sub, newPlanId, newInterval) {
768
+ const oldPricing = await this.config.getPlanPricing(sub.planId, sub.interval);
769
+ const newPricing = await this.config.getPlanPricing(newPlanId, newInterval);
770
+ const now = this.dateTime.now();
771
+ const periodStart = this.dateTime.of(sub.currentPeriodStart);
772
+ const daysInPeriod = this.dateTime.of(sub.currentPeriodEnd).diff(periodStart, "days");
773
+ if (daysInPeriod <= 0) return 0;
774
+ const daysRemaining = daysInPeriod - now.diff(periodStart, "days");
775
+ const oldDailyRate = oldPricing.amount / daysInPeriod;
776
+ const newDailyRate = newPricing.amount / daysInPeriod;
777
+ const credit = Math.round(daysRemaining * oldDailyRate);
778
+ return Math.round(daysRemaining * newDailyRate) - credit;
779
+ }
780
+ };
781
+ //#endregion
782
+ //#region ../../src/api/subscriptions/controllers/AdminSubscriptionController.ts
783
+ var AdminSubscriptionController = class {
784
+ url = "/subscriptions";
785
+ group = "admin:subscriptions";
786
+ service = $inject(SubscriptionService);
787
+ config = $inject(SubscriptionConfig);
788
+ /**
789
+ * Find subscriptions with pagination and filtering.
790
+ */
791
+ findSubscriptions = $action({
792
+ path: this.url,
793
+ group: this.group,
794
+ use: [$secure({ permissions: ["admin:subscription:read"] })],
795
+ description: "Find subscriptions with pagination and filtering",
796
+ schema: {
797
+ query: subscriptionQuerySchema,
798
+ response: t.page(subscriptionResourceSchema)
799
+ },
800
+ handler: ({ query }) => this.service.findSubscriptions(query)
801
+ });
802
+ /**
803
+ * Get a subscription by ID.
804
+ */
805
+ getSubscription = $action({
806
+ path: `${this.url}/:id`,
807
+ group: this.group,
808
+ use: [$secure({ permissions: ["admin:subscription:read"] })],
809
+ description: "Get a subscription by ID",
810
+ schema: {
811
+ params: t.object({ id: t.uuid() }),
812
+ response: subscriptionResourceSchema
813
+ },
814
+ handler: ({ params }) => this.service.getSubscription(params.id)
815
+ });
816
+ /**
817
+ * Get aggregated subscription statistics.
818
+ */
819
+ getStats = $action({
820
+ path: `${this.url}/stats`,
821
+ group: this.group,
822
+ use: [$secure({ permissions: ["admin:subscription:read"] })],
823
+ description: "Get aggregated subscription statistics",
824
+ schema: { response: subscriptionStatsSchema },
825
+ handler: () => this.service.getStats()
826
+ });
827
+ /**
828
+ * Get revenue data from recent subscription events.
829
+ */
830
+ getRevenue = $action({
831
+ path: `${this.url}/revenue`,
832
+ group: this.group,
833
+ use: [$secure({ permissions: ["admin:subscription:read"] })],
834
+ description: "Get revenue data from recent subscription events",
835
+ schema: {
836
+ query: t.object({ days: t.optional(t.integer({
837
+ minimum: 1,
838
+ maximum: 365
839
+ })) }),
840
+ response: t.object({
841
+ total: t.integer(),
842
+ count: t.integer()
843
+ })
844
+ },
845
+ handler: ({ query }) => this.service.getRevenue(query.days)
846
+ });
847
+ /**
848
+ * Get Monthly Recurring Revenue breakdown.
849
+ */
850
+ getMrr = $action({
851
+ path: `${this.url}/mrr`,
852
+ group: this.group,
853
+ use: [$secure({ permissions: ["admin:subscription:read"] })],
854
+ description: "Get Monthly Recurring Revenue breakdown",
855
+ schema: { response: mrrSchema },
856
+ handler: async () => {
857
+ const activeSubs = await this.service.findSubscriptions({
858
+ status: "active",
859
+ size: 1e3
860
+ });
861
+ const plans = await this.config.getPlans();
862
+ const byPlan = {};
863
+ let total = 0;
864
+ for (const sub of activeSubs.content) {
865
+ const plan = plans.find((p) => p.id === sub.planId);
866
+ if (!plan) continue;
867
+ const pricing = plan.pricing.find((p) => p.interval === sub.interval);
868
+ if (!pricing) continue;
869
+ const monthlyAmount = sub.interval === "yearly" ? Math.round(pricing.amount / 12) : pricing.amount;
870
+ byPlan[sub.planId] = (byPlan[sub.planId] ?? 0) + monthlyAmount;
871
+ total += monthlyAmount;
872
+ }
873
+ return {
874
+ total,
875
+ byPlan,
876
+ growth: 0,
877
+ newMrr: 0,
878
+ expansionMrr: 0,
879
+ contractionMrr: 0,
880
+ churnMrr: 0
881
+ };
882
+ }
883
+ });
884
+ /**
885
+ * Force a plan change for a subscription (admin action).
886
+ */
887
+ adminChangePlan = $action({
888
+ method: "POST",
889
+ path: `${this.url}/:id/change-plan`,
890
+ group: this.group,
891
+ use: [$secure({ permissions: ["admin:subscription:update"] })],
892
+ description: "Force a plan change for a subscription",
893
+ schema: {
894
+ params: t.object({ id: t.uuid() }),
895
+ body: changePlanSchema,
896
+ response: subscriptionResourceSchema
897
+ },
898
+ handler: async ({ params, body }) => {
899
+ await this.service.changePlan(params.id, body.planId, body.interval, { immediate: body.immediate });
900
+ return this.service.getSubscription(params.id);
901
+ }
902
+ });
903
+ /**
904
+ * Force cancel a subscription (admin action).
905
+ */
906
+ adminCancel = $action({
907
+ method: "POST",
908
+ path: `${this.url}/:id/cancel`,
909
+ group: this.group,
910
+ use: [$secure({ permissions: ["admin:subscription:update"] })],
911
+ description: "Force cancel a subscription",
912
+ schema: {
913
+ params: t.object({ id: t.uuid() }),
914
+ body: cancelSubscriptionSchema,
915
+ response: okSchema
916
+ },
917
+ handler: async ({ params, body }) => {
918
+ await this.service.cancel(params.id, {
919
+ reason: body.reason,
920
+ immediate: body.immediate
921
+ });
922
+ return { ok: true };
923
+ }
924
+ });
925
+ /**
926
+ * Reactivate a suspended subscription (admin action).
927
+ */
928
+ adminReactivate = $action({
929
+ method: "POST",
930
+ path: `${this.url}/:id/reactivate`,
931
+ group: this.group,
932
+ use: [$secure({ permissions: ["admin:subscription:update"] })],
933
+ description: "Reactivate a suspended subscription",
934
+ schema: {
935
+ params: t.object({ id: t.uuid() }),
936
+ response: okSchema
937
+ },
938
+ handler: async ({ params }) => {
939
+ await this.service.reactivate(params.id);
940
+ return { ok: true };
941
+ }
942
+ });
943
+ /**
944
+ * Extend the trial period for a trialing subscription (admin action).
945
+ */
946
+ adminExtendTrial = $action({
947
+ method: "POST",
948
+ path: `${this.url}/:id/extend-trial`,
949
+ group: this.group,
950
+ use: [$secure({ permissions: ["admin:subscription:update"] })],
951
+ description: "Extend the trial period for a subscription",
952
+ schema: {
953
+ params: t.object({ id: t.uuid() }),
954
+ body: t.object({ days: t.integer({
955
+ minimum: 1,
956
+ maximum: 365
957
+ }) }),
958
+ response: okSchema
959
+ },
960
+ handler: async ({ params, body }) => {
961
+ await this.service.extendTrial(params.id, body.days);
962
+ return { ok: true };
963
+ }
964
+ });
965
+ };
966
+ //#endregion
967
+ //#region ../../src/api/subscriptions/schemas/createSubscriptionSchema.ts
968
+ const createSubscriptionSchema = t.object({
969
+ planId: t.string(),
970
+ interval: t.enum(["monthly", "yearly"]),
971
+ paymentMethodId: t.optional(t.uuid()),
972
+ skipTrial: t.optional(t.boolean()),
973
+ metadata: t.optional(t.record(t.text(), t.any()))
974
+ });
975
+ //#endregion
976
+ //#region ../../src/api/subscriptions/schemas/entitlementsSchema.ts
977
+ const entitlementsSchema = t.object({
978
+ planId: t.string(),
979
+ planName: t.string(),
980
+ status: t.enum([
981
+ "trialing",
982
+ "active",
983
+ "past_due",
984
+ "suspended",
985
+ "cancelled",
986
+ "expired"
987
+ ]),
988
+ features: t.array(t.string()),
989
+ limits: t.record(t.text(), t.integer()),
990
+ trialEndsAt: t.optional(t.datetime()),
991
+ periodEndsAt: t.datetime(),
992
+ cancelledAt: t.optional(t.datetime())
993
+ });
994
+ //#endregion
995
+ //#region ../../src/api/subscriptions/schemas/planResourceSchema.ts
996
+ const planResourceSchema = t.object({
997
+ id: t.string(),
998
+ name: t.string(),
999
+ description: t.optional(t.string()),
1000
+ pricing: t.array(t.object({
1001
+ interval: t.enum(["monthly", "yearly"]),
1002
+ amount: t.integer(),
1003
+ currency: t.string()
1004
+ })),
1005
+ features: t.array(t.string()),
1006
+ limits: t.record(t.text(), t.integer()),
1007
+ trial: t.optional(t.object({
1008
+ days: t.integer(),
1009
+ requirePaymentMethod: t.boolean()
1010
+ })),
1011
+ order: t.integer()
1012
+ });
1013
+ //#endregion
1014
+ //#region ../../src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts
1015
+ const subscriptionEventResourceSchema = subscriptionEvents.schema;
1016
+ //#endregion
1017
+ //#region ../../src/api/subscriptions/controllers/SubscriptionController.ts
1018
+ var SubscriptionController = class {
1019
+ url = "/subscriptions";
1020
+ group = "subscriptions";
1021
+ service = $inject(SubscriptionService);
1022
+ config = $inject(SubscriptionConfig);
1023
+ /**
1024
+ * List available subscription plans with pricing.
1025
+ */
1026
+ getPlans = $action({
1027
+ path: `${this.url}/plans`,
1028
+ group: this.group,
1029
+ description: "List available subscription plans",
1030
+ schema: { response: t.array(planResourceSchema) },
1031
+ handler: async () => {
1032
+ return (await this.config.getPlans()).filter((p) => p.available).map((p) => ({
1033
+ id: p.id,
1034
+ name: p.name,
1035
+ description: p.description,
1036
+ pricing: p.pricing,
1037
+ features: p.features,
1038
+ limits: p.limits,
1039
+ trial: p.trial,
1040
+ order: p.order
1041
+ }));
1042
+ }
1043
+ });
1044
+ /**
1045
+ * Get the current organization's subscription.
1046
+ */
1047
+ getMySubscription = $action({
1048
+ path: `${this.url}/mine`,
1049
+ group: this.group,
1050
+ use: [$secure()],
1051
+ description: "Get the current organization subscription",
1052
+ schema: { response: subscriptionResourceSchema },
1053
+ handler: async ({ user }) => {
1054
+ const sub = await this.service.getByOrganization(user.organization);
1055
+ if (!sub) throw new NotFoundError("No subscription found for your organization");
1056
+ return sub;
1057
+ }
1058
+ });
1059
+ /**
1060
+ * Create a new subscription for the current organization.
1061
+ */
1062
+ subscribe = $action({
1063
+ method: "POST",
1064
+ path: this.url,
1065
+ group: this.group,
1066
+ use: [$secure({ permissions: ["subscription:create"] })],
1067
+ description: "Create a new subscription",
1068
+ schema: {
1069
+ body: createSubscriptionSchema,
1070
+ response: subscriptionResourceSchema
1071
+ },
1072
+ handler: ({ body, user }) => this.service.subscribe(user.organization, body.planId, body.interval, {
1073
+ skipTrial: body.skipTrial,
1074
+ metadata: body.metadata
1075
+ })
1076
+ });
1077
+ /**
1078
+ * Change the plan for the current organization's subscription.
1079
+ */
1080
+ changePlan = $action({
1081
+ method: "POST",
1082
+ path: `${this.url}/mine/change-plan`,
1083
+ group: this.group,
1084
+ use: [$secure({ permissions: ["subscription:update"] })],
1085
+ description: "Upgrade or downgrade the subscription plan",
1086
+ schema: {
1087
+ body: changePlanSchema,
1088
+ response: subscriptionResourceSchema
1089
+ },
1090
+ handler: async ({ body, user }) => {
1091
+ const sub = await this.service.getByOrganization(user.organization);
1092
+ if (!sub) throw new NotFoundError("No subscription found for your organization");
1093
+ await this.service.changePlan(sub.id, body.planId, body.interval, { immediate: body.immediate });
1094
+ return this.service.getSubscription(sub.id);
1095
+ }
1096
+ });
1097
+ /**
1098
+ * Cancel the current organization's subscription.
1099
+ */
1100
+ cancel = $action({
1101
+ method: "POST",
1102
+ path: `${this.url}/mine/cancel`,
1103
+ group: this.group,
1104
+ use: [$secure({ permissions: ["subscription:update"] })],
1105
+ description: "Cancel the current subscription",
1106
+ schema: {
1107
+ body: cancelSubscriptionSchema,
1108
+ response: okSchema
1109
+ },
1110
+ handler: async ({ body, user }) => {
1111
+ const sub = await this.service.getByOrganization(user.organization);
1112
+ if (!sub) throw new NotFoundError("No subscription found for your organization");
1113
+ await this.service.cancel(sub.id, {
1114
+ reason: body.reason,
1115
+ immediate: body.immediate
1116
+ });
1117
+ return { ok: true };
1118
+ }
1119
+ });
1120
+ /**
1121
+ * Resume a cancelled subscription before the period ends.
1122
+ */
1123
+ resume = $action({
1124
+ method: "POST",
1125
+ path: `${this.url}/mine/resume`,
1126
+ group: this.group,
1127
+ use: [$secure({ permissions: ["subscription:update"] })],
1128
+ description: "Resume a cancelled subscription",
1129
+ schema: { response: okSchema },
1130
+ handler: async ({ user }) => {
1131
+ const sub = await this.service.getByOrganization(user.organization);
1132
+ if (!sub) throw new NotFoundError("No subscription found for your organization");
1133
+ await this.service.resume(sub.id);
1134
+ return { ok: true };
1135
+ }
1136
+ });
1137
+ /**
1138
+ * Get the billing event history for the current organization's subscription.
1139
+ */
1140
+ getSubscriptionHistory = $action({
1141
+ path: `${this.url}/mine/history`,
1142
+ group: this.group,
1143
+ use: [$secure()],
1144
+ description: "Get the subscription billing event history",
1145
+ schema: { response: t.array(subscriptionEventResourceSchema) },
1146
+ handler: async ({ user }) => {
1147
+ const sub = await this.service.getByOrganization(user.organization);
1148
+ if (!sub) throw new NotFoundError("No subscription found for your organization");
1149
+ return this.service.getHistory(sub.id);
1150
+ }
1151
+ });
1152
+ /**
1153
+ * Get the feature and usage limit entitlements for the current organization.
1154
+ */
1155
+ getEntitlements = $action({
1156
+ path: `${this.url}/mine/entitlements`,
1157
+ group: this.group,
1158
+ use: [$secure()],
1159
+ description: "Get the feature and limit entitlements for the current organization",
1160
+ schema: { response: entitlementsSchema },
1161
+ handler: ({ user }) => this.service.getEntitlements(user.organization)
1162
+ });
1163
+ };
1164
+ //#endregion
1165
+ //#region ../../src/api/subscriptions/jobs/SubscriptionJobs.ts
1166
+ var SubscriptionJobs = class {
1167
+ log = $logger();
1168
+ dateTime = $inject(DateTimeProvider);
1169
+ paymentService = $inject(PaymentService);
1170
+ config = $inject(SubscriptionConfig);
1171
+ subscriptionRepo = $repository(subscriptions);
1172
+ eventRepo = $repository(subscriptionEvents);
1173
+ /**
1174
+ * Record a subscription event in the event log.
1175
+ */
1176
+ async recordEvent(subscriptionId, organizationId, type, context) {
1177
+ await this.eventRepo.create({
1178
+ subscriptionId,
1179
+ organizationId,
1180
+ type,
1181
+ previousStatus: context?.previousStatus,
1182
+ newStatus: context?.newStatus,
1183
+ paymentIntentId: context?.paymentIntentId,
1184
+ amount: context?.amount,
1185
+ currency: context?.currency,
1186
+ triggeredBy: context?.triggeredBy,
1187
+ note: context?.note
1188
+ });
1189
+ }
1190
+ /**
1191
+ * Creates payment intents for subscriptions due for renewal.
1192
+ * Runs hourly.
1193
+ */
1194
+ billingCycle = $job({
1195
+ cron: "0 * * * *",
1196
+ lock: true,
1197
+ timeout: [10, "minute"],
1198
+ handler: async ({ now }) => {
1199
+ const nowISO = now.toISOString();
1200
+ const due = await this.subscriptionRepo.findMany({ where: {
1201
+ nextBillingAt: { lte: nowISO },
1202
+ status: { inArray: ["active", "trialing"] }
1203
+ } });
1204
+ this.log.info(`Billing cycle: processing ${due.length} subscription(s)`);
1205
+ for (const sub of due) try {
1206
+ const pricing = await this.config.getPlanPricing(sub.planId, sub.interval);
1207
+ const intent = await this.paymentService.createIntent(pricing.amount, pricing.currency, { subscriptionId: sub.id });
1208
+ await this.subscriptionRepo.updateById(sub.id, { lastPaymentIntentId: intent.id });
1209
+ this.log.debug("Created payment intent for subscription", {
1210
+ subscriptionId: sub.id,
1211
+ intentId: intent.id
1212
+ });
1213
+ } catch (err) {
1214
+ this.log.error("Failed to create payment intent for subscription", {
1215
+ subscriptionId: sub.id,
1216
+ error: err
1217
+ });
1218
+ }
1219
+ }
1220
+ });
1221
+ /**
1222
+ * Retries failed payments on the dunning schedule.
1223
+ * Runs hourly.
1224
+ */
1225
+ dunningRetry = $job({
1226
+ cron: "0 * * * *",
1227
+ lock: true,
1228
+ timeout: [10, "minute"],
1229
+ handler: async ({ now }) => {
1230
+ const nowISO = now.toISOString();
1231
+ const pastDue = await this.subscriptionRepo.findMany({ where: {
1232
+ dunningNextRetryAt: { lte: nowISO },
1233
+ status: { eq: "past_due" }
1234
+ } });
1235
+ this.log.info(`Dunning retry: processing ${pastDue.length} subscription(s)`);
1236
+ const settings = await this.config.getSettings();
1237
+ for (const sub of pastDue) try {
1238
+ const pricing = await this.config.getPlanPricing(sub.planId, sub.interval);
1239
+ const intent = await this.paymentService.createIntent(pricing.amount, pricing.currency, { subscriptionId: sub.id });
1240
+ const newAttempt = sub.dunningAttempt + 1;
1241
+ const scheduleDays = settings.dunningSchedule[newAttempt - 1];
1242
+ const nextRetry = scheduleDays !== void 0 ? now.add(scheduleDays, "days").toISOString() : void 0;
1243
+ await this.subscriptionRepo.updateById(sub.id, {
1244
+ lastPaymentIntentId: intent.id,
1245
+ dunningAttempt: newAttempt,
1246
+ dunningNextRetryAt: nextRetry
1247
+ });
1248
+ await this.recordEvent(sub.id, sub.organizationId, "payment_retried", {
1249
+ paymentIntentId: intent.id,
1250
+ note: `Dunning retry attempt ${newAttempt}`
1251
+ });
1252
+ this.log.debug("Dunning retry payment intent created", {
1253
+ subscriptionId: sub.id,
1254
+ attempt: newAttempt
1255
+ });
1256
+ } catch (err) {
1257
+ this.log.error("Failed to create dunning retry intent", {
1258
+ subscriptionId: sub.id,
1259
+ error: err
1260
+ });
1261
+ }
1262
+ }
1263
+ });
1264
+ /**
1265
+ * Handles trial expirations.
1266
+ * Runs hourly.
1267
+ */
1268
+ trialExpiry = $job({
1269
+ cron: "0 * * * *",
1270
+ lock: true,
1271
+ handler: async ({ now }) => {
1272
+ const nowISO = now.toISOString();
1273
+ const expired = await this.subscriptionRepo.findMany({ where: {
1274
+ trialEnd: { lte: nowISO },
1275
+ status: { eq: "trialing" }
1276
+ } });
1277
+ this.log.info(`Trial expiry: processing ${expired.length} subscription(s)`);
1278
+ for (const sub of expired) try {
1279
+ const pricing = await this.config.getPlanPricing(sub.planId, sub.interval);
1280
+ const intent = await this.paymentService.createIntent(pricing.amount, pricing.currency, { subscriptionId: sub.id });
1281
+ await this.subscriptionRepo.updateById(sub.id, { lastPaymentIntentId: intent.id });
1282
+ this.log.debug("Created payment intent for trial expiry", {
1283
+ subscriptionId: sub.id,
1284
+ intentId: intent.id
1285
+ });
1286
+ } catch (err) {
1287
+ this.log.error("Failed to process trial expiry", {
1288
+ subscriptionId: sub.id,
1289
+ error: err
1290
+ });
1291
+ }
1292
+ }
1293
+ });
1294
+ /**
1295
+ * Expires cancelled subscriptions that reached period end.
1296
+ * Runs hourly.
1297
+ */
1298
+ expirationSweep = $job({
1299
+ cron: "0 * * * *",
1300
+ lock: true,
1301
+ handler: async ({ now }) => {
1302
+ const nowISO = now.toISOString();
1303
+ const toExpire = await this.subscriptionRepo.findMany({ where: {
1304
+ currentPeriodEnd: { lte: nowISO },
1305
+ status: { eq: "cancelled" },
1306
+ cancelAtPeriodEnd: { eq: true }
1307
+ } });
1308
+ this.log.info(`Expiration sweep: expiring ${toExpire.length} subscription(s)`);
1309
+ for (const sub of toExpire) try {
1310
+ await this.subscriptionRepo.updateById(sub.id, { status: "expired" });
1311
+ await this.recordEvent(sub.id, sub.organizationId, "expired", {
1312
+ previousStatus: "cancelled",
1313
+ newStatus: "expired"
1314
+ });
1315
+ this.log.debug("Subscription expired", { subscriptionId: sub.id });
1316
+ } catch (err) {
1317
+ this.log.error("Failed to expire subscription", {
1318
+ subscriptionId: sub.id,
1319
+ error: err
1320
+ });
1321
+ }
1322
+ }
1323
+ });
1324
+ /**
1325
+ * Suspends past_due subscriptions where grace period has elapsed.
1326
+ * Runs daily at 2 AM.
1327
+ */
1328
+ gracePeriodSweep = $job({
1329
+ cron: "0 2 * * *",
1330
+ lock: true,
1331
+ handler: async ({ now }) => {
1332
+ const gracePeriodDays = (await this.config.getSettings()).gracePeriodDays;
1333
+ const toSuspend = (await this.subscriptionRepo.findMany({ where: { status: { eq: "past_due" } } })).filter((sub) => {
1334
+ if (!sub.dunningStartedAt) return false;
1335
+ const graceEnd = this.dateTime.of(sub.dunningStartedAt).add(gracePeriodDays, "days");
1336
+ return !now.isBefore(graceEnd.toISOString());
1337
+ });
1338
+ this.log.info(`Grace period sweep: suspending ${toSuspend.length} subscription(s)`);
1339
+ for (const sub of toSuspend) try {
1340
+ await this.subscriptionRepo.updateById(sub.id, { status: "suspended" });
1341
+ await this.recordEvent(sub.id, sub.organizationId, "suspended", {
1342
+ previousStatus: "past_due",
1343
+ newStatus: "suspended"
1344
+ });
1345
+ this.log.debug("Subscription suspended after grace period", { subscriptionId: sub.id });
1346
+ } catch (err) {
1347
+ this.log.error("Failed to suspend subscription", {
1348
+ subscriptionId: sub.id,
1349
+ error: err
1350
+ });
1351
+ }
1352
+ }
1353
+ });
1354
+ /**
1355
+ * Purges old subscription events older than 365 days.
1356
+ * Runs daily at 3 AM.
1357
+ */
1358
+ purgeEvents = $job({
1359
+ cron: "0 3 * * *",
1360
+ lock: true,
1361
+ handler: async ({ now }) => {
1362
+ const cutoff = now.subtract(365, "days").toISOString();
1363
+ const old = await this.eventRepo.findMany({ where: { createdAt: { lt: cutoff } } });
1364
+ this.log.info(`Purge events: removing ${old.length} old event(s)`);
1365
+ for (const event of old) await this.eventRepo.deleteById(event.id);
1366
+ }
1367
+ });
1368
+ };
1369
+ //#endregion
1370
+ //#region ../../src/api/subscriptions/notifications/SubscriptionNotifications.ts
1371
+ var SubscriptionNotifications = class {
1372
+ /**
1373
+ * Sent when a trial is ending soon.
1374
+ */
1375
+ trialEnding = $notification({
1376
+ name: "subscription-trial-ending",
1377
+ category: "subscriptions",
1378
+ schema: t.object({
1379
+ planName: t.text(),
1380
+ trialEndDate: t.text(),
1381
+ amount: t.text(),
1382
+ interval: t.text()
1383
+ }),
1384
+ email: {
1385
+ subject: "Your trial is ending soon",
1386
+ body: (v) => `Your ${v.planName} trial is ending on ${v.trialEndDate}. You'll be charged ${v.amount}/${v.interval}.`
1387
+ }
1388
+ });
1389
+ /**
1390
+ * Sent when a payment fails. Critical notification.
1391
+ */
1392
+ paymentFailed = $notification({
1393
+ name: "subscription-payment-failed",
1394
+ category: "subscriptions",
1395
+ critical: true,
1396
+ schema: t.object({
1397
+ planName: t.text(),
1398
+ amount: t.text(),
1399
+ retryDate: t.optional(t.text())
1400
+ }),
1401
+ email: {
1402
+ subject: "Payment failed for your subscription",
1403
+ body: (v) => `We couldn't charge your card for ${v.planName} (${v.amount}). ${v.retryDate ? `We'll retry on ${v.retryDate}.` : "Please update your payment method."}`
1404
+ }
1405
+ });
1406
+ /**
1407
+ * Sent when a subscription is suspended due to failed payments. Critical notification.
1408
+ */
1409
+ subscriptionSuspended = $notification({
1410
+ name: "subscription-suspended",
1411
+ category: "subscriptions",
1412
+ critical: true,
1413
+ schema: t.object({ planName: t.text() }),
1414
+ email: {
1415
+ subject: "Your subscription has been suspended",
1416
+ body: (v) => `Your ${v.planName} subscription has been suspended due to failed payments. Update your payment method to reactivate.`
1417
+ }
1418
+ });
1419
+ /**
1420
+ * Sent when a subscription is successfully renewed.
1421
+ */
1422
+ subscriptionRenewed = $notification({
1423
+ name: "subscription-renewed",
1424
+ category: "subscriptions",
1425
+ schema: t.object({
1426
+ planName: t.text(),
1427
+ amount: t.text(),
1428
+ nextBillingDate: t.text()
1429
+ }),
1430
+ email: {
1431
+ subject: "Payment received — subscription renewed",
1432
+ body: (v) => `Your ${v.planName} subscription has been renewed. Amount: ${v.amount}. Next billing: ${v.nextBillingDate}.`
1433
+ }
1434
+ });
1435
+ /**
1436
+ * Sent when a subscription plan is changed.
1437
+ */
1438
+ planChanged = $notification({
1439
+ name: "subscription-plan-changed",
1440
+ category: "subscriptions",
1441
+ schema: t.object({
1442
+ oldPlanName: t.text(),
1443
+ newPlanName: t.text(),
1444
+ effectiveDate: t.text()
1445
+ }),
1446
+ email: {
1447
+ subject: "Your subscription plan has been changed",
1448
+ body: (v) => `Your plan has been changed from ${v.oldPlanName} to ${v.newPlanName}, effective ${v.effectiveDate}.`
1449
+ }
1450
+ });
1451
+ /**
1452
+ * Sent when a subscription is cancelled.
1453
+ */
1454
+ cancellationConfirmed = $notification({
1455
+ name: "subscription-cancelled",
1456
+ category: "subscriptions",
1457
+ schema: t.object({
1458
+ planName: t.text(),
1459
+ accessUntil: t.optional(t.text())
1460
+ }),
1461
+ email: {
1462
+ subject: "Your subscription has been cancelled",
1463
+ body: (v) => `Your ${v.planName} subscription has been cancelled.${v.accessUntil ? ` You'll have access until ${v.accessUntil}.` : ""}`
1464
+ }
1465
+ });
1466
+ };
1467
+ //#endregion
1468
+ //#region ../../src/api/subscriptions/services/BillingService.ts
1469
+ var BillingService = class {
1470
+ alepha = $inject(Alepha);
1471
+ log = $logger();
1472
+ dateTime = $inject(DateTimeProvider);
1473
+ subscriptionRepo = $repository(subscriptions);
1474
+ eventRepo = $repository(subscriptionEvents);
1475
+ paymentService = $inject(PaymentService);
1476
+ config = $inject(SubscriptionConfig);
1477
+ /**
1478
+ * React to successful payment capture.
1479
+ * Routes to the appropriate handler based on subscription status.
1480
+ */
1481
+ onPaymentCaptured = $hook({
1482
+ on: "payments:captured",
1483
+ handler: async (event) => {
1484
+ const sub = await this.findByPaymentIntent(event.intentId);
1485
+ if (!sub) return;
1486
+ if (sub.status === "trialing") await this.activate(sub, event);
1487
+ else if (sub.status === "active") await this.renew(sub, event);
1488
+ else if (sub.status === "past_due") await this.recoverFromDunning(sub, event);
1489
+ else if (sub.status === "suspended") await this.reactivateFromPayment(sub, event);
1490
+ }
1491
+ });
1492
+ /**
1493
+ * React to failed payment.
1494
+ * Starts or advances the dunning flow.
1495
+ */
1496
+ onPaymentFailed = $hook({
1497
+ on: "payments:failed",
1498
+ handler: async (event) => {
1499
+ const sub = await this.findByPaymentIntent(event.intentId);
1500
+ if (!sub) return;
1501
+ await this.handlePaymentFailure(sub, event);
1502
+ }
1503
+ });
1504
+ /**
1505
+ * Find a subscription by its last payment intent ID.
1506
+ * Returns null if no subscription matches.
1507
+ */
1508
+ async findByPaymentIntent(intentId) {
1509
+ return await this.subscriptionRepo.findOne({ where: { lastPaymentIntentId: { eq: intentId } } }) ?? null;
1510
+ }
1511
+ /**
1512
+ * Trial to active transition.
1513
+ * Sets the first paid billing period and records activation events.
1514
+ */
1515
+ async activate(sub, event) {
1516
+ const orgId = sub.organizationId;
1517
+ const nowISO = this.dateTime.now().toISOString();
1518
+ const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
1519
+ await this.subscriptionRepo.updateById(sub.id, {
1520
+ status: "active",
1521
+ lastPaymentAt: nowISO,
1522
+ lastPaymentIntentId: event.intentId,
1523
+ currentPeriodStart: nowISO,
1524
+ currentPeriodEnd: periodEnd,
1525
+ nextBillingAt: periodEnd
1526
+ });
1527
+ await this.recordEvent(sub.id, orgId, "trial_ended", {
1528
+ previousStatus: "trialing",
1529
+ newStatus: "active",
1530
+ paymentIntentId: event.intentId,
1531
+ amount: event.amount,
1532
+ currency: event.currency
1533
+ });
1534
+ await this.recordEvent(sub.id, orgId, "activated", {
1535
+ previousStatus: "trialing",
1536
+ newStatus: "active",
1537
+ paymentIntentId: event.intentId,
1538
+ amount: event.amount,
1539
+ currency: event.currency
1540
+ });
1541
+ this.log.info("Subscription activated from trial", {
1542
+ id: sub.id,
1543
+ organizationId: orgId,
1544
+ planId: sub.planId
1545
+ });
1546
+ await this.alepha.events.emit("subscription:activated", {
1547
+ subscriptionId: sub.id,
1548
+ organizationId: orgId,
1549
+ planId: sub.planId
1550
+ });
1551
+ }
1552
+ /**
1553
+ * Active to active cycle renewal.
1554
+ * Applies any pending plan change, then advances the billing period.
1555
+ */
1556
+ async renew(sub, event) {
1557
+ const orgId = sub.organizationId;
1558
+ let effectivePlanId = sub.planId;
1559
+ let effectiveInterval = sub.interval;
1560
+ if (sub.pendingPlanId) {
1561
+ effectivePlanId = sub.pendingPlanId;
1562
+ effectiveInterval = sub.pendingInterval ?? sub.interval;
1563
+ await this.subscriptionRepo.updateById(sub.id, {
1564
+ planId: effectivePlanId,
1565
+ interval: effectiveInterval,
1566
+ pendingPlanId: void 0,
1567
+ pendingInterval: void 0
1568
+ });
1569
+ await this.recordEvent(sub.id, orgId, "plan_changed", {
1570
+ previousPlanId: sub.planId,
1571
+ newPlanId: effectivePlanId,
1572
+ note: `Plan changed on renewal from '${sub.planId}' to '${effectivePlanId}'`
1573
+ });
1574
+ }
1575
+ const newPeriodStart = sub.currentPeriodEnd;
1576
+ const newPeriodEnd = this.computeIntervalEnd(newPeriodStart, effectiveInterval);
1577
+ await this.subscriptionRepo.updateById(sub.id, {
1578
+ currentPeriodStart: newPeriodStart,
1579
+ currentPeriodEnd: newPeriodEnd,
1580
+ lastPaymentAt: this.dateTime.now().toISOString(),
1581
+ lastPaymentIntentId: event.intentId,
1582
+ nextBillingAt: newPeriodEnd
1583
+ });
1584
+ await this.recordEvent(sub.id, orgId, "renewed", {
1585
+ paymentIntentId: event.intentId,
1586
+ amount: event.amount,
1587
+ currency: event.currency
1588
+ });
1589
+ this.log.info("Subscription renewed", {
1590
+ id: sub.id,
1591
+ organizationId: orgId,
1592
+ planId: effectivePlanId,
1593
+ periodEnd: newPeriodEnd
1594
+ });
1595
+ await this.alepha.events.emit("subscription:renewed", {
1596
+ subscriptionId: sub.id,
1597
+ organizationId: orgId,
1598
+ planId: effectivePlanId
1599
+ });
1600
+ }
1601
+ /**
1602
+ * Recover from dunning: past_due to active.
1603
+ * Resets all dunning state and records reactivation.
1604
+ */
1605
+ async recoverFromDunning(sub, event) {
1606
+ const orgId = sub.organizationId;
1607
+ const nowISO = this.dateTime.now().toISOString();
1608
+ await this.subscriptionRepo.updateById(sub.id, {
1609
+ status: "active",
1610
+ lastPaymentAt: nowISO,
1611
+ lastPaymentIntentId: event.intentId,
1612
+ dunningStartedAt: void 0,
1613
+ dunningAttempt: 0,
1614
+ dunningNextRetryAt: void 0
1615
+ });
1616
+ await this.recordEvent(sub.id, orgId, "reactivated", {
1617
+ previousStatus: "past_due",
1618
+ newStatus: "active",
1619
+ paymentIntentId: event.intentId,
1620
+ amount: event.amount,
1621
+ currency: event.currency
1622
+ });
1623
+ this.log.info("Subscription recovered from dunning", {
1624
+ id: sub.id,
1625
+ organizationId: orgId
1626
+ });
1627
+ await this.alepha.events.emit("subscription:reactivated", {
1628
+ subscriptionId: sub.id,
1629
+ organizationId: orgId,
1630
+ planId: sub.planId
1631
+ });
1632
+ }
1633
+ /**
1634
+ * Reactivate from suspended state after a successful payment.
1635
+ * Resets dunning, sets a fresh billing period, and records reactivation.
1636
+ */
1637
+ async reactivateFromPayment(sub, event) {
1638
+ const orgId = sub.organizationId;
1639
+ const nowISO = this.dateTime.now().toISOString();
1640
+ const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
1641
+ await this.subscriptionRepo.updateById(sub.id, {
1642
+ status: "active",
1643
+ currentPeriodStart: nowISO,
1644
+ currentPeriodEnd: periodEnd,
1645
+ nextBillingAt: periodEnd,
1646
+ lastPaymentAt: nowISO,
1647
+ lastPaymentIntentId: event.intentId,
1648
+ dunningStartedAt: void 0,
1649
+ dunningAttempt: 0,
1650
+ dunningNextRetryAt: void 0
1651
+ });
1652
+ await this.recordEvent(sub.id, orgId, "reactivated", {
1653
+ previousStatus: "suspended",
1654
+ newStatus: "active",
1655
+ paymentIntentId: event.intentId,
1656
+ amount: event.amount,
1657
+ currency: event.currency
1658
+ });
1659
+ this.log.info("Subscription reactivated from suspended", {
1660
+ id: sub.id,
1661
+ organizationId: orgId,
1662
+ planId: sub.planId
1663
+ });
1664
+ await this.alepha.events.emit("subscription:reactivated", {
1665
+ subscriptionId: sub.id,
1666
+ organizationId: orgId,
1667
+ planId: sub.planId
1668
+ });
1669
+ }
1670
+ /**
1671
+ * Handle a failed payment: start or advance dunning.
1672
+ * Updates dunning state and transitions to past_due if needed.
1673
+ */
1674
+ async handlePaymentFailure(sub, event) {
1675
+ const orgId = sub.organizationId;
1676
+ const now = this.dateTime.now();
1677
+ const nowISO = now.toISOString();
1678
+ const schedule = (await this.config.getSettings()).dunningSchedule;
1679
+ let attempt;
1680
+ if (sub.dunningAttempt === 0) {
1681
+ attempt = 1;
1682
+ await this.subscriptionRepo.updateById(sub.id, {
1683
+ dunningStartedAt: nowISO,
1684
+ dunningAttempt: attempt
1685
+ });
1686
+ } else {
1687
+ attempt = sub.dunningAttempt + 1;
1688
+ await this.subscriptionRepo.updateById(sub.id, { dunningAttempt: attempt });
1689
+ }
1690
+ if (sub.status !== "past_due") {
1691
+ await this.subscriptionRepo.updateById(sub.id, { status: "past_due" });
1692
+ await this.recordEvent(sub.id, orgId, "past_due", {
1693
+ previousStatus: sub.status,
1694
+ newStatus: "past_due",
1695
+ paymentIntentId: event.intentId
1696
+ });
1697
+ }
1698
+ const scheduleDays = schedule[attempt - 1];
1699
+ if (scheduleDays !== void 0) {
1700
+ const nextRetry = now.add(scheduleDays, "days").toISOString();
1701
+ await this.subscriptionRepo.updateById(sub.id, { dunningNextRetryAt: nextRetry });
1702
+ } else await this.subscriptionRepo.updateById(sub.id, { dunningNextRetryAt: void 0 });
1703
+ await this.recordEvent(sub.id, orgId, "payment_failed", {
1704
+ paymentIntentId: event.intentId,
1705
+ amount: event.amount,
1706
+ currency: event.currency,
1707
+ note: `Dunning attempt ${attempt}/${schedule.length}`
1708
+ });
1709
+ this.log.warn("Subscription payment failed", {
1710
+ id: sub.id,
1711
+ organizationId: orgId,
1712
+ attempt,
1713
+ maxAttempts: schedule.length
1714
+ });
1715
+ await this.alepha.events.emit("subscription:payment_failed", {
1716
+ subscriptionId: sub.id,
1717
+ organizationId: orgId,
1718
+ planId: sub.planId,
1719
+ attempt
1720
+ });
1721
+ }
1722
+ /**
1723
+ * Compute the end of a billing interval from a start date.
1724
+ */
1725
+ computeIntervalEnd(start, interval) {
1726
+ const startDate = this.dateTime.of(start);
1727
+ const unit = interval === "monthly" ? "months" : "years";
1728
+ return startDate.add(1, unit).toISOString();
1729
+ }
1730
+ /**
1731
+ * Record a subscription event in the event log.
1732
+ */
1733
+ async recordEvent(subscriptionId, organizationId, type, context) {
1734
+ await this.eventRepo.create({
1735
+ subscriptionId,
1736
+ organizationId,
1737
+ type,
1738
+ previousStatus: context?.previousStatus,
1739
+ newStatus: context?.newStatus,
1740
+ previousPlanId: context?.previousPlanId,
1741
+ newPlanId: context?.newPlanId,
1742
+ paymentIntentId: context?.paymentIntentId,
1743
+ amount: context?.amount,
1744
+ currency: context?.currency,
1745
+ triggeredBy: context?.triggeredBy,
1746
+ userId: context?.userId,
1747
+ note: context?.note
1748
+ });
1749
+ }
1750
+ };
1751
+ //#endregion
1752
+ //#region ../../src/api/subscriptions/services/UsageService.ts
1753
+ /**
1754
+ * Tracks and enforces per-organization resource usage limits.
1755
+ *
1756
+ * Usage counters are keyed by `organizationId:resource:YYYY-MM` and stored in the cache.
1757
+ * Limits are resolved from the organization's current subscription plan.
1758
+ */
1759
+ var UsageService = class {
1760
+ cache = $inject(CacheProvider);
1761
+ dateTime = $inject(DateTimeProvider);
1762
+ subscriptionService = $inject(SubscriptionService);
1763
+ /**
1764
+ * Increment a resource counter for the current period and return the usage result.
1765
+ *
1766
+ * @param organizationId The organization to track usage for.
1767
+ * @param resource The resource identifier (e.g., "api_calls", "seats").
1768
+ * @param amount Amount to increment by (default: 1).
1769
+ */
1770
+ async increment(organizationId, resource, amount = 1) {
1771
+ const limit = await this.subscriptionService.limit(organizationId, resource);
1772
+ const key = this.buildKey(organizationId, resource);
1773
+ const current = await this.cache.incr("subscriptions:usage", key, amount);
1774
+ return {
1775
+ allowed: limit === -1 || current <= limit,
1776
+ current,
1777
+ limit,
1778
+ remaining: limit === -1 ? -1 : Math.max(0, limit - current)
1779
+ };
1780
+ }
1781
+ /**
1782
+ * Get the current usage for a resource without incrementing.
1783
+ *
1784
+ * @param organizationId The organization to query usage for.
1785
+ * @param resource The resource identifier.
1786
+ */
1787
+ async getUsage(organizationId, resource) {
1788
+ const limit = await this.subscriptionService.limit(organizationId, resource);
1789
+ const key = this.buildKey(organizationId, resource);
1790
+ const current = await this.cache.incr("subscriptions:usage", key, 0);
1791
+ return {
1792
+ allowed: limit === -1 || current <= limit,
1793
+ current,
1794
+ limit,
1795
+ remaining: limit === -1 ? -1 : Math.max(0, limit - current)
1796
+ };
1797
+ }
1798
+ /**
1799
+ * Reset all usage counters for an organization.
1800
+ *
1801
+ * Used at the start of a new billing period.
1802
+ *
1803
+ * @param organizationId The organization whose counters to reset.
1804
+ */
1805
+ async resetForPeriod(organizationId) {
1806
+ const pattern = this.buildKey(organizationId, "*");
1807
+ await this.cache.invalidateKeys("subscriptions:usage", [pattern]);
1808
+ }
1809
+ /**
1810
+ * Build the cache key for a usage counter.
1811
+ *
1812
+ * Format: `organizationId:resource:YYYY-MM`
1813
+ */
1814
+ buildKey(organizationId, resource) {
1815
+ return `${organizationId}:${resource}:${this.dateTime.now().format("YYYY-MM")}`;
1816
+ }
1817
+ };
1818
+ //#endregion
1819
+ //#region ../../src/api/subscriptions/middleware/$requireLimit.ts
1820
+ /**
1821
+ * Middleware that enforces a per-organization usage limit for a resource.
1822
+ *
1823
+ * Resolves the organization from `args[0].user.organization`, increments the
1824
+ * usage counter for the given resource, and throws `ForbiddenError` if the
1825
+ * plan limit has been reached.
1826
+ * Throws `ForbiddenError` if no organization is present or the limit is exceeded.
1827
+ *
1828
+ * ```typescript
1829
+ * class ApiController {
1830
+ * search = $action({
1831
+ * use: [$requireLimit("api_calls")],
1832
+ * handler: async ({ query }) => { ... },
1833
+ * });
1834
+ * }
1835
+ * ```
1836
+ *
1837
+ * @param resource The resource identifier to track (e.g., "api_calls", "exports").
1838
+ */
1839
+ const $requireLimit = (resource) => {
1840
+ const { alepha } = $context();
1841
+ const usageService = alepha.inject(UsageService);
1842
+ return createMiddleware({
1843
+ name: "$requireLimit",
1844
+ options: { resource },
1845
+ handler: ({ next }) => {
1846
+ return async (...args) => {
1847
+ const user = args[0]?.user;
1848
+ if (!user?.organization) throw new ForbiddenError("Organization required");
1849
+ const result = await usageService.increment(user.organization, resource);
1850
+ if (!result.allowed) throw new ForbiddenError(`Usage limit for '${resource}' has been reached (${result.current}/${result.limit})`);
1851
+ return next(...args);
1852
+ };
1853
+ }
1854
+ });
1855
+ };
1856
+ //#endregion
1857
+ //#region ../../src/api/subscriptions/middleware/$requirePlan.ts
1858
+ /**
1859
+ * Middleware that gates access to a handler behind a subscription feature flag.
1860
+ *
1861
+ * Resolves the organization from `args[0].user.organization` and checks whether
1862
+ * the organization's current plan includes the given feature.
1863
+ * Throws `ForbiddenError` if no organization is present or the feature is not available.
1864
+ *
1865
+ * ```typescript
1866
+ * class ReportController {
1867
+ * generate = $action({
1868
+ * use: [$requirePlan("advanced_reports")],
1869
+ * handler: async ({ user }) => { ... },
1870
+ * });
1871
+ * }
1872
+ * ```
1873
+ *
1874
+ * @param feature The feature identifier to check against the plan's feature list.
1875
+ */
1876
+ const $requirePlan = (feature) => {
1877
+ const { alepha } = $context();
1878
+ const subscriptionService = alepha.inject(SubscriptionService);
1879
+ return createMiddleware({
1880
+ name: "$requirePlan",
1881
+ options: { feature },
1882
+ handler: ({ next }) => {
1883
+ return async (...args) => {
1884
+ const user = args[0]?.user;
1885
+ if (!user?.organization) throw new ForbiddenError("Organization required");
1886
+ if (!await subscriptionService.can(user.organization, feature)) throw new ForbiddenError(`Feature '${feature}' not available on your plan`);
1887
+ return next(...args);
1888
+ };
1889
+ }
1890
+ });
1891
+ };
1892
+ //#endregion
1893
+ //#region ../../src/api/subscriptions/index.ts
1894
+ /**
1895
+ * Subscription management module — plan-based access control, billing integration,
1896
+ * usage limits, and lifecycle events (trial, renewal, cancellation, suspension).
1897
+ *
1898
+ * Depends on `AlephaPayments` for payment processing — register it in your app
1899
+ * alongside this module. Use `SubscriptionConfig` to declare your plans and limits.
1900
+ *
1901
+ * @module alepha.api.subscriptions
1902
+ */
1903
+ const AlephaApiSubscriptions = $module({
1904
+ name: "alepha.api.subscriptions",
1905
+ services: [
1906
+ SubscriptionConfig,
1907
+ SubscriptionService,
1908
+ BillingService,
1909
+ UsageService,
1910
+ SubscriptionJobs,
1911
+ SubscriptionNotifications,
1912
+ SubscriptionController,
1913
+ AdminSubscriptionController
1914
+ ]
1915
+ });
1916
+ //#endregion
1917
+ export { $requireLimit, $requirePlan, AdminSubscriptionController, AlephaApiSubscriptions, BillingService, SubscriptionConfig, SubscriptionController, SubscriptionJobs, SubscriptionNotifications, SubscriptionService, UsageService, cancelSubscriptionSchema, changePlanSchema, createSubscriptionSchema, entitlementsSchema, mrrSchema, planDefinitionSchema, planResourceSchema, subscriptionEventResourceSchema, subscriptionEvents, subscriptionQuerySchema, subscriptionResourceSchema, subscriptionSettingsSchema, subscriptionStatsSchema, subscriptions };
1918
+
1919
+ //# sourceMappingURL=index.js.map