alepha 0.20.6 → 0.20.8

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 (243) 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 +370 -355
  7. package/dist/api/audits/index.d.ts.map +1 -1
  8. package/dist/api/audits/index.js +1 -0
  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 +179 -170
  13. package/dist/api/files/index.d.ts.map +1 -1
  14. package/dist/api/files/index.js +1 -0
  15. package/dist/api/files/index.js.map +1 -1
  16. package/dist/api/jobs/index.browser.js +7 -0
  17. package/dist/api/jobs/index.browser.js.map +1 -1
  18. package/dist/api/jobs/index.d.ts +259 -250
  19. package/dist/api/jobs/index.d.ts.map +1 -1
  20. package/dist/api/jobs/index.js +21 -3
  21. package/dist/api/jobs/index.js.map +1 -1
  22. package/dist/api/keys/index.d.ts +198 -192
  23. package/dist/api/keys/index.d.ts.map +1 -1
  24. package/dist/api/keys/index.js +1 -0
  25. package/dist/api/keys/index.js.map +1 -1
  26. package/dist/api/notifications/index.d.ts +246 -245
  27. package/dist/api/notifications/index.d.ts.map +1 -1
  28. package/dist/api/organizations/index.d.ts +100 -97
  29. package/dist/api/organizations/index.d.ts.map +1 -1
  30. package/dist/api/parameters/index.d.ts +323 -320
  31. package/dist/api/parameters/index.d.ts.map +1 -1
  32. package/dist/api/payments/index.d.ts +431 -376
  33. package/dist/api/payments/index.d.ts.map +1 -1
  34. package/dist/api/payments/index.js +202 -87
  35. package/dist/api/payments/index.js.map +1 -1
  36. package/dist/api/subscriptions/index.d.ts +1695 -0
  37. package/dist/api/subscriptions/index.d.ts.map +1 -0
  38. package/dist/api/subscriptions/index.js +1919 -0
  39. package/dist/api/subscriptions/index.js.map +1 -0
  40. package/dist/api/users/index.d.ts +857 -841
  41. package/dist/api/users/index.d.ts.map +1 -1
  42. package/dist/api/verifications/index.d.ts +128 -127
  43. package/dist/api/verifications/index.d.ts.map +1 -1
  44. package/dist/bucket/index.d.ts +3 -2
  45. package/dist/bucket/index.d.ts.map +1 -1
  46. package/dist/cache/core/index.d.ts +114 -4
  47. package/dist/cache/core/index.d.ts.map +1 -1
  48. package/dist/cache/core/index.js +181 -15
  49. package/dist/cache/core/index.js.map +1 -1
  50. package/dist/cache/core/index.workerd.js +181 -15
  51. package/dist/cache/core/index.workerd.js.map +1 -1
  52. package/dist/cache/database/index.d.ts +20 -19
  53. package/dist/cache/database/index.d.ts.map +1 -1
  54. package/dist/cache/redis/index.d.ts +3 -2
  55. package/dist/cache/redis/index.d.ts.map +1 -1
  56. package/dist/cli/core/index.d.ts +116 -132
  57. package/dist/cli/core/index.d.ts.map +1 -1
  58. package/dist/cli/core/index.js +75 -7
  59. package/dist/cli/core/index.js.map +1 -1
  60. package/dist/cli/devtools/index.d.ts +3 -2
  61. package/dist/cli/devtools/index.d.ts.map +1 -1
  62. package/dist/cli/platform/index.d.ts +346 -290
  63. package/dist/cli/platform/index.d.ts.map +1 -1
  64. package/dist/cli/platform/index.js +105 -6
  65. package/dist/cli/platform/index.js.map +1 -1
  66. package/dist/cli/vendor/index.d.ts +12 -11
  67. package/dist/cli/vendor/index.d.ts.map +1 -1
  68. package/dist/command/index.d.ts +5 -4
  69. package/dist/command/index.d.ts.map +1 -1
  70. package/dist/core/index.browser.js +1 -1
  71. package/dist/core/index.browser.js.map +1 -1
  72. package/dist/core/index.d.ts +119 -118
  73. package/dist/core/index.d.ts.map +1 -1
  74. package/dist/core/index.js +1 -1
  75. package/dist/core/index.js.map +1 -1
  76. package/dist/core/index.native.js +1 -1
  77. package/dist/core/index.native.js.map +1 -1
  78. package/dist/core/index.workerd.js +1 -1
  79. package/dist/core/index.workerd.js.map +1 -1
  80. package/dist/crypto/index.d.ts +3 -2
  81. package/dist/crypto/index.d.ts.map +1 -1
  82. package/dist/email/core/index.d.ts +3 -2
  83. package/dist/email/core/index.d.ts.map +1 -1
  84. package/dist/email/smtp/index.d.ts +7 -6
  85. package/dist/email/smtp/index.d.ts.map +1 -1
  86. package/dist/lock/core/index.d.ts +5 -4
  87. package/dist/lock/core/index.d.ts.map +1 -1
  88. package/dist/logger/index.d.ts +10 -9
  89. package/dist/logger/index.d.ts.map +1 -1
  90. package/dist/mcp/index.d.ts +9 -8
  91. package/dist/mcp/index.d.ts.map +1 -1
  92. package/dist/mcp/index.js +1 -1
  93. package/dist/mcp/index.js.map +1 -1
  94. package/dist/orm/core/index.browser.js +9 -3
  95. package/dist/orm/core/index.browser.js.map +1 -1
  96. package/dist/orm/core/index.bun.js +31 -10
  97. package/dist/orm/core/index.bun.js.map +1 -1
  98. package/dist/orm/core/index.d.ts +33 -14
  99. package/dist/orm/core/index.d.ts.map +1 -1
  100. package/dist/orm/core/index.js +31 -10
  101. package/dist/orm/core/index.js.map +1 -1
  102. package/dist/orm/postgres/index.d.ts +6 -5
  103. package/dist/orm/postgres/index.d.ts.map +1 -1
  104. package/dist/queue/core/index.d.ts +5 -4
  105. package/dist/queue/core/index.d.ts.map +1 -1
  106. package/dist/queue/redis/index.d.ts +3 -2
  107. package/dist/queue/redis/index.d.ts.map +1 -1
  108. package/dist/react/form/index.d.ts +5 -0
  109. package/dist/react/form/index.d.ts.map +1 -1
  110. package/dist/react/form/index.js +6 -4
  111. package/dist/react/form/index.js.map +1 -1
  112. package/dist/react/i18n/index.d.ts +2 -1
  113. package/dist/react/i18n/index.d.ts.map +1 -1
  114. package/dist/react/router/index.d.ts +206 -205
  115. package/dist/react/router/index.d.ts.map +1 -1
  116. package/dist/react/ui/index.d.ts +11 -11
  117. package/dist/react/ui/index.d.ts.map +1 -1
  118. package/dist/scheduler/index.d.ts +3 -2
  119. package/dist/scheduler/index.d.ts.map +1 -1
  120. package/dist/security/index.browser.js +29 -1
  121. package/dist/security/index.browser.js.map +1 -1
  122. package/dist/security/index.d.ts +82 -35
  123. package/dist/security/index.d.ts.map +1 -1
  124. package/dist/security/index.js +56 -3
  125. package/dist/security/index.js.map +1 -1
  126. package/dist/server/auth/index.d.ts +163 -158
  127. package/dist/server/auth/index.d.ts.map +1 -1
  128. package/dist/server/auth/index.js +16 -4
  129. package/dist/server/auth/index.js.map +1 -1
  130. package/dist/server/core/index.d.ts +35 -34
  131. package/dist/server/core/index.d.ts.map +1 -1
  132. package/dist/server/cors/index.d.ts +7 -6
  133. package/dist/server/cors/index.d.ts.map +1 -1
  134. package/dist/server/health/index.d.ts +16 -15
  135. package/dist/server/health/index.d.ts.map +1 -1
  136. package/dist/server/links/index.d.ts +51 -50
  137. package/dist/server/links/index.d.ts.map +1 -1
  138. package/dist/server/rate-limit/index.d.ts +6 -5
  139. package/dist/server/rate-limit/index.d.ts.map +1 -1
  140. package/dist/server/swagger/index.d.ts +2 -1
  141. package/dist/server/swagger/index.d.ts.map +1 -1
  142. package/dist/topic/redis/index.d.ts +3 -2
  143. package/dist/topic/redis/index.d.ts.map +1 -1
  144. package/package.json +16 -32
  145. package/src/api/audits/entities/audits.ts +1 -0
  146. package/src/api/files/entities/files.ts +1 -0
  147. package/src/api/jobs/__tests__/$job.spec.ts +92 -40
  148. package/src/api/jobs/entities/jobExecutionEntity.ts +1 -0
  149. package/src/api/jobs/providers/JobProvider.ts +20 -5
  150. package/src/api/jobs/schemas/jobConfigAtom.ts +5 -0
  151. package/src/api/keys/entities/apiKeyEntity.ts +1 -0
  152. package/src/api/payments/controllers/MockCheckoutController.ts +146 -0
  153. package/src/api/payments/index.ts +3 -0
  154. package/src/api/payments/providers/MemoryPaymentProvider.ts +9 -4
  155. package/src/api/payments/providers/PaymentProvider.ts +25 -9
  156. package/src/api/payments/services/PaymentService.ts +3 -0
  157. package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
  158. package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
  159. package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
  160. package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
  161. package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
  162. package/src/api/subscriptions/entities/subscriptions.ts +68 -0
  163. package/src/api/subscriptions/index.ts +133 -0
  164. package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
  165. package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
  166. package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
  167. package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
  168. package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
  169. package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
  170. package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
  171. package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
  172. package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
  173. package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
  174. package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
  175. package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
  176. package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
  177. package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
  178. package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
  179. package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
  180. package/src/api/subscriptions/services/BillingService.ts +437 -0
  181. package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
  182. package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
  183. package/src/api/subscriptions/services/UsageService.ts +118 -0
  184. package/src/cache/core/__tests__/$cache.memory.spec.ts +450 -0
  185. package/src/cache/core/__tests__/$cache.swr.spec.ts +394 -0
  186. package/src/cache/core/index.ts +16 -0
  187. package/src/cache/core/primitives/$cache.ts +347 -21
  188. package/src/cli/core/tasks/BuildCloudflareTask.ts +16 -0
  189. package/src/cli/core/templates/agentMd.ts +39 -4
  190. package/src/cli/core/templates/biomeJson.ts +25 -1
  191. package/src/cli/core/templates/saasAdminLayoutTsx.ts +2 -2
  192. package/src/cli/platform/__tests__/CloudflareAdapter.spec.ts +117 -0
  193. package/src/cli/platform/adapters/CloudflareAdapter.ts +104 -7
  194. package/src/cli/platform/atoms/platformOptions.ts +13 -0
  195. package/src/cli/platform/schemas/platform.ts +1 -0
  196. package/src/cli/platform/services/CloudflareApi.ts +61 -0
  197. package/src/cli/platform/services/PlatformOrchestrator.ts +9 -4
  198. package/src/core/__tests__/$module.spec.ts +2 -2
  199. package/src/core/primitives/$module.ts +4 -4
  200. package/src/mcp/providers/McpServerProvider.ts +1 -1
  201. package/src/orm/core/providers/DatabaseTypeProvider.ts +9 -3
  202. package/src/orm/core/providers/drivers/DatabaseProvider.ts +1 -1
  203. package/src/orm/core/schemas/insertSchema.ts +10 -2
  204. package/src/orm/core/services/Repository.ts +27 -7
  205. package/src/react/form/hooks/useFormState.ts +8 -1
  206. package/src/react/form/index.ts +10 -1
  207. package/src/react/form/services/FormModel.ts +9 -3
  208. package/src/security/atoms/currentTenantAtom.ts +34 -0
  209. package/src/security/index.browser.ts +1 -0
  210. package/src/security/index.ts +12 -1
  211. package/src/security/primitives/$issuer.ts +17 -1
  212. package/src/security/providers/SecurityProvider.ts +37 -0
  213. package/src/server/auth/__tests__/validateRedirectUri.spec.ts +78 -0
  214. package/src/server/auth/providers/ServerAuthProvider.ts +21 -5
  215. package/tsconfig.base.json +2 -1
  216. package/dist/react/websocket/index.d.ts +0 -117
  217. package/dist/react/websocket/index.d.ts.map +0 -1
  218. package/dist/react/websocket/index.js +0 -108
  219. package/dist/react/websocket/index.js.map +0 -1
  220. package/dist/websocket/index.browser.js +0 -848
  221. package/dist/websocket/index.browser.js.map +0 -1
  222. package/dist/websocket/index.d.ts +0 -876
  223. package/dist/websocket/index.d.ts.map +0 -1
  224. package/dist/websocket/index.js +0 -1185
  225. package/dist/websocket/index.js.map +0 -1
  226. package/src/react/websocket/hooks/useRoom.tsx +0 -251
  227. package/src/react/websocket/index.ts +0 -7
  228. package/src/websocket/__tests__/$channel.spec.ts +0 -30
  229. package/src/websocket/__tests__/$websocket-new.spec.ts +0 -195
  230. package/src/websocket/__tests__/RoomManager.spec.ts +0 -146
  231. package/src/websocket/__tests__/websocket-integration.spec.ts +0 -951
  232. package/src/websocket/errors/WebSocketError.ts +0 -34
  233. package/src/websocket/index.browser.ts +0 -25
  234. package/src/websocket/index.shared.ts +0 -8
  235. package/src/websocket/index.ts +0 -85
  236. package/src/websocket/interfaces/WebSocketInterfaces.ts +0 -252
  237. package/src/websocket/primitives/$channel.ts +0 -131
  238. package/src/websocket/primitives/$websocket.ts +0 -107
  239. package/src/websocket/providers/NodeWebSocketServerProvider.ts +0 -617
  240. package/src/websocket/providers/WebSocketServerProvider.ts +0 -56
  241. package/src/websocket/services/RoomManager.ts +0 -160
  242. package/src/websocket/services/WebSocketClient.ts +0 -642
  243. package/src/websocket/services/WebSocketTopicService.ts +0 -108
