alepha 0.19.2 → 0.19.4

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