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,867 @@
1
+ import { $inject, Alepha } from "alepha";
2
+ import { DateTimeProvider } from "alepha/datetime";
3
+ import { $logger } from "alepha/logger";
4
+ import { $repository, type Page } from "alepha/orm";
5
+ import { BadRequestError, NotFoundError } from "alepha/server";
6
+ import {
7
+ type SubscriptionEventEntity,
8
+ subscriptionEvents,
9
+ } from "../entities/subscriptionEvents.ts";
10
+ import {
11
+ type SubscriptionEntity,
12
+ subscriptions,
13
+ } from "../entities/subscriptions.ts";
14
+ import type { Entitlements } from "../schemas/entitlementsSchema.ts";
15
+ import type { SubscriptionQuery } from "../schemas/subscriptionQuerySchema.ts";
16
+ import type { SubscriptionStats } from "../schemas/subscriptionStatsSchema.ts";
17
+ import { SubscriptionConfig } from "./SubscriptionConfig.ts";
18
+
19
+ // -----------------------------------------------------------------------------------------------------------------
20
+
21
+ interface SubscribeOptions {
22
+ /**
23
+ * Override plan/global trial days.
24
+ */
25
+ trialDays?: number;
26
+
27
+ /**
28
+ * Go straight to active (requires payment).
29
+ */
30
+ skipTrial?: boolean;
31
+
32
+ /**
33
+ * Metadata to attach to the subscription.
34
+ */
35
+ metadata?: Record<string, unknown>;
36
+ }
37
+
38
+ // -----------------------------------------------------------------------------------------------------------------
39
+
40
+ interface CancelOptions {
41
+ /**
42
+ * Cancellation reason.
43
+ */
44
+ reason?: string;
45
+
46
+ /**
47
+ * Cancel immediately instead of at period end.
48
+ */
49
+ immediate?: boolean;
50
+
51
+ /**
52
+ * User who initiated the cancellation.
53
+ */
54
+ cancelledBy?: string;
55
+ }
56
+
57
+ // -----------------------------------------------------------------------------------------------------------------
58
+
59
+ interface ChangePlanOptions {
60
+ /**
61
+ * Apply now (with proration) or at period end.
62
+ */
63
+ immediate?: boolean;
64
+
65
+ /**
66
+ * Override settings.prorateOnChange.
67
+ */
68
+ prorate?: boolean;
69
+ }
70
+
71
+ // -----------------------------------------------------------------------------------------------------------------
72
+
73
+ interface EventContext {
74
+ previousStatus?: string;
75
+ newStatus?: string;
76
+ previousPlanId?: string;
77
+ newPlanId?: string;
78
+ paymentIntentId?: string;
79
+ amount?: number;
80
+ currency?: string;
81
+ triggeredBy?: string;
82
+ userId?: string;
83
+ note?: string;
84
+ }
85
+
86
+ // -----------------------------------------------------------------------------------------------------------------
87
+
88
+ export class SubscriptionService {
89
+ protected readonly alepha = $inject(Alepha);
90
+ protected readonly log = $logger();
91
+ protected readonly dateTime = $inject(DateTimeProvider);
92
+ protected readonly subscriptionRepo = $repository(subscriptions);
93
+ protected readonly eventRepo = $repository(subscriptionEvents);
94
+ protected readonly config = $inject(SubscriptionConfig);
95
+
96
+ // ---------------------------------------------------------------------------------------------------------------
97
+ // Helpers
98
+ // ---------------------------------------------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Find a subscription by organization ID.
102
+ * Returns null if no subscription exists.
103
+ */
104
+ public async getByOrganization(
105
+ organizationId: string,
106
+ ): Promise<SubscriptionEntity | null> {
107
+ const result = await this.subscriptionRepo.findOne({
108
+ where: { organizationId: { eq: organizationId } },
109
+ });
110
+ return result ?? null;
111
+ }
112
+
113
+ /**
114
+ * Get a subscription by ID. Throws NotFoundError if not found.
115
+ */
116
+ public async getSubscription(id: string): Promise<SubscriptionEntity> {
117
+ return this.subscriptionRepo.getById(id);
118
+ }
119
+
120
+ /**
121
+ * Returns true if the subscription currently grants access.
122
+ * Accessible statuses: trialing, active, past_due (grace period),
123
+ * or cancelled with cancelAtPeriodEnd before period end.
124
+ */
125
+ public isAccessible(sub: SubscriptionEntity): boolean {
126
+ if (
127
+ sub.status === "trialing" ||
128
+ sub.status === "active" ||
129
+ sub.status === "past_due"
130
+ ) {
131
+ return true;
132
+ }
133
+
134
+ if (
135
+ sub.status === "cancelled" &&
136
+ sub.cancelAtPeriodEnd &&
137
+ this.dateTime.now().isBefore(sub.currentPeriodEnd)
138
+ ) {
139
+ return true;
140
+ }
141
+
142
+ return false;
143
+ }
144
+
145
+ /**
146
+ * Record a subscription event in the event log.
147
+ */
148
+ public async recordEvent(
149
+ subscriptionId: string,
150
+ organizationId: string,
151
+ type: SubscriptionEventEntity["type"],
152
+ context?: EventContext,
153
+ ): Promise<void> {
154
+ await this.eventRepo.create({
155
+ subscriptionId,
156
+ organizationId,
157
+ type,
158
+ previousStatus: context?.previousStatus,
159
+ newStatus: context?.newStatus,
160
+ previousPlanId: context?.previousPlanId,
161
+ newPlanId: context?.newPlanId,
162
+ paymentIntentId: context?.paymentIntentId,
163
+ amount: context?.amount,
164
+ currency: context?.currency,
165
+ triggeredBy: context?.triggeredBy,
166
+ userId: context?.userId,
167
+ note: context?.note,
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Compute the end of a billing interval from a start date.
173
+ */
174
+ public computeIntervalEnd(
175
+ start: string,
176
+ interval: "monthly" | "yearly",
177
+ ): string {
178
+ const startDate = this.dateTime.of(start);
179
+ const unit = interval === "monthly" ? "months" : "years";
180
+ return startDate.add(1, unit).toISOString();
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------------------------------------------
184
+ // Lifecycle
185
+ // ---------------------------------------------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Create a new subscription for an organization.
189
+ */
190
+ public async subscribe(
191
+ organizationId: string,
192
+ planId: string,
193
+ interval: "monthly" | "yearly",
194
+ options?: SubscribeOptions,
195
+ ): Promise<SubscriptionEntity> {
196
+ const plan = await this.config.getPlan(planId);
197
+
198
+ if (!plan.available) {
199
+ throw new BadRequestError(
200
+ `Plan '${planId}' is not available for new subscriptions`,
201
+ );
202
+ }
203
+
204
+ await this.config.getPlanPricing(planId, interval);
205
+
206
+ const existing = await this.subscriptionRepo.findOne({
207
+ where: {
208
+ organizationId: { eq: organizationId },
209
+ status: { inArray: ["trialing", "active", "past_due"] },
210
+ },
211
+ });
212
+
213
+ if (existing) {
214
+ throw new BadRequestError(
215
+ "Organization already has an active subscription",
216
+ );
217
+ }
218
+
219
+ const settings = await this.config.getSettings();
220
+ const trialDays =
221
+ options?.trialDays ?? plan.trial?.days ?? settings.trialDays;
222
+ const skipTrial = options?.skipTrial ?? false;
223
+ const now = this.dateTime.now();
224
+ const nowISO = now.toISOString();
225
+
226
+ if (trialDays > 0 && !skipTrial) {
227
+ const trialEnd = now.add(trialDays, "days").toISOString();
228
+
229
+ const entity = await this.subscriptionRepo.create({
230
+ organizationId,
231
+ planId,
232
+ interval,
233
+ status: "trialing",
234
+ currentPeriodStart: nowISO,
235
+ currentPeriodEnd: trialEnd,
236
+ trialStart: nowISO,
237
+ trialEnd,
238
+ nextBillingAt: trialEnd,
239
+ cancelAtPeriodEnd: false,
240
+ dunningAttempt: 0,
241
+ metadata: options?.metadata as any,
242
+ });
243
+
244
+ await this.recordEvent(entity.id, organizationId, "created", {
245
+ newStatus: "trialing",
246
+ });
247
+
248
+ await this.recordEvent(entity.id, organizationId, "trial_started", {
249
+ newStatus: "trialing",
250
+ });
251
+
252
+ this.log.info("Subscription created with trial", {
253
+ id: entity.id,
254
+ organizationId,
255
+ planId,
256
+ trialDays,
257
+ });
258
+
259
+ await this.alepha.events.emit("subscription:created" as any, {
260
+ subscription: entity,
261
+ });
262
+
263
+ return entity;
264
+ }
265
+
266
+ const periodEnd = this.computeIntervalEnd(nowISO, interval);
267
+
268
+ const entity = await this.subscriptionRepo.create({
269
+ organizationId,
270
+ planId,
271
+ interval,
272
+ status: "active",
273
+ currentPeriodStart: nowISO,
274
+ currentPeriodEnd: periodEnd,
275
+ nextBillingAt: periodEnd,
276
+ cancelAtPeriodEnd: false,
277
+ dunningAttempt: 0,
278
+ metadata: options?.metadata as any,
279
+ });
280
+
281
+ await this.recordEvent(entity.id, organizationId, "created", {
282
+ newStatus: "active",
283
+ });
284
+
285
+ this.log.info("Subscription created", {
286
+ id: entity.id,
287
+ organizationId,
288
+ planId,
289
+ });
290
+
291
+ await this.alepha.events.emit("subscription:created" as any, {
292
+ subscription: entity,
293
+ });
294
+
295
+ return entity;
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------------------------------------------
299
+
300
+ /**
301
+ * Cancel a subscription.
302
+ * If immediate, the subscription expires right away.
303
+ * If at period end, the subscription remains accessible until the period ends.
304
+ */
305
+ public async cancel(
306
+ subscriptionId: string,
307
+ options?: CancelOptions,
308
+ ): Promise<void> {
309
+ const sub = await this.subscriptionRepo.getById(subscriptionId);
310
+ const orgId = sub.organizationId as string;
311
+
312
+ if (
313
+ sub.status !== "trialing" &&
314
+ sub.status !== "active" &&
315
+ sub.status !== "past_due"
316
+ ) {
317
+ throw new BadRequestError(
318
+ `Cannot cancel subscription with status '${sub.status}'`,
319
+ );
320
+ }
321
+
322
+ const settings = await this.config.getSettings();
323
+ const immediate = options?.immediate ?? !settings.cancelAtPeriodEnd;
324
+ const now = this.dateTime.now();
325
+ const nowISO = now.toISOString();
326
+ const previousStatus = sub.status;
327
+
328
+ if (immediate) {
329
+ await this.subscriptionRepo.updateById(subscriptionId, {
330
+ status: "expired",
331
+ cancelledAt: nowISO,
332
+ cancelReason: options?.reason,
333
+ cancelAtPeriodEnd: false,
334
+ });
335
+
336
+ await this.recordEvent(subscriptionId, orgId, "cancelled", {
337
+ previousStatus,
338
+ newStatus: "expired",
339
+ triggeredBy: options?.cancelledBy ? "user" : "system",
340
+ userId: options?.cancelledBy,
341
+ note: options?.reason,
342
+ });
343
+
344
+ this.log.info("Subscription cancelled immediately", {
345
+ id: subscriptionId,
346
+ organizationId: orgId,
347
+ });
348
+ } else {
349
+ await this.subscriptionRepo.updateById(subscriptionId, {
350
+ status: "cancelled",
351
+ cancelledAt: nowISO,
352
+ cancelReason: options?.reason,
353
+ cancelAtPeriodEnd: true,
354
+ });
355
+
356
+ await this.recordEvent(subscriptionId, orgId, "cancelled", {
357
+ previousStatus,
358
+ newStatus: "cancelled",
359
+ triggeredBy: options?.cancelledBy ? "user" : "system",
360
+ userId: options?.cancelledBy,
361
+ note: options?.reason,
362
+ });
363
+
364
+ this.log.info("Subscription cancelled at period end", {
365
+ id: subscriptionId,
366
+ organizationId: orgId,
367
+ periodEnd: sub.currentPeriodEnd,
368
+ });
369
+ }
370
+
371
+ await this.alepha.events.emit("subscription:cancelled" as any, {
372
+ subscription: sub,
373
+ immediate,
374
+ reason: options?.reason,
375
+ });
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------------------------------------------
379
+
380
+ /**
381
+ * Resume a cancelled subscription before its period ends.
382
+ * Only valid for subscriptions cancelled with cancelAtPeriodEnd.
383
+ */
384
+ public async resume(subscriptionId: string): Promise<void> {
385
+ const sub = await this.subscriptionRepo.getById(subscriptionId);
386
+ const orgId = sub.organizationId as string;
387
+
388
+ if (sub.status !== "cancelled") {
389
+ throw new BadRequestError(
390
+ `Cannot resume subscription with status '${sub.status}', must be 'cancelled'`,
391
+ );
392
+ }
393
+
394
+ if (!sub.cancelAtPeriodEnd) {
395
+ throw new BadRequestError(
396
+ "Cannot resume a subscription that was not cancelled at period end",
397
+ );
398
+ }
399
+
400
+ if (!this.dateTime.now().isBefore(sub.currentPeriodEnd)) {
401
+ throw new BadRequestError(
402
+ "Cannot resume subscription, period has already ended",
403
+ );
404
+ }
405
+
406
+ await this.subscriptionRepo.updateById(subscriptionId, {
407
+ status: "active",
408
+ cancelledAt: undefined,
409
+ cancelReason: undefined,
410
+ cancelAtPeriodEnd: false,
411
+ });
412
+
413
+ await this.recordEvent(subscriptionId, orgId, "resumed", {
414
+ previousStatus: "cancelled",
415
+ newStatus: "active",
416
+ });
417
+
418
+ this.log.info("Subscription resumed", {
419
+ id: subscriptionId,
420
+ organizationId: orgId,
421
+ });
422
+
423
+ await this.alepha.events.emit("subscription:resumed" as any, {
424
+ subscription: sub,
425
+ });
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------------------------------------------
429
+
430
+ /**
431
+ * Change the plan of a subscription.
432
+ * If immediate, proration is calculated and the plan changes now.
433
+ * If at period end, the change is scheduled for the next renewal.
434
+ * Returns the net proration amount (positive = charge, negative = credit).
435
+ */
436
+ public async changePlan(
437
+ subscriptionId: string,
438
+ newPlanId: string,
439
+ newInterval?: "monthly" | "yearly",
440
+ options?: ChangePlanOptions,
441
+ ): Promise<number> {
442
+ const sub = await this.subscriptionRepo.getById(subscriptionId);
443
+ const orgId = sub.organizationId as string;
444
+
445
+ if (sub.status !== "active" && sub.status !== "trialing") {
446
+ throw new BadRequestError(
447
+ `Cannot change plan for subscription with status '${sub.status}'`,
448
+ );
449
+ }
450
+
451
+ const newPlan = await this.config.getPlan(newPlanId);
452
+
453
+ if (!newPlan.available) {
454
+ throw new BadRequestError(
455
+ `Plan '${newPlanId}' is not available for new subscriptions`,
456
+ );
457
+ }
458
+
459
+ const effectiveInterval = newInterval ?? sub.interval;
460
+ await this.config.getPlanPricing(newPlanId, effectiveInterval);
461
+
462
+ const settings = await this.config.getSettings();
463
+ const immediate = options?.immediate ?? true;
464
+
465
+ if (!immediate) {
466
+ await this.subscriptionRepo.updateById(subscriptionId, {
467
+ pendingPlanId: newPlanId,
468
+ pendingInterval: effectiveInterval,
469
+ });
470
+
471
+ await this.recordEvent(subscriptionId, orgId, "plan_change_scheduled", {
472
+ previousPlanId: sub.planId,
473
+ newPlanId,
474
+ note: `Scheduled change to '${newPlanId}' (${effectiveInterval}) at period end`,
475
+ });
476
+
477
+ this.log.info("Plan change scheduled for period end", {
478
+ id: subscriptionId,
479
+ organizationId: orgId,
480
+ newPlanId,
481
+ newInterval: effectiveInterval,
482
+ });
483
+
484
+ await this.alepha.events.emit("subscription:plan_changed" as any, {
485
+ subscription: sub,
486
+ previousPlanId: sub.planId,
487
+ newPlanId,
488
+ immediate: false,
489
+ });
490
+
491
+ return 0;
492
+ }
493
+
494
+ const shouldProrate = options?.prorate ?? settings.prorateOnChange;
495
+ let netAmount = 0;
496
+
497
+ if (shouldProrate && sub.status === "active") {
498
+ netAmount = await this.calculateProration(
499
+ sub,
500
+ newPlanId,
501
+ effectiveInterval,
502
+ );
503
+ }
504
+
505
+ const previousPlanId = sub.planId;
506
+
507
+ await this.subscriptionRepo.updateById(subscriptionId, {
508
+ planId: newPlanId,
509
+ interval: effectiveInterval,
510
+ pendingPlanId: undefined,
511
+ pendingInterval: undefined,
512
+ metadata:
513
+ netAmount < 0
514
+ ? { ...sub.metadata, credit: Math.abs(netAmount) }
515
+ : sub.metadata,
516
+ });
517
+
518
+ await this.recordEvent(subscriptionId, orgId, "plan_changed", {
519
+ previousPlanId,
520
+ newPlanId,
521
+ amount: netAmount !== 0 ? Math.abs(netAmount) : undefined,
522
+ note:
523
+ netAmount > 0
524
+ ? `Proration charge: ${netAmount}`
525
+ : netAmount < 0
526
+ ? `Proration credit: ${Math.abs(netAmount)}`
527
+ : undefined,
528
+ });
529
+
530
+ this.log.info("Plan changed immediately", {
531
+ id: subscriptionId,
532
+ organizationId: orgId,
533
+ previousPlanId,
534
+ newPlanId,
535
+ netAmount,
536
+ });
537
+
538
+ await this.alepha.events.emit("subscription:plan_changed" as any, {
539
+ subscription: sub,
540
+ previousPlanId,
541
+ newPlanId,
542
+ immediate: true,
543
+ netAmount,
544
+ });
545
+
546
+ return netAmount;
547
+ }
548
+
549
+ // ---------------------------------------------------------------------------------------------------------------
550
+
551
+ /**
552
+ * Reactivate a suspended subscription (admin action).
553
+ * Resets dunning state and starts a new billing period.
554
+ */
555
+ public async reactivate(subscriptionId: string): Promise<void> {
556
+ const sub = await this.subscriptionRepo.getById(subscriptionId);
557
+ const orgId = sub.organizationId as string;
558
+
559
+ if (sub.status !== "suspended") {
560
+ throw new BadRequestError(
561
+ `Cannot reactivate subscription with status '${sub.status}', must be 'suspended'`,
562
+ );
563
+ }
564
+
565
+ const now = this.dateTime.now();
566
+ const nowISO = now.toISOString();
567
+ const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
568
+
569
+ await this.subscriptionRepo.updateById(subscriptionId, {
570
+ status: "active",
571
+ currentPeriodStart: nowISO,
572
+ currentPeriodEnd: periodEnd,
573
+ nextBillingAt: periodEnd,
574
+ dunningStartedAt: undefined,
575
+ dunningAttempt: 0,
576
+ dunningNextRetryAt: undefined,
577
+ });
578
+
579
+ await this.recordEvent(subscriptionId, orgId, "reactivated", {
580
+ previousStatus: "suspended",
581
+ newStatus: "active",
582
+ });
583
+
584
+ this.log.info("Subscription reactivated", {
585
+ id: subscriptionId,
586
+ organizationId: orgId,
587
+ });
588
+
589
+ await this.alepha.events.emit("subscription:reactivated" as any, {
590
+ subscription: sub,
591
+ });
592
+ }
593
+
594
+ // ---------------------------------------------------------------------------------------------------------------
595
+
596
+ /**
597
+ * Extend the trial period of a trialing subscription.
598
+ */
599
+ public async extendTrial(
600
+ subscriptionId: string,
601
+ days: number,
602
+ ): Promise<void> {
603
+ const sub = await this.subscriptionRepo.getById(subscriptionId);
604
+
605
+ if (sub.status !== "trialing") {
606
+ throw new BadRequestError(
607
+ `Cannot extend trial for subscription with status '${sub.status}', must be 'trialing'`,
608
+ );
609
+ }
610
+
611
+ if (!sub.trialEnd) {
612
+ throw new BadRequestError("Subscription has no trial end date set");
613
+ }
614
+
615
+ const currentTrialEnd = this.dateTime.of(sub.trialEnd);
616
+ const newTrialEnd = currentTrialEnd.add(days, "days").toISOString();
617
+
618
+ await this.subscriptionRepo.updateById(subscriptionId, {
619
+ trialEnd: newTrialEnd,
620
+ currentPeriodEnd: newTrialEnd,
621
+ nextBillingAt: newTrialEnd,
622
+ });
623
+
624
+ this.log.info("Trial extended", {
625
+ id: subscriptionId,
626
+ organizationId: sub.organizationId as string,
627
+ days,
628
+ newTrialEnd,
629
+ });
630
+ }
631
+
632
+ // ---------------------------------------------------------------------------------------------------------------
633
+ // Entitlements
634
+ // ---------------------------------------------------------------------------------------------------------------
635
+
636
+ /**
637
+ * Check if an organization has access to a specific feature.
638
+ */
639
+ public async can(organizationId: string, feature: string): Promise<boolean> {
640
+ const sub = await this.getByOrganization(organizationId);
641
+ if (!sub || !this.isAccessible(sub)) return false;
642
+ const plan = await this.config.getPlan(sub.planId);
643
+ return plan.features.includes(feature);
644
+ }
645
+
646
+ /**
647
+ * Get the usage limit for a resource.
648
+ * Returns -1 for unlimited, 0 for no access.
649
+ */
650
+ public async limit(
651
+ organizationId: string,
652
+ resource: string,
653
+ ): Promise<number> {
654
+ const sub = await this.getByOrganization(organizationId);
655
+ if (!sub || !this.isAccessible(sub)) return 0;
656
+ const plan = await this.config.getPlan(sub.planId);
657
+ return plan.limits[resource] ?? 0;
658
+ }
659
+
660
+ /**
661
+ * Get the full entitlements snapshot for an organization.
662
+ */
663
+ public async getEntitlements(organizationId: string): Promise<Entitlements> {
664
+ const sub = await this.getByOrganization(organizationId);
665
+
666
+ if (!sub) {
667
+ throw new NotFoundError(
668
+ `No subscription found for organization '${organizationId}'`,
669
+ );
670
+ }
671
+
672
+ const plan = await this.config.getPlan(sub.planId);
673
+
674
+ return {
675
+ planId: plan.id,
676
+ planName: plan.name,
677
+ status: sub.status,
678
+ features: plan.features,
679
+ limits: plan.limits,
680
+ trialEndsAt: sub.trialEnd,
681
+ periodEndsAt: sub.currentPeriodEnd,
682
+ cancelledAt: sub.cancelledAt,
683
+ };
684
+ }
685
+
686
+ // ---------------------------------------------------------------------------------------------------------------
687
+ // Queries
688
+ // ---------------------------------------------------------------------------------------------------------------
689
+
690
+ /**
691
+ * Find subscriptions with pagination and filtering.
692
+ */
693
+ public async findSubscriptions(
694
+ query: SubscriptionQuery = {},
695
+ ): Promise<Page<SubscriptionEntity>> {
696
+ query.sort ??= "-createdAt";
697
+
698
+ const where = this.subscriptionRepo.createQueryWhere();
699
+
700
+ if (query.status) {
701
+ where.status = { eq: query.status };
702
+ }
703
+
704
+ if (query.planId) {
705
+ where.planId = { eq: query.planId };
706
+ }
707
+
708
+ if (query.organizationId) {
709
+ where.organizationId = { eq: query.organizationId };
710
+ }
711
+
712
+ return this.subscriptionRepo.paginate(query, { where }, { count: true });
713
+ }
714
+
715
+ // ---------------------------------------------------------------------------------------------------------------
716
+
717
+ /**
718
+ * Get the event history for a subscription, ordered by most recent first.
719
+ */
720
+ public async getHistory(
721
+ subscriptionId: string,
722
+ ): Promise<SubscriptionEventEntity[]> {
723
+ return this.eventRepo.findMany({
724
+ where: { subscriptionId: { eq: subscriptionId } },
725
+ orderBy: { column: "createdAt", direction: "desc" },
726
+ });
727
+ }
728
+
729
+ // ---------------------------------------------------------------------------------------------------------------
730
+
731
+ /**
732
+ * Get aggregated subscription statistics.
733
+ */
734
+ public async getStats(): Promise<SubscriptionStats> {
735
+ const [trialing, active, pastDue, suspended, cancelled, expired] =
736
+ await Promise.all([
737
+ this.subscriptionRepo.count({ status: { eq: "trialing" } }),
738
+ this.subscriptionRepo.count({ status: { eq: "active" } }),
739
+ this.subscriptionRepo.count({ status: { eq: "past_due" } }),
740
+ this.subscriptionRepo.count({ status: { eq: "suspended" } }),
741
+ this.subscriptionRepo.count({ status: { eq: "cancelled" } }),
742
+ this.subscriptionRepo.count({ status: { eq: "expired" } }),
743
+ ]);
744
+
745
+ const total = trialing + active + pastDue + suspended + cancelled + expired;
746
+
747
+ const trialEndedEvents = await this.eventRepo.count({
748
+ type: { eq: "trial_ended" },
749
+ });
750
+ const activatedEvents = await this.eventRepo.count({
751
+ type: { eq: "activated" },
752
+ });
753
+ const trialConversionRate =
754
+ trialEndedEvents > 0 ? activatedEvents / trialEndedEvents : 0;
755
+
756
+ const cancelledEvents = await this.eventRepo.count({
757
+ type: { eq: "cancelled" },
758
+ });
759
+ const totalSubscribed = active + trialing + pastDue;
760
+ const churnRate =
761
+ totalSubscribed + cancelledEvents > 0
762
+ ? cancelledEvents / (totalSubscribed + cancelledEvents)
763
+ : 0;
764
+
765
+ const plans = await this.config.getPlans();
766
+ const byPlan: Record<
767
+ string,
768
+ { active: number; trialing: number; total: number }
769
+ > = {};
770
+
771
+ for (const plan of plans) {
772
+ const [planActive, planTrialing] = await Promise.all([
773
+ this.subscriptionRepo.count({
774
+ planId: { eq: plan.id },
775
+ status: { eq: "active" },
776
+ }),
777
+ this.subscriptionRepo.count({
778
+ planId: { eq: plan.id },
779
+ status: { eq: "trialing" },
780
+ }),
781
+ ]);
782
+
783
+ byPlan[plan.id] = {
784
+ active: planActive,
785
+ trialing: planTrialing,
786
+ total: planActive + planTrialing,
787
+ };
788
+ }
789
+
790
+ return {
791
+ total,
792
+ trialing,
793
+ active,
794
+ pastDue,
795
+ suspended,
796
+ cancelled,
797
+ expired,
798
+ trialConversionRate,
799
+ churnRate,
800
+ byPlan,
801
+ };
802
+ }
803
+
804
+ // ---------------------------------------------------------------------------------------------------------------
805
+
806
+ /**
807
+ * Get revenue data from recent subscription events.
808
+ * Sums amounts from renewed and activated events within the specified window.
809
+ */
810
+ public async getRevenue(
811
+ days = 30,
812
+ ): Promise<{ total: number; count: number }> {
813
+ const cutoff = this.dateTime.now().subtract(days, "days").toISOString();
814
+
815
+ const events = await this.eventRepo.findMany({
816
+ where: {
817
+ type: { inArray: ["renewed", "activated"] },
818
+ createdAt: { gt: cutoff },
819
+ },
820
+ });
821
+
822
+ let total = 0;
823
+ for (const event of events) {
824
+ total += event.amount ?? 0;
825
+ }
826
+
827
+ return { total, count: events.length };
828
+ }
829
+
830
+ // ---------------------------------------------------------------------------------------------------------------
831
+ // Protected helpers
832
+ // ---------------------------------------------------------------------------------------------------------------
833
+
834
+ /**
835
+ * Calculate proration for a mid-cycle plan change.
836
+ * Returns the net amount: positive = charge, negative = credit.
837
+ */
838
+ protected async calculateProration(
839
+ sub: SubscriptionEntity,
840
+ newPlanId: string,
841
+ newInterval: "monthly" | "yearly",
842
+ ): Promise<number> {
843
+ const oldPricing = await this.config.getPlanPricing(
844
+ sub.planId,
845
+ sub.interval,
846
+ );
847
+ const newPricing = await this.config.getPlanPricing(newPlanId, newInterval);
848
+
849
+ const now = this.dateTime.now();
850
+ const periodStart = this.dateTime.of(sub.currentPeriodStart);
851
+ const periodEnd = this.dateTime.of(sub.currentPeriodEnd);
852
+
853
+ const daysInPeriod = periodEnd.diff(periodStart, "days");
854
+ if (daysInPeriod <= 0) return 0;
855
+
856
+ const daysUsed = now.diff(periodStart, "days");
857
+ const daysRemaining = daysInPeriod - daysUsed;
858
+
859
+ const oldDailyRate = oldPricing.amount / daysInPeriod;
860
+ const newDailyRate = newPricing.amount / daysInPeriod;
861
+
862
+ const credit = Math.round(daysRemaining * oldDailyRate);
863
+ const charge = Math.round(daysRemaining * newDailyRate);
864
+
865
+ return charge - credit;
866
+ }
867
+ }