@@ -0,0 +1,382 @@
1
+ import { $inject } from "alepha";
2
+ import { $job } from "alepha/api/jobs";
3
+ import { PaymentService } from "alepha/api/payments";
4
+ import { DateTimeProvider } from "alepha/datetime";
5
+ import { $logger } from "alepha/logger";
6
+ import { $repository } from "alepha/orm";
7
+ import type { SubscriptionEventEntity } from "../entities/subscriptionEvents.ts";
8
+ import { subscriptionEvents } from "../entities/subscriptionEvents.ts";
9
+ import { subscriptions } from "../entities/subscriptions.ts";
10
+ import { SubscriptionConfig } from "../services/SubscriptionConfig.ts";
11
+
12
+ // -----------------------------------------------------------------------------------------------------------------
13
+
14
+ interface EventContext {
15
+ previousStatus?: string;
16
+ newStatus?: string;
17
+ paymentIntentId?: string;
18
+ amount?: number;
19
+ currency?: string;
20
+ triggeredBy?: string;
21
+ note?: string;
22
+ }
23
+
24
+ // -----------------------------------------------------------------------------------------------------------------
25
+
26
+ export class SubscriptionJobs {
27
+ protected readonly log = $logger();
28
+ protected readonly dateTime = $inject(DateTimeProvider);
29
+ protected readonly paymentService = $inject(PaymentService);
30
+ protected readonly config = $inject(SubscriptionConfig);
31
+ protected readonly subscriptionRepo = $repository(subscriptions);
32
+ protected readonly eventRepo = $repository(subscriptionEvents);
33
+
34
+ // ---------------------------------------------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Record a subscription event in the event log.
40
+ */
41
+ protected async recordEvent(
42
+ subscriptionId: string,
43
+ organizationId: string,
44
+ type: SubscriptionEventEntity["type"],
45
+ context?: EventContext,
46
+ ): Promise<void> {
47
+ await this.eventRepo.create({
48
+ subscriptionId,
49
+ organizationId,
50
+ type,
51
+ previousStatus: context?.previousStatus,
52
+ newStatus: context?.newStatus,
53
+ paymentIntentId: context?.paymentIntentId,
54
+ amount: context?.amount,
55
+ currency: context?.currency,
56
+ triggeredBy: context?.triggeredBy,
57
+ note: context?.note,
58
+ });
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------------------------------------------
62
+ // Jobs
63
+ // ---------------------------------------------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Creates payment intents for subscriptions due for renewal.
67
+ * Runs hourly.
68
+ */
69
+ public readonly billingCycle = $job({
70
+ cron: "0 * * * *",
71
+ lock: true,
72
+ timeout: [10, "minute"],
73
+ handler: async ({ now }) => {
74
+ const nowISO = now.toISOString();
75
+
76
+ const due = await this.subscriptionRepo.findMany({
77
+ where: {
78
+ nextBillingAt: { lte: nowISO },
79
+ status: { inArray: ["active", "trialing"] },
80
+ },
81
+ });
82
+
83
+ this.log.info(`Billing cycle: processing ${due.length} subscription(s)`);
84
+
85
+ for (const sub of due) {
86
+ try {
87
+ const pricing = await this.config.getPlanPricing(
88
+ sub.planId,
89
+ sub.interval,
90
+ );
91
+
92
+ const intent = await this.paymentService.createIntent(
93
+ pricing.amount,
94
+ pricing.currency,
95
+ { subscriptionId: sub.id },
96
+ );
97
+
98
+ await this.subscriptionRepo.updateById(sub.id, {
99
+ lastPaymentIntentId: intent.id,
100
+ });
101
+
102
+ this.log.debug("Created payment intent for subscription", {
103
+ subscriptionId: sub.id,
104
+ intentId: intent.id,
105
+ });
106
+ } catch (err) {
107
+ this.log.error("Failed to create payment intent for subscription", {
108
+ subscriptionId: sub.id,
109
+ error: err,
110
+ });
111
+ }
112
+ }
113
+ },
114
+ });
115
+
116
+ // ---------------------------------------------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Retries failed payments on the dunning schedule.
120
+ * Runs hourly.
121
+ */
122
+ public readonly dunningRetry = $job({
123
+ cron: "0 * * * *",
124
+ lock: true,
125
+ timeout: [10, "minute"],
126
+ handler: async ({ now }) => {
127
+ const nowISO = now.toISOString();
128
+
129
+ const pastDue = await this.subscriptionRepo.findMany({
130
+ where: {
131
+ dunningNextRetryAt: { lte: nowISO },
132
+ status: { eq: "past_due" },
133
+ },
134
+ });
135
+
136
+ this.log.info(
137
+ `Dunning retry: processing ${pastDue.length} subscription(s)`,
138
+ );
139
+
140
+ const settings = await this.config.getSettings();
141
+
142
+ for (const sub of pastDue) {
143
+ try {
144
+ const pricing = await this.config.getPlanPricing(
145
+ sub.planId,
146
+ sub.interval,
147
+ );
148
+
149
+ const intent = await this.paymentService.createIntent(
150
+ pricing.amount,
151
+ pricing.currency,
152
+ { subscriptionId: sub.id },
153
+ );
154
+
155
+ const newAttempt = sub.dunningAttempt + 1;
156
+ const scheduleDays = settings.dunningSchedule[newAttempt - 1];
157
+ const nextRetry =
158
+ scheduleDays !== undefined
159
+ ? now.add(scheduleDays, "days").toISOString()
160
+ : undefined;
161
+
162
+ await this.subscriptionRepo.updateById(sub.id, {
163
+ lastPaymentIntentId: intent.id,
164
+ dunningAttempt: newAttempt,
165
+ dunningNextRetryAt: nextRetry,
166
+ });
167
+
168
+ await this.recordEvent(
169
+ sub.id,
170
+ sub.organizationId as string,
171
+ "payment_retried",
172
+ {
173
+ paymentIntentId: intent.id,
174
+ note: `Dunning retry attempt ${newAttempt}`,
175
+ },
176
+ );
177
+
178
+ this.log.debug("Dunning retry payment intent created", {
179
+ subscriptionId: sub.id,
180
+ attempt: newAttempt,
181
+ });
182
+ } catch (err) {
183
+ this.log.error("Failed to create dunning retry intent", {
184
+ subscriptionId: sub.id,
185
+ error: err,
186
+ });
187
+ }
188
+ }
189
+ },
190
+ });
191
+
192
+ // ---------------------------------------------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Handles trial expirations.
196
+ * Runs hourly.
197
+ */
198
+ public readonly trialExpiry = $job({
199
+ cron: "0 * * * *",
200
+ lock: true,
201
+ handler: async ({ now }) => {
202
+ const nowISO = now.toISOString();
203
+
204
+ const expired = await this.subscriptionRepo.findMany({
205
+ where: {
206
+ trialEnd: { lte: nowISO },
207
+ status: { eq: "trialing" },
208
+ },
209
+ });
210
+
211
+ this.log.info(
212
+ `Trial expiry: processing ${expired.length} subscription(s)`,
213
+ );
214
+
215
+ for (const sub of expired) {
216
+ try {
217
+ const pricing = await this.config.getPlanPricing(
218
+ sub.planId,
219
+ sub.interval,
220
+ );
221
+
222
+ const intent = await this.paymentService.createIntent(
223
+ pricing.amount,
224
+ pricing.currency,
225
+ { subscriptionId: sub.id },
226
+ );
227
+
228
+ await this.subscriptionRepo.updateById(sub.id, {
229
+ lastPaymentIntentId: intent.id,
230
+ });
231
+
232
+ this.log.debug("Created payment intent for trial expiry", {
233
+ subscriptionId: sub.id,
234
+ intentId: intent.id,
235
+ });
236
+ } catch (err) {
237
+ this.log.error("Failed to process trial expiry", {
238
+ subscriptionId: sub.id,
239
+ error: err,
240
+ });
241
+ }
242
+ }
243
+ },
244
+ });
245
+
246
+ // ---------------------------------------------------------------------------------------------------------------
247
+
248
+ /**
249
+ * Expires cancelled subscriptions that reached period end.
250
+ * Runs hourly.
251
+ */
252
+ public readonly expirationSweep = $job({
253
+ cron: "0 * * * *",
254
+ lock: true,
255
+ handler: async ({ now }) => {
256
+ const nowISO = now.toISOString();
257
+
258
+ const toExpire = await this.subscriptionRepo.findMany({
259
+ where: {
260
+ currentPeriodEnd: { lte: nowISO },
261
+ status: { eq: "cancelled" },
262
+ cancelAtPeriodEnd: { eq: true },
263
+ },
264
+ });
265
+
266
+ this.log.info(
267
+ `Expiration sweep: expiring ${toExpire.length} subscription(s)`,
268
+ );
269
+
270
+ for (const sub of toExpire) {
271
+ try {
272
+ await this.subscriptionRepo.updateById(sub.id, {
273
+ status: "expired",
274
+ });
275
+
276
+ await this.recordEvent(
277
+ sub.id,
278
+ sub.organizationId as string,
279
+ "expired",
280
+ {
281
+ previousStatus: "cancelled",
282
+ newStatus: "expired",
283
+ },
284
+ );
285
+
286
+ this.log.debug("Subscription expired", { subscriptionId: sub.id });
287
+ } catch (err) {
288
+ this.log.error("Failed to expire subscription", {
289
+ subscriptionId: sub.id,
290
+ error: err,
291
+ });
292
+ }
293
+ }
294
+ },
295
+ });
296
+
297
+ // ---------------------------------------------------------------------------------------------------------------
298
+
299
+ /**
300
+ * Suspends past_due subscriptions where grace period has elapsed.
301
+ * Runs daily at 2 AM.
302
+ */
303
+ public readonly gracePeriodSweep = $job({
304
+ cron: "0 2 * * *",
305
+ lock: true,
306
+ handler: async ({ now }) => {
307
+ const settings = await this.config.getSettings();
308
+ const gracePeriodDays = settings.gracePeriodDays;
309
+
310
+ const pastDueSubs = await this.subscriptionRepo.findMany({
311
+ where: {
312
+ status: { eq: "past_due" },
313
+ },
314
+ });
315
+
316
+ const toSuspend = pastDueSubs.filter((sub) => {
317
+ if (!sub.dunningStartedAt) return false;
318
+ const graceEnd = this.dateTime
319
+ .of(sub.dunningStartedAt)
320
+ .add(gracePeriodDays, "days");
321
+ return !now.isBefore(graceEnd.toISOString());
322
+ });
323
+
324
+ this.log.info(
325
+ `Grace period sweep: suspending ${toSuspend.length} subscription(s)`,
326
+ );
327
+
328
+ for (const sub of toSuspend) {
329
+ try {
330
+ await this.subscriptionRepo.updateById(sub.id, {
331
+ status: "suspended",
332
+ });
333
+
334
+ await this.recordEvent(
335
+ sub.id,
336
+ sub.organizationId as string,
337
+ "suspended",
338
+ {
339
+ previousStatus: "past_due",
340
+ newStatus: "suspended",
341
+ },
342
+ );
343
+
344
+ this.log.debug("Subscription suspended after grace period", {
345
+ subscriptionId: sub.id,
346
+ });
347
+ } catch (err) {
348
+ this.log.error("Failed to suspend subscription", {
349
+ subscriptionId: sub.id,
350
+ error: err,
351
+ });
352
+ }
353
+ }
354
+ },
355
+ });
356
+
357
+ // ---------------------------------------------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Purges old subscription events older than 365 days.
361
+ * Runs daily at 3 AM.
362
+ */
363
+ public readonly purgeEvents = $job({
364
+ cron: "0 3 * * *",
365
+ lock: true,
366
+ handler: async ({ now }) => {
367
+ const cutoff = now.subtract(365, "days").toISOString();
368
+
369
+ const old = await this.eventRepo.findMany({
370
+ where: {
371
+ createdAt: { lt: cutoff },
372
+ },
373
+ });
374
+
375
+ this.log.info(`Purge events: removing ${old.length} old event(s)`);
376
+
377
+ for (const event of old) {
378
+ await this.eventRepo.deleteById(event.id);
379
+ }
380
+ },
381
+ });
382
+ }
@@ -0,0 +1,50 @@
1
+ import { $context, createMiddleware, type Middleware } from "alepha";
2
+ import { ForbiddenError } from "alepha/server";
3
+ import { UsageService } from "../services/UsageService.ts";
4
+
5
+ /**
6
+ * Middleware that enforces a per-organization usage limit for a resource.
7
+ *
8
+ * Resolves the organization from `args[0].user.organization`, increments the
9
+ * usage counter for the given resource, and throws `ForbiddenError` if the
10
+ * plan limit has been reached.
11
+ * Throws `ForbiddenError` if no organization is present or the limit is exceeded.
12
+ *
13
+ * ```typescript
14
+ * class ApiController {
15
+ * search = $action({
16
+ * use: [$requireLimit("api_calls")],
17
+ * handler: async ({ query }) => { ... },
18
+ * });
19
+ * }
20
+ * ```
21
+ *
22
+ * @param resource The resource identifier to track (e.g., "api_calls", "exports").
23
+ */
24
+ export const $requireLimit = (resource: string): Middleware => {
25
+ const { alepha } = $context();
26
+ const usageService = alepha.inject(UsageService);
27
+
28
+ return createMiddleware({
29
+ name: "$requireLimit",
30
+ options: { resource } as Record<string, unknown>,
31
+ handler: ({ next }) => {
32
+ return async (...args: any[]) => {
33
+ const user = args[0]?.user;
34
+ if (!user?.organization) {
35
+ throw new ForbiddenError("Organization required");
36
+ }
37
+ const result = await usageService.increment(
38
+ user.organization,
39
+ resource,
40
+ );
41
+ if (!result.allowed) {
42
+ throw new ForbiddenError(
43
+ `Usage limit for '${resource}' has been reached (${result.current}/${result.limit})`,
44
+ );
45
+ }
46
+ return next(...args);
47
+ };
48
+ },
49
+ });
50
+ };
@@ -0,0 +1,49 @@
1
+ import { $context, createMiddleware, type Middleware } from "alepha";
2
+ import { ForbiddenError } from "alepha/server";
3
+ import { SubscriptionService } from "../services/SubscriptionService.ts";
4
+
5
+ /**
6
+ * Middleware that gates access to a handler behind a subscription feature flag.
7
+ *
8
+ * Resolves the organization from `args[0].user.organization` and checks whether
9
+ * the organization's current plan includes the given feature.
10
+ * Throws `ForbiddenError` if no organization is present or the feature is not available.
11
+ *
12
+ * ```typescript
13
+ * class ReportController {
14
+ * generate = $action({
15
+ * use: [$requirePlan("advanced_reports")],
16
+ * handler: async ({ user }) => { ... },
17
+ * });
18
+ * }
19
+ * ```
20
+ *
21
+ * @param feature The feature identifier to check against the plan's feature list.
22
+ */
23
+ export const $requirePlan = (feature: string): Middleware => {
24
+ const { alepha } = $context();
25
+ const subscriptionService = alepha.inject(SubscriptionService);
26
+
27
+ return createMiddleware({
28
+ name: "$requirePlan",
29
+ options: { feature } as Record<string, unknown>,
30
+ handler: ({ next }) => {
31
+ return async (...args: any[]) => {
32
+ const user = args[0]?.user;
33
+ if (!user?.organization) {
34
+ throw new ForbiddenError("Organization required");
35
+ }
36
+ const allowed = await subscriptionService.can(
37
+ user.organization,
38
+ feature,
39
+ );
40
+ if (!allowed) {
41
+ throw new ForbiddenError(
42
+ `Feature '${feature}' not available on your plan`,
43
+ );
44
+ }
45
+ return next(...args);
46
+ };
47
+ },
48
+ });
49
+ };
@@ -0,0 +1,110 @@
1
+ import { t } from "alepha";
2
+ import { $notification } from "alepha/api/notifications";
3
+
4
+ export class SubscriptionNotifications {
5
+ /**
6
+ * Sent when a trial is ending soon.
7
+ */
8
+ protected readonly trialEnding = $notification({
9
+ name: "subscription-trial-ending",
10
+ category: "subscriptions",
11
+ schema: t.object({
12
+ planName: t.text(),
13
+ trialEndDate: t.text(),
14
+ amount: t.text(),
15
+ interval: t.text(),
16
+ }),
17
+ email: {
18
+ subject: "Your trial is ending soon",
19
+ body: (v) =>
20
+ `Your ${v.planName} trial is ending on ${v.trialEndDate}. You'll be charged ${v.amount}/${v.interval}.`,
21
+ },
22
+ });
23
+
24
+ /**
25
+ * Sent when a payment fails. Critical notification.
26
+ */
27
+ protected readonly paymentFailed = $notification({
28
+ name: "subscription-payment-failed",
29
+ category: "subscriptions",
30
+ critical: true,
31
+ schema: t.object({
32
+ planName: t.text(),
33
+ amount: t.text(),
34
+ retryDate: t.optional(t.text()),
35
+ }),
36
+ email: {
37
+ subject: "Payment failed for your subscription",
38
+ body: (v) =>
39
+ `We couldn't charge your card for ${v.planName} (${v.amount}). ${v.retryDate ? `We'll retry on ${v.retryDate}.` : "Please update your payment method."}`,
40
+ },
41
+ });
42
+
43
+ /**
44
+ * Sent when a subscription is suspended due to failed payments. Critical notification.
45
+ */
46
+ protected readonly subscriptionSuspended = $notification({
47
+ name: "subscription-suspended",
48
+ category: "subscriptions",
49
+ critical: true,
50
+ schema: t.object({ planName: t.text() }),
51
+ email: {
52
+ subject: "Your subscription has been suspended",
53
+ body: (v) =>
54
+ `Your ${v.planName} subscription has been suspended due to failed payments. Update your payment method to reactivate.`,
55
+ },
56
+ });
57
+
58
+ /**
59
+ * Sent when a subscription is successfully renewed.
60
+ */
61
+ protected readonly subscriptionRenewed = $notification({
62
+ name: "subscription-renewed",
63
+ category: "subscriptions",
64
+ schema: t.object({
65
+ planName: t.text(),
66
+ amount: t.text(),
67
+ nextBillingDate: t.text(),
68
+ }),
69
+ email: {
70
+ subject: "Payment received — subscription renewed",
71
+ body: (v) =>
72
+ `Your ${v.planName} subscription has been renewed. Amount: ${v.amount}. Next billing: ${v.nextBillingDate}.`,
73
+ },
74
+ });
75
+
76
+ /**
77
+ * Sent when a subscription plan is changed.
78
+ */
79
+ protected readonly planChanged = $notification({
80
+ name: "subscription-plan-changed",
81
+ category: "subscriptions",
82
+ schema: t.object({
83
+ oldPlanName: t.text(),
84
+ newPlanName: t.text(),
85
+ effectiveDate: t.text(),
86
+ }),
87
+ email: {
88
+ subject: "Your subscription plan has been changed",
89
+ body: (v) =>
90
+ `Your plan has been changed from ${v.oldPlanName} to ${v.newPlanName}, effective ${v.effectiveDate}.`,
91
+ },
92
+ });
93
+
94
+ /**
95
+ * Sent when a subscription is cancelled.
96
+ */
97
+ protected readonly cancellationConfirmed = $notification({
98
+ name: "subscription-cancelled",
99
+ category: "subscriptions",
100
+ schema: t.object({
101
+ planName: t.text(),
102
+ accessUntil: t.optional(t.text()),
103
+ }),
104
+ email: {
105
+ subject: "Your subscription has been cancelled",
106
+ body: (v) =>
107
+ `Your ${v.planName} subscription has been cancelled.${v.accessUntil ? ` You'll have access until ${v.accessUntil}.` : ""}`,
108
+ },
109
+ });
110
+ }
@@ -0,0 +1,8 @@
1
+ import { type Static, t } from "alepha";
2
+
3
+ export const cancelSubscriptionSchema = t.object({
4
+ reason: t.optional(t.string()),
5
+ immediate: t.optional(t.boolean()),
6
+ });
7
+
8
+ export type CancelSubscription = Static<typeof cancelSubscriptionSchema>;
@@ -0,0 +1,9 @@
1
+ import { type Static, t } from "alepha";
2
+
3
+ export const changePlanSchema = t.object({
4
+ planId: t.string(),
5
+ interval: t.optional(t.enum(["monthly", "yearly"])),
6
+ immediate: t.optional(t.boolean()),
7
+ });
8
+
9
+ export type ChangePlan = Static<typeof changePlanSchema>;
@@ -0,0 +1,11 @@
1
+ import { type Static, t } from "alepha";
2
+
3
+ export const createSubscriptionSchema = t.object({
4
+ planId: t.string(),
5
+ interval: t.enum(["monthly", "yearly"]),
6
+ paymentMethodId: t.optional(t.uuid()),
7
+ skipTrial: t.optional(t.boolean()),
8
+ metadata: t.optional(t.record(t.text(), t.any())),
9
+ });
10
+
11
+ export type CreateSubscription = Static<typeof createSubscriptionSchema>;
@@ -0,0 +1,21 @@
1
+ import { type Static, t } from "alepha";
2
+
3
+ export const entitlementsSchema = t.object({
4
+ planId: t.string(),
5
+ planName: t.string(),
6
+ status: t.enum([
7
+ "trialing",
8
+ "active",
9
+ "past_due",
10
+ "suspended",
11
+ "cancelled",
12
+ "expired",
13
+ ]),
14
+ features: t.array(t.string()),
15
+ limits: t.record(t.text(), t.integer()),
16
+ trialEndsAt: t.optional(t.datetime()),
17
+ periodEndsAt: t.datetime(),
18
+ cancelledAt: t.optional(t.datetime()),
19
+ });
20
+
21
+ export type Entitlements = Static<typeof entitlementsSchema>;
@@ -0,0 +1,13 @@
1
+ import { type Static, t } from "alepha";
2
+
3
+ export const mrrSchema = t.object({
4
+ total: t.integer(),
5
+ byPlan: t.record(t.text(), t.integer()),
6
+ growth: t.integer(),
7
+ newMrr: t.integer(),
8
+ expansionMrr: t.integer(),
9
+ contractionMrr: t.integer(),
10
+ churnMrr: t.integer(),
11
+ });
12
+
13
+ export type MrrData = Static<typeof mrrSchema>;