alepha 0.20.6 → 0.20.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +271 -262
  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 +863 -847
  41. package/dist/api/users/index.d.ts.map +1 -1
  42. package/dist/api/verifications/index.d.ts +126 -125
  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 +113 -129
  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
@@ -31,7 +31,12 @@ export abstract class PaymentProvider {
31
31
  */
32
32
  abstract createSession(
33
33
  intent: PaymentIntentEntity,
34
- options: { returnUrl: string; authorize?: boolean },
34
+ options: {
35
+ returnUrl: string;
36
+ authorize?: boolean;
37
+ stripeAccount?: string;
38
+ applicationFeeAmount?: number;
39
+ },
35
40
  ): Promise<CreateSessionResult>;
36
41
 
37
42
  /**
@@ -54,19 +59,29 @@ export abstract class PaymentProvider {
54
59
  ): Promise<RefundResult>;
55
60
 
56
61
  /**
57
- * Parse and verify an incoming PSP webhook request.
62
+ * Parse and verify the authenticity of an incoming PSP webhook request.
58
63
  *
59
- * Implementations MUST verify the webhook signature before returning.
60
- * Throw an error if the signature is invalid or missing — this is the
61
- * only authentication on the webhook endpoint (no $secure middleware).
64
+ * Implementations MUST establish authenticity before returning. Two
65
+ * common strategies, both acceptable:
62
66
  *
63
- * Failure to verify signatures allows attackers to forge payment
64
- * confirmations by POSTing fake webhook events.
67
+ * - Signature verification (Stripe, Adyen): verify an HMAC header
68
+ * against the raw body using a shared secret. Throw if invalid.
69
+ * - Re-fetch (Mollie, legacy webhooks): the body only carries an id;
70
+ * call back into the PSP API with your authenticated client to
71
+ * fetch the real resource state. The fetch itself is the auth —
72
+ * an attacker can POST a fake id but cannot influence the result.
73
+ *
74
+ * Throw an error if authenticity cannot be established. This endpoint
75
+ * has no $secure middleware; provider verification is its only auth.
65
76
  */
66
77
  abstract parseWebhook(request: Request): Promise<WebhookEvent>;
67
78
 
68
79
  /**
69
- * Store a payment method token with the PSP.
80
+ * Store a payment method token with the PSP and return the saved
81
+ * instrument's reference. Implementations that don't support
82
+ * tokenize-then-attach (e.g. Mollie, where mandates are created by
83
+ * making a "first" payment) MAY throw to signal the host app to use
84
+ * the checkout flow with `setup_future_usage`-style options instead.
70
85
  */
71
86
  abstract createPaymentMethod(
72
87
  userId: string,
@@ -74,7 +89,8 @@ export abstract class PaymentProvider {
74
89
  ): Promise<CreatePaymentMethodResult>;
75
90
 
76
91
  /**
77
- * Delete a stored payment method from the PSP.
92
+ * Delete a stored payment method from the PSP. Implementations that
93
+ * don't manage payment methods directly MAY no-op.
78
94
  */
79
95
  abstract deletePaymentMethod(providerRef: string): Promise<void>;
80
96
 
@@ -79,6 +79,7 @@ export class PaymentService {
79
79
  returnUrl: string,
80
80
  authorize?: boolean,
81
81
  userId?: string,
82
+ options?: { stripeAccount?: string; applicationFeeAmount?: number },
82
83
  ): Promise<{ url: string; intentId: string }> {
83
84
  const intent = await this.getIntent(intentId);
84
85
  this.assertStatus(intent, "created", "createSession");
@@ -96,6 +97,8 @@ export class PaymentService {
96
97
  const result = await this.provider.createSession(intent, {
97
98
  returnUrl,
98
99
  authorize,
100
+ stripeAccount: options?.stripeAccount,
101
+ applicationFeeAmount: options?.applicationFeeAmount,
99
102
  });
100
103
 
101
104
  await this.intentRepo.updateById(intent.id, {
@@ -0,0 +1,218 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Alepha } from "alepha";
3
+ import { $repository } from "alepha/orm";
4
+ import { AlephaOrmPostgres } from "alepha/orm/postgres";
5
+ import { describe, it } from "vitest";
6
+ import { subscriptions } from "../entities/subscriptions.ts";
7
+ import { AlephaApiSubscriptions } from "../index.ts";
8
+ import type { PlanDefinition } from "../schemas/planDefinitionSchema.ts";
9
+ import { SubscriptionConfig } from "../services/SubscriptionConfig.ts";
10
+ import { SubscriptionService } from "../services/SubscriptionService.ts";
11
+
12
+ // -----------------------------------------------------------------------------------------------------------------
13
+
14
+ class TestSubscriptionConfig extends SubscriptionConfig {
15
+ public async seedPlans(plans: PlanDefinition[]): Promise<void> {
16
+ await this.plans.set({ plans });
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Helper to directly update subscription records for test setup.
22
+ */
23
+ class TestRepositories {
24
+ subscriptionRepo = $repository(subscriptions);
25
+ }
26
+
27
+ // -----------------------------------------------------------------------------------------------------------------
28
+
29
+ const testPlans: PlanDefinition[] = [
30
+ {
31
+ id: "free",
32
+ name: "Free",
33
+ available: true,
34
+ pricing: [{ interval: "monthly", amount: 0, currency: "usd" }],
35
+ features: ["dashboard"],
36
+ limits: { seats: 1, "api-calls": 100 },
37
+ order: 0,
38
+ },
39
+ {
40
+ id: "pro",
41
+ name: "Pro",
42
+ available: true,
43
+ pricing: [
44
+ { interval: "monthly", amount: 2900, currency: "usd" },
45
+ { interval: "yearly", amount: 29000, currency: "usd" },
46
+ ],
47
+ trial: { days: 14, requirePaymentMethod: false },
48
+ features: ["dashboard", "analytics", "export"],
49
+ limits: { seats: 10, "api-calls": 10000 },
50
+ order: 1,
51
+ },
52
+ ];
53
+
54
+ // -----------------------------------------------------------------------------------------------------------------
55
+
56
+ const setup = async () => {
57
+ const alepha = Alepha.create()
58
+ .with(AlephaOrmPostgres)
59
+ .with({ provide: SubscriptionConfig, use: TestSubscriptionConfig })
60
+ .with(AlephaApiSubscriptions);
61
+
62
+ const service = alepha.inject(SubscriptionService);
63
+ const config = alepha.inject(
64
+ TestSubscriptionConfig,
65
+ ) as TestSubscriptionConfig;
66
+ const repos = alepha.inject(TestRepositories);
67
+ await alepha.start();
68
+
69
+ await config.seedPlans(testPlans);
70
+
71
+ /**
72
+ * Helper: create a subscription and attach a payment intent ID for billing lookup.
73
+ */
74
+ const createSubscriptionWithIntent = async (
75
+ planId: string,
76
+ intentId: string,
77
+ options?: { skipTrial?: boolean },
78
+ ) => {
79
+ const orgId = randomUUID();
80
+ const sub = await service.subscribe(orgId, planId, "monthly", options);
81
+ await repos.subscriptionRepo.updateById(sub.id, {
82
+ lastPaymentIntentId: intentId,
83
+ });
84
+ return service.getSubscription(sub.id);
85
+ };
86
+
87
+ return { alepha, service, config, repos, createSubscriptionWithIntent };
88
+ };
89
+
90
+ // -----------------------------------------------------------------------------------------------------------------
91
+
92
+ describe("BillingService", () => {
93
+ // ---------------------------------------------------------------------------------------------------------------
94
+
95
+ describe("payment captured", () => {
96
+ it("should activate a trialing subscription after payment", async ({
97
+ expect,
98
+ }) => {
99
+ const { alepha, service, createSubscriptionWithIntent } = await setup();
100
+
101
+ const intentId = randomUUID();
102
+ const sub = await createSubscriptionWithIntent("pro", intentId);
103
+ expect(sub.status).toBe("trialing");
104
+
105
+ await alepha.events.emit("payments:captured", {
106
+ intentId,
107
+ amount: 2900,
108
+ currency: "usd",
109
+ });
110
+
111
+ const updated = await service.getSubscription(sub.id);
112
+ expect(updated.status).toBe("active");
113
+ expect(updated.lastPaymentIntentId).toBe(intentId);
114
+ });
115
+
116
+ it("should renew an active subscription and advance period", async ({
117
+ expect,
118
+ }) => {
119
+ const { alepha, service, createSubscriptionWithIntent } = await setup();
120
+
121
+ const intentId = randomUUID();
122
+ const sub = await createSubscriptionWithIntent("pro", intentId, {
123
+ skipTrial: true,
124
+ });
125
+ expect(sub.status).toBe("active");
126
+
127
+ const originalPeriodEnd = sub.currentPeriodEnd;
128
+
129
+ await alepha.events.emit("payments:captured", {
130
+ intentId,
131
+ amount: 2900,
132
+ currency: "usd",
133
+ });
134
+
135
+ const updated = await service.getSubscription(sub.id);
136
+ expect(updated.status).toBe("active");
137
+ expect(updated.currentPeriodStart).toBe(originalPeriodEnd);
138
+ expect(updated.currentPeriodEnd).not.toBe(originalPeriodEnd);
139
+ });
140
+
141
+ it("should recover from dunning", async ({ expect }) => {
142
+ const { alepha, service, repos, createSubscriptionWithIntent } =
143
+ await setup();
144
+
145
+ const intentId = randomUUID();
146
+ const sub = await createSubscriptionWithIntent("pro", intentId, {
147
+ skipTrial: true,
148
+ });
149
+
150
+ await repos.subscriptionRepo.updateById(sub.id, {
151
+ status: "past_due",
152
+ dunningAttempt: 2,
153
+ dunningStartedAt: new Date().toISOString(),
154
+ });
155
+
156
+ await alepha.events.emit("payments:captured", {
157
+ intentId,
158
+ amount: 2900,
159
+ currency: "usd",
160
+ });
161
+
162
+ const updated = await service.getSubscription(sub.id);
163
+ expect(updated.status).toBe("active");
164
+ expect(updated.dunningAttempt).toBe(0);
165
+ });
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------------------------------------------
169
+
170
+ describe("payment failed", () => {
171
+ it("should start dunning on first failure", async ({ expect }) => {
172
+ const { alepha, service, createSubscriptionWithIntent } = await setup();
173
+
174
+ const intentId = randomUUID();
175
+ const sub = await createSubscriptionWithIntent("pro", intentId, {
176
+ skipTrial: true,
177
+ });
178
+ expect(sub.status).toBe("active");
179
+
180
+ await alepha.events.emit("payments:failed", {
181
+ intentId,
182
+ amount: 2900,
183
+ currency: "usd",
184
+ });
185
+
186
+ const updated = await service.getSubscription(sub.id);
187
+ expect(updated.status).toBe("past_due");
188
+ expect(updated.dunningAttempt).toBe(1);
189
+ expect(updated.dunningStartedAt).toBeDefined();
190
+ });
191
+
192
+ it("should increment dunning on subsequent failure", async ({ expect }) => {
193
+ const { alepha, service, repos, createSubscriptionWithIntent } =
194
+ await setup();
195
+
196
+ const intentId = randomUUID();
197
+ const sub = await createSubscriptionWithIntent("pro", intentId, {
198
+ skipTrial: true,
199
+ });
200
+
201
+ await repos.subscriptionRepo.updateById(sub.id, {
202
+ status: "past_due",
203
+ dunningAttempt: 1,
204
+ dunningStartedAt: new Date().toISOString(),
205
+ });
206
+
207
+ await alepha.events.emit("payments:failed", {
208
+ intentId,
209
+ amount: 2900,
210
+ currency: "usd",
211
+ });
212
+
213
+ const updated = await service.getSubscription(sub.id);
214
+ expect(updated.status).toBe("past_due");
215
+ expect(updated.dunningAttempt).toBe(2);
216
+ });
217
+ });
218
+ });
@@ -0,0 +1,278 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Alepha } from "alepha";
3
+ import { AlephaOrmPostgres } from "alepha/orm/postgres";
4
+ import { BadRequestError } from "alepha/server";
5
+ import { describe, it } from "vitest";
6
+ import { AlephaApiSubscriptions } from "../index.ts";
7
+ import type { PlanDefinition } from "../schemas/planDefinitionSchema.ts";
8
+ import { SubscriptionConfig } from "../services/SubscriptionConfig.ts";
9
+ import { SubscriptionService } from "../services/SubscriptionService.ts";
10
+
11
+ // -----------------------------------------------------------------------------------------------------------------
12
+
13
+ class TestSubscriptionConfig extends SubscriptionConfig {
14
+ public async seedPlans(plans: PlanDefinition[]): Promise<void> {
15
+ await this.plans.set({ plans });
16
+ }
17
+ }
18
+
19
+ // -----------------------------------------------------------------------------------------------------------------
20
+
21
+ const testPlans: PlanDefinition[] = [
22
+ {
23
+ id: "free",
24
+ name: "Free",
25
+ available: true,
26
+ pricing: [{ interval: "monthly", amount: 0, currency: "usd" }],
27
+ features: ["dashboard"],
28
+ limits: { seats: 1, "api-calls": 100 },
29
+ order: 0,
30
+ },
31
+ {
32
+ id: "pro",
33
+ name: "Pro",
34
+ available: true,
35
+ pricing: [
36
+ { interval: "monthly", amount: 2900, currency: "usd" },
37
+ { interval: "yearly", amount: 29000, currency: "usd" },
38
+ ],
39
+ trial: { days: 14, requirePaymentMethod: false },
40
+ features: ["dashboard", "analytics", "export"],
41
+ limits: { seats: 10, "api-calls": 10000 },
42
+ order: 1,
43
+ },
44
+ ];
45
+
46
+ // -----------------------------------------------------------------------------------------------------------------
47
+
48
+ const setup = async () => {
49
+ const alepha = Alepha.create()
50
+ .with(AlephaOrmPostgres)
51
+ .with({ provide: SubscriptionConfig, use: TestSubscriptionConfig })
52
+ .with(AlephaApiSubscriptions);
53
+
54
+ const service = alepha.inject(SubscriptionService);
55
+ const config = alepha.inject(
56
+ TestSubscriptionConfig,
57
+ ) as TestSubscriptionConfig;
58
+ await alepha.start();
59
+
60
+ await config.seedPlans(testPlans);
61
+
62
+ return { alepha, service, config };
63
+ };
64
+
65
+ // -----------------------------------------------------------------------------------------------------------------
66
+
67
+ describe("SubscriptionService", () => {
68
+ // ---------------------------------------------------------------------------------------------------------------
69
+
70
+ describe("subscribe", () => {
71
+ it("should create a trialing subscription with trial days", async ({
72
+ expect,
73
+ }) => {
74
+ const { service } = await setup();
75
+ const orgId = randomUUID();
76
+
77
+ const sub = await service.subscribe(orgId, "pro", "monthly");
78
+
79
+ expect(sub.status).toBe("trialing");
80
+ expect(sub.planId).toBe("pro");
81
+ expect(sub.interval).toBe("monthly");
82
+ expect(sub.organizationId).toBe(orgId);
83
+ expect(sub.trialStart).toBeDefined();
84
+ expect(sub.trialEnd).toBeDefined();
85
+ });
86
+
87
+ it("should create an active subscription when skipTrial", async ({
88
+ expect,
89
+ }) => {
90
+ const { service } = await setup();
91
+ const orgId = randomUUID();
92
+
93
+ const sub = await service.subscribe(orgId, "pro", "monthly", {
94
+ skipTrial: true,
95
+ });
96
+
97
+ expect(sub.status).toBe("active");
98
+ expect(sub.planId).toBe("pro");
99
+ expect(sub.trialStart).toBeUndefined();
100
+ expect(sub.trialEnd).toBeUndefined();
101
+ });
102
+
103
+ it("should reject when plan not found", async ({ expect }) => {
104
+ const { service } = await setup();
105
+ const orgId = randomUUID();
106
+
107
+ await expect(
108
+ service.subscribe(orgId, "nonexistent", "monthly"),
109
+ ).rejects.toThrow(BadRequestError);
110
+ });
111
+
112
+ it("should reject duplicate subscription for same org", async ({
113
+ expect,
114
+ }) => {
115
+ const { service } = await setup();
116
+ const orgId = randomUUID();
117
+
118
+ await service.subscribe(orgId, "pro", "monthly", {
119
+ skipTrial: true,
120
+ });
121
+
122
+ await expect(service.subscribe(orgId, "free", "monthly")).rejects.toThrow(
123
+ BadRequestError,
124
+ );
125
+ });
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------------------------------------------
129
+
130
+ describe("cancel", () => {
131
+ it("should cancel at period end", async ({ expect }) => {
132
+ const { service } = await setup();
133
+ const orgId = randomUUID();
134
+
135
+ const sub = await service.subscribe(orgId, "pro", "monthly", {
136
+ skipTrial: true,
137
+ });
138
+
139
+ await service.cancel(sub.id, { immediate: false });
140
+
141
+ const updated = await service.getSubscription(sub.id);
142
+ expect(updated.status).toBe("cancelled");
143
+ expect(updated.cancelAtPeriodEnd).toBe(true);
144
+ expect(updated.cancelledAt).toBeDefined();
145
+ });
146
+
147
+ it("should cancel immediately with expired status", async ({ expect }) => {
148
+ const { service } = await setup();
149
+ const orgId = randomUUID();
150
+
151
+ const sub = await service.subscribe(orgId, "pro", "monthly", {
152
+ skipTrial: true,
153
+ });
154
+
155
+ await service.cancel(sub.id, { immediate: true });
156
+
157
+ const updated = await service.getSubscription(sub.id);
158
+ expect(updated.status).toBe("expired");
159
+ expect(updated.cancelAtPeriodEnd).toBe(false);
160
+ expect(updated.cancelledAt).toBeDefined();
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------------------------------------------
165
+
166
+ describe("resume", () => {
167
+ it("should resume a cancelled subscription back to active", async ({
168
+ expect,
169
+ }) => {
170
+ const { service } = await setup();
171
+ const orgId = randomUUID();
172
+
173
+ const sub = await service.subscribe(orgId, "pro", "monthly", {
174
+ skipTrial: true,
175
+ });
176
+
177
+ await service.cancel(sub.id, { immediate: false });
178
+
179
+ const cancelled = await service.getSubscription(sub.id);
180
+ expect(cancelled.status).toBe("cancelled");
181
+
182
+ await service.resume(sub.id);
183
+
184
+ const resumed = await service.getSubscription(sub.id);
185
+ expect(resumed.status).toBe("active");
186
+ expect(resumed.cancelAtPeriodEnd).toBe(false);
187
+ });
188
+ });
189
+
190
+ // ---------------------------------------------------------------------------------------------------------------
191
+
192
+ describe("changePlan", () => {
193
+ it("should schedule plan change at period end", async ({ expect }) => {
194
+ const { service } = await setup();
195
+ const orgId = randomUUID();
196
+
197
+ const sub = await service.subscribe(orgId, "free", "monthly", {
198
+ skipTrial: true,
199
+ });
200
+
201
+ await service.changePlan(sub.id, "pro", "monthly", {
202
+ immediate: false,
203
+ });
204
+
205
+ const updated = await service.getSubscription(sub.id);
206
+ expect(updated.pendingPlanId).toBe("pro");
207
+ expect(updated.planId).toBe("free");
208
+ });
209
+
210
+ it("should apply immediate plan change", async ({ expect }) => {
211
+ const { service } = await setup();
212
+ const orgId = randomUUID();
213
+
214
+ const sub = await service.subscribe(orgId, "free", "monthly", {
215
+ skipTrial: true,
216
+ });
217
+
218
+ await service.changePlan(sub.id, "pro", "monthly", {
219
+ immediate: true,
220
+ });
221
+
222
+ const updated = await service.getSubscription(sub.id);
223
+ expect(updated.planId).toBe("pro");
224
+ expect(updated.pendingPlanId).toBeUndefined();
225
+ });
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------------------------------------------
229
+
230
+ describe("entitlements", () => {
231
+ it("should return true for included feature", async ({ expect }) => {
232
+ const { service } = await setup();
233
+ const orgId = randomUUID();
234
+
235
+ await service.subscribe(orgId, "pro", "monthly", {
236
+ skipTrial: true,
237
+ });
238
+
239
+ const canAnalytics = await service.can(orgId, "analytics");
240
+ expect(canAnalytics).toBe(true);
241
+ });
242
+
243
+ it("should return false for excluded feature", async ({ expect }) => {
244
+ const { service } = await setup();
245
+ const orgId = randomUUID();
246
+
247
+ await service.subscribe(orgId, "free", "monthly", {
248
+ skipTrial: true,
249
+ });
250
+
251
+ const canAnalytics = await service.can(orgId, "analytics");
252
+ expect(canAnalytics).toBe(false);
253
+ });
254
+
255
+ it("should return correct limit value", async ({ expect }) => {
256
+ const { service } = await setup();
257
+ const orgId = randomUUID();
258
+
259
+ await service.subscribe(orgId, "pro", "monthly", {
260
+ skipTrial: true,
261
+ });
262
+
263
+ const seats = await service.limit(orgId, "seats");
264
+ expect(seats).toBe(10);
265
+
266
+ const apiCalls = await service.limit(orgId, "api-calls");
267
+ expect(apiCalls).toBe(10000);
268
+ });
269
+
270
+ it("should return 0 limit for no subscription", async ({ expect }) => {
271
+ const { service } = await setup();
272
+ const orgId = randomUUID();
273
+
274
+ const seats = await service.limit(orgId, "seats");
275
+ expect(seats).toBe(0);
276
+ });
277
+ });
278
+ });