alepha 0.19.3 → 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 (215) 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 +99 -43
  10. package/dist/api/jobs/index.d.ts.map +1 -1
  11. package/dist/api/jobs/index.js +257 -40
  12. package/dist/api/jobs/index.js.map +1 -1
  13. package/dist/api/keys/index.d.ts +5 -5
  14. package/dist/api/notifications/index.browser.js +0 -1
  15. package/dist/api/notifications/index.browser.js.map +1 -1
  16. package/dist/api/notifications/index.d.ts +3 -3
  17. package/dist/api/notifications/index.d.ts.map +1 -1
  18. package/dist/api/notifications/index.js +0 -1
  19. package/dist/api/notifications/index.js.map +1 -1
  20. package/dist/api/parameters/index.browser.js +112 -1
  21. package/dist/api/parameters/index.browser.js.map +1 -1
  22. package/dist/api/parameters/index.d.ts +90 -3
  23. package/dist/api/parameters/index.d.ts.map +1 -1
  24. package/dist/api/parameters/index.js +79 -12
  25. package/dist/api/parameters/index.js.map +1 -1
  26. package/dist/{billing → api/payments}/index.d.ts +67 -49
  27. package/dist/api/payments/index.d.ts.map +1 -0
  28. package/dist/{billing → api/payments}/index.js +108 -74
  29. package/dist/api/payments/index.js.map +1 -0
  30. package/dist/api/subscriptions/index.d.ts +1692 -0
  31. package/dist/api/subscriptions/index.d.ts.map +1 -0
  32. package/dist/api/subscriptions/index.js +1870 -0
  33. package/dist/api/subscriptions/index.js.map +1 -0
  34. package/dist/api/users/index.d.ts +18 -2
  35. package/dist/api/users/index.d.ts.map +1 -1
  36. package/dist/api/users/index.js +167 -34
  37. package/dist/api/users/index.js.map +1 -1
  38. package/dist/api/verifications/index.d.ts +13 -13
  39. package/dist/api/workflows/index.browser.js +246 -0
  40. package/dist/api/workflows/index.browser.js.map +1 -0
  41. package/dist/api/workflows/index.d.ts +1618 -0
  42. package/dist/api/workflows/index.d.ts.map +1 -0
  43. package/dist/api/workflows/index.js +1504 -0
  44. package/dist/api/workflows/index.js.map +1 -0
  45. package/dist/cli/core/index.d.ts +44 -28
  46. package/dist/cli/core/index.d.ts.map +1 -1
  47. package/dist/cli/core/index.js +16 -61
  48. package/dist/cli/core/index.js.map +1 -1
  49. package/dist/cli/vendor/index.d.ts +31 -8
  50. package/dist/cli/vendor/index.d.ts.map +1 -1
  51. package/dist/cli/vendor/index.js +79 -24
  52. package/dist/cli/vendor/index.js.map +1 -1
  53. package/dist/core/index.browser.js +21 -2
  54. package/dist/core/index.browser.js.map +1 -1
  55. package/dist/core/index.d.ts +33 -2
  56. package/dist/core/index.d.ts.map +1 -1
  57. package/dist/core/index.js +21 -2
  58. package/dist/core/index.js.map +1 -1
  59. package/dist/core/index.native.js +21 -2
  60. package/dist/core/index.native.js.map +1 -1
  61. package/dist/core/index.workerd.js +21 -2
  62. package/dist/core/index.workerd.js.map +1 -1
  63. package/dist/email/smtp/index.js +24 -8
  64. package/dist/email/smtp/index.js.map +1 -1
  65. package/dist/orm/core/index.browser.js +0 -18
  66. package/dist/orm/core/index.browser.js.map +1 -1
  67. package/dist/orm/core/index.bun.js +0 -17
  68. package/dist/orm/core/index.bun.js.map +1 -1
  69. package/dist/orm/core/index.d.ts +1 -13
  70. package/dist/orm/core/index.d.ts.map +1 -1
  71. package/dist/orm/core/index.js +0 -17
  72. package/dist/orm/core/index.js.map +1 -1
  73. package/dist/orm/postgres/index.bun.js +3 -3
  74. package/dist/orm/postgres/index.bun.js.map +1 -1
  75. package/dist/orm/postgres/index.d.ts.map +1 -1
  76. package/dist/orm/postgres/index.js +3 -3
  77. package/dist/orm/postgres/index.js.map +1 -1
  78. package/dist/react/router/index.browser.js +25 -3
  79. package/dist/react/router/index.browser.js.map +1 -1
  80. package/dist/react/router/index.d.ts +16 -1
  81. package/dist/react/router/index.d.ts.map +1 -1
  82. package/dist/react/router/index.js +25 -3
  83. package/dist/react/router/index.js.map +1 -1
  84. package/dist/security/index.d.ts +28 -0
  85. package/dist/security/index.d.ts.map +1 -1
  86. package/dist/security/index.js +28 -0
  87. package/dist/security/index.js.map +1 -1
  88. package/package.json +37 -20
  89. package/src/api/invitations/__tests__/InvitationService.spec.ts +439 -0
  90. package/src/api/invitations/controllers/AdminInvitationController.ts +86 -0
  91. package/src/api/invitations/controllers/InvitationController.ts +84 -0
  92. package/src/api/invitations/entities/invitations.ts +33 -0
  93. package/src/api/invitations/index.ts +65 -0
  94. package/src/api/invitations/jobs/InvitationJobs.ts +37 -0
  95. package/src/api/invitations/providers/InvitationProvider.ts +45 -0
  96. package/src/api/invitations/schemas/createInvitationSchema.ts +12 -0
  97. package/src/api/invitations/schemas/invitationConfigAtom.ts +20 -0
  98. package/src/api/invitations/schemas/invitationQuerySchema.ts +15 -0
  99. package/src/api/invitations/schemas/invitationResourceSchema.ts +6 -0
  100. package/src/api/invitations/schemas/invitationWithResourceInfoSchema.ts +22 -0
  101. package/src/api/invitations/schemas/myInvitationsQuerySchema.ts +10 -0
  102. package/src/api/invitations/services/InvitationService.ts +556 -0
  103. package/src/api/jobs/__tests__/$job.spec.ts +876 -0
  104. package/src/api/jobs/controllers/AdminJobController.ts +44 -0
  105. package/src/api/jobs/entities/jobExecutionEntity.ts +0 -2
  106. package/src/api/jobs/index.ts +0 -3
  107. package/src/api/jobs/primitives/$job.ts +22 -11
  108. package/src/api/jobs/providers/JobProvider.ts +229 -19
  109. package/src/api/jobs/schemas/jobConfigAtom.ts +4 -0
  110. package/src/api/jobs/schemas/jobCronInfoSchema.ts +1 -0
  111. package/src/api/jobs/schemas/jobExecutionQuerySchema.ts +0 -1
  112. package/src/api/jobs/schemas/jobQueueDepthSchema.ts +1 -0
  113. package/src/api/jobs/schemas/jobRegistrationSchema.ts +1 -6
  114. package/src/api/jobs/services/JobService.ts +51 -12
  115. package/src/api/notifications/schemas/notificationQuerySchema.ts +0 -1
  116. package/src/api/parameters/__tests__/$parameter.spec.ts +327 -0
  117. package/src/api/parameters/controllers/AdminParameterController.ts +29 -3
  118. package/src/api/parameters/index.browser.ts +12 -0
  119. package/src/api/parameters/primitives/$parameter.ts +20 -3
  120. package/src/api/parameters/services/ParameterProvider.ts +48 -7
  121. package/src/{billing → api/payments}/__tests__/PaymentMethodService.spec.ts +32 -6
  122. package/src/api/payments/__tests__/PaymentService.spec.ts +279 -0
  123. package/src/{billing/controllers/AdminBillingController.ts → api/payments/controllers/AdminPaymentController.ts} +26 -21
  124. package/src/{billing/controllers/BillingController.ts → api/payments/controllers/PaymentController.ts} +23 -11
  125. package/src/{billing → api/payments}/entities/paymentIntents.ts +1 -0
  126. package/src/{billing/errors/BillingError.ts → api/payments/errors/PaymentError.ts} +1 -1
  127. package/src/{billing → api/payments}/index.ts +31 -25
  128. package/src/{billing/providers/MemoryBillingProvider.ts → api/payments/providers/MemoryPaymentProvider.ts} +4 -4
  129. package/src/{billing/providers/BillingProvider.ts → api/payments/providers/PaymentProvider.ts} +9 -2
  130. package/src/{billing → api/payments}/services/PaymentMethodService.ts +5 -5
  131. package/src/{billing/services/BillingService.ts → api/payments/services/PaymentService.ts} +94 -18
  132. package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
  133. package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
  134. package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
  135. package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
  136. package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
  137. package/src/api/subscriptions/entities/subscriptions.ts +68 -0
  138. package/src/api/subscriptions/index.ts +144 -0
  139. package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
  140. package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
  141. package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
  142. package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
  143. package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
  144. package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
  145. package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
  146. package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
  147. package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
  148. package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
  149. package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
  150. package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
  151. package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
  152. package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
  153. package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
  154. package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
  155. package/src/api/subscriptions/services/BillingService.ts +437 -0
  156. package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
  157. package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
  158. package/src/api/subscriptions/services/UsageService.ts +118 -0
  159. package/src/api/users/__tests__/AdminUserController.spec.ts +80 -1
  160. package/src/api/users/__tests__/CredentialService.spec.ts +177 -0
  161. package/src/api/users/__tests__/EmailVerification.spec.ts +29 -18
  162. package/src/api/users/__tests__/PasswordReset.spec.ts +3 -0
  163. package/src/api/users/__tests__/RegistrationService.spec.ts +148 -1
  164. package/src/api/users/__tests__/SessionService.spec.ts +142 -1
  165. package/src/api/users/atoms/realmAuthSettingsAtom.ts +10 -1
  166. package/src/api/users/controllers/UserController.ts +3 -8
  167. package/src/api/users/notifications/UserNotifications.ts +23 -0
  168. package/src/api/users/schemas/loginSchema.ts +1 -1
  169. package/src/api/users/services/CredentialService.ts +51 -4
  170. package/src/api/users/services/RegistrationService.ts +38 -9
  171. package/src/api/users/services/SessionService.ts +62 -9
  172. package/src/api/users/services/UserService.ts +21 -12
  173. package/src/api/workflows/__tests__/$workflow.spec.ts +616 -0
  174. package/src/api/workflows/controllers/AdminWorkflowController.ts +191 -0
  175. package/src/api/workflows/entities/workflowExecutions.ts +74 -0
  176. package/src/api/workflows/entities/workflowStepExecutions.ts +74 -0
  177. package/src/api/workflows/entities/workflowStepLogs.ts +13 -0
  178. package/src/api/workflows/index.browser.ts +22 -0
  179. package/src/api/workflows/index.ts +124 -0
  180. package/src/api/workflows/jobs/WorkflowJobs.ts +77 -0
  181. package/src/api/workflows/primitives/$workflow.ts +202 -0
  182. package/src/api/workflows/providers/WorkflowProvider.ts +1284 -0
  183. package/src/api/workflows/schemas/workflowActivitySchema.ts +15 -0
  184. package/src/api/workflows/schemas/workflowConfigAtom.ts +51 -0
  185. package/src/api/workflows/schemas/workflowExecutionDetailSchema.ts +18 -0
  186. package/src/api/workflows/schemas/workflowExecutionQuerySchema.ts +26 -0
  187. package/src/api/workflows/schemas/workflowExecutionResourceSchema.ts +30 -0
  188. package/src/api/workflows/schemas/workflowRegistrationSchema.ts +26 -0
  189. package/src/api/workflows/schemas/workflowStatsSchema.ts +16 -0
  190. package/src/api/workflows/schemas/workflowStepExecutionResourceSchema.ts +15 -0
  191. package/src/api/workflows/services/WorkflowService.ts +382 -0
  192. package/src/cli/core/templates/webAppRouterTs.ts +5 -58
  193. package/src/cli/vendor/__tests__/VendorService.spec.ts +283 -178
  194. package/src/cli/vendor/services/VendorService.ts +126 -27
  195. package/src/core/__tests__/TypeProvider.spec.ts +4 -2
  196. package/src/core/providers/SchemaValidator.ts +1 -1
  197. package/src/core/providers/TypeProvider.ts +46 -3
  198. package/src/orm/__tests__/enums.spec.ts +22 -29
  199. package/src/orm/__tests__/orm-showcase-tests.ts +430 -0
  200. package/src/orm/__tests__/orm-showcase.spec.ts +167 -0
  201. package/src/orm/core/providers/DatabaseTypeProvider.ts +0 -29
  202. package/src/orm/postgres/services/PostgresModelBuilder.ts +3 -6
  203. package/src/react/router/__tests__/$page.browser.spec.tsx +157 -0
  204. package/src/react/router/providers/ReactBrowserProvider.ts +39 -0
  205. package/src/react/router/providers/ReactBrowserRouterProvider.ts +22 -0
  206. package/src/security/__tests__/$secure-combinations.spec.ts +945 -0
  207. package/src/security/primitives/$secure.ts +28 -0
  208. package/dist/billing/index.d.ts.map +0 -1
  209. package/dist/billing/index.js.map +0 -1
  210. package/src/billing/__tests__/BillingService.spec.ts +0 -136
  211. /package/src/{billing → api/payments}/entities/paymentMethods.ts +0 -0
  212. /package/src/{billing → api/payments}/entities/refunds.ts +0 -0
  213. /package/src/{billing → api/payments}/schemas/intentSchemas.ts +0 -0
  214. /package/src/{billing → api/payments}/schemas/paymentMethodSchemas.ts +0 -0
  215. /package/src/{billing → api/payments}/schemas/refundSchemas.ts +0 -0
@@ -1,76 +1,82 @@
1
1
  import { $module } from "alepha";
2
- import { AdminBillingController } from "./controllers/AdminBillingController.ts";
3
- import { BillingController } from "./controllers/BillingController.ts";
4
- import { BillingProvider } from "./providers/BillingProvider.ts";
5
- import { MemoryBillingProvider } from "./providers/MemoryBillingProvider.ts";
6
- import { BillingService } from "./services/BillingService.ts";
2
+ import { AdminPaymentController } from "./controllers/AdminPaymentController.ts";
3
+ import { PaymentController } from "./controllers/PaymentController.ts";
4
+ import { MemoryPaymentProvider } from "./providers/MemoryPaymentProvider.ts";
5
+ import { PaymentProvider } from "./providers/PaymentProvider.ts";
7
6
  import { PaymentMethodService } from "./services/PaymentMethodService.ts";
7
+ import { PaymentService } from "./services/PaymentService.ts";
8
8
 
9
- export * from "./controllers/AdminBillingController.ts";
10
- export * from "./controllers/BillingController.ts";
9
+ export * from "./controllers/AdminPaymentController.ts";
10
+ export * from "./controllers/PaymentController.ts";
11
11
  export * from "./entities/paymentIntents.ts";
12
12
  export * from "./entities/paymentMethods.ts";
13
13
  export * from "./entities/refunds.ts";
14
- export * from "./errors/BillingError.ts";
15
- export * from "./providers/BillingProvider.ts";
16
- export * from "./providers/MemoryBillingProvider.ts";
14
+ export * from "./errors/PaymentError.ts";
15
+ export * from "./providers/MemoryPaymentProvider.ts";
16
+ export * from "./providers/PaymentProvider.ts";
17
17
  export * from "./schemas/intentSchemas.ts";
18
18
  export * from "./schemas/paymentMethodSchemas.ts";
19
19
  export * from "./schemas/refundSchemas.ts";
20
- export * from "./services/BillingService.ts";
21
20
  export * from "./services/PaymentMethodService.ts";
21
+ export * from "./services/PaymentService.ts";
22
22
 
23
23
  declare module "alepha" {
24
24
  interface Hooks {
25
- "billing:authorized": {
25
+ "payments:authorized": {
26
26
  intentId: string;
27
27
  amount: number;
28
28
  currency: string;
29
29
  metadata?: unknown;
30
30
  };
31
- "billing:captured": {
31
+ "payments:captured": {
32
32
  intentId: string;
33
33
  amount: number;
34
34
  currency: string;
35
35
  metadata?: unknown;
36
36
  };
37
- "billing:failed": {
37
+ "payments:failed": {
38
38
  intentId: string;
39
39
  amount: number;
40
40
  currency: string;
41
41
  metadata?: unknown;
42
42
  };
43
- "billing:voided": {
43
+ "payments:voided": {
44
44
  intentId: string;
45
45
  amount: number;
46
46
  currency: string;
47
47
  metadata?: unknown;
48
48
  };
49
- "billing:refunded": {
49
+ "payments:refunded": {
50
50
  intentId: string;
51
51
  refundId: string;
52
52
  amount: number;
53
53
  currency: string;
54
54
  metadata?: unknown;
55
55
  };
56
+ "payments:cancelled": {
57
+ intentId: string;
58
+ amount: number;
59
+ currency: string;
60
+ metadata?: unknown;
61
+ };
56
62
  }
57
63
  }
58
64
 
59
- export const AlephaBilling = $module({
60
- name: "alepha.billing",
65
+ export const AlephaApiPayments = $module({
66
+ name: "alepha.api.payments",
61
67
  services: [
62
- AdminBillingController,
63
- BillingController,
64
- BillingProvider,
65
- MemoryBillingProvider,
66
- BillingService,
68
+ AdminPaymentController,
69
+ PaymentController,
70
+ PaymentProvider,
71
+ MemoryPaymentProvider,
72
+ PaymentService,
67
73
  PaymentMethodService,
68
74
  ],
69
75
  register: (alepha) => {
70
76
  alepha.with({
71
77
  optional: true,
72
- provide: BillingProvider,
73
- use: MemoryBillingProvider,
78
+ provide: PaymentProvider,
79
+ use: MemoryPaymentProvider,
74
80
  });
75
81
  },
76
82
  });
@@ -1,12 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { PaymentIntentEntity } from "../entities/paymentIntents.ts";
3
3
  import type {
4
- BillingProvider,
5
4
  CreatePaymentMethodResult,
6
5
  CreateSessionResult,
6
+ PaymentProvider,
7
7
  RefundResult,
8
8
  WebhookEvent,
9
- } from "./BillingProvider.ts";
9
+ } from "./PaymentProvider.ts";
10
10
 
11
11
  interface MemoryCharge {
12
12
  providerRef: string;
@@ -20,7 +20,7 @@ interface MemoryRefund {
20
20
  amount: number;
21
21
  }
22
22
 
23
- export class MemoryBillingProvider implements BillingProvider {
23
+ export class MemoryPaymentProvider implements PaymentProvider {
24
24
  protected readonly charges: Map<string, MemoryCharge> = new Map();
25
25
  protected readonly refundRecords: Map<string, MemoryRefund> = new Map();
26
26
  protected readonly methods: Map<string, CreatePaymentMethodResult> =
@@ -39,7 +39,7 @@ export class MemoryBillingProvider implements BillingProvider {
39
39
  status,
40
40
  });
41
41
  return {
42
- url: `/billing/mock-checkout/${intent.id}`,
42
+ url: `/payments/mock-checkout/${intent.id}`,
43
43
  providerRef,
44
44
  };
45
45
  }
@@ -24,7 +24,7 @@ export interface CreatePaymentMethodResult {
24
24
  expYear?: number;
25
25
  }
26
26
 
27
- export abstract class BillingProvider {
27
+ export abstract class PaymentProvider {
28
28
  /**
29
29
  * Create a checkout session with the PSP.
30
30
  * Returns a URL to redirect the user to, and the PSP's reference ID.
@@ -54,7 +54,14 @@ export abstract class BillingProvider {
54
54
  ): Promise<RefundResult>;
55
55
 
56
56
  /**
57
- * Parse an incoming PSP webhook request into a normalized event.
57
+ * Parse and verify an incoming PSP webhook request.
58
+ *
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).
62
+ *
63
+ * Failure to verify signatures allows attackers to forge payment
64
+ * confirmations by POSTing fake webhook events.
58
65
  */
59
66
  abstract parseWebhook(request: Request): Promise<WebhookEvent>;
60
67
 
@@ -5,12 +5,12 @@ import {
5
5
  type PaymentMethodEntity,
6
6
  paymentMethods,
7
7
  } from "../entities/paymentMethods.ts";
8
- import { BillingError } from "../errors/BillingError.ts";
9
- import { BillingProvider } from "../providers/BillingProvider.ts";
8
+ import { PaymentError } from "../errors/PaymentError.ts";
9
+ import { PaymentProvider } from "../providers/PaymentProvider.ts";
10
10
 
11
11
  export class PaymentMethodService {
12
12
  protected readonly log = $logger();
13
- protected readonly provider = $inject(BillingProvider);
13
+ protected readonly provider = $inject(PaymentProvider);
14
14
  protected readonly methodRepo = $repository(paymentMethods);
15
15
 
16
16
  public async addPaymentMethod(
@@ -51,7 +51,7 @@ export class PaymentMethodService {
51
51
  ): Promise<void> {
52
52
  const method = await this.methodRepo.getById(methodId);
53
53
  if (method.userId !== userId) {
54
- throw new BillingError("Cannot remove another user's payment method");
54
+ throw new PaymentError("Cannot remove another user's payment method");
55
55
  }
56
56
 
57
57
  await this.provider.deletePaymentMethod(method.providerRef);
@@ -64,7 +64,7 @@ export class PaymentMethodService {
64
64
  ): Promise<PaymentMethodEntity> {
65
65
  const method = await this.methodRepo.getById(methodId);
66
66
  if (method.userId !== userId) {
67
- throw new BillingError("Cannot modify another user's payment method");
67
+ throw new PaymentError("Cannot modify another user's payment method");
68
68
  }
69
69
 
70
70
  const userMethods = await this.methodRepo.findMany({
@@ -8,14 +8,14 @@ import {
8
8
  paymentIntents,
9
9
  } from "../entities/paymentIntents.ts";
10
10
  import { type RefundEntity, refunds } from "../entities/refunds.ts";
11
- import { BillingError } from "../errors/BillingError.ts";
12
- import { BillingProvider } from "../providers/BillingProvider.ts";
11
+ import { PaymentError } from "../errors/PaymentError.ts";
12
+ import { PaymentProvider } from "../providers/PaymentProvider.ts";
13
13
 
14
- export class BillingService {
14
+ export class PaymentService {
15
15
  protected readonly alepha = $inject(Alepha);
16
16
  protected readonly log = $logger();
17
17
  protected readonly dateTime = $inject(DateTimeProvider);
18
- protected readonly provider = $inject(BillingProvider);
18
+ protected readonly provider = $inject(PaymentProvider);
19
19
  protected readonly intentRepo = $repository(paymentIntents);
20
20
  protected readonly refundRepo = $repository(refunds);
21
21
 
@@ -60,7 +60,7 @@ export class BillingService {
60
60
  ): Promise<PaymentIntentEntity> {
61
61
  return await this.intentRepo.create({
62
62
  amount,
63
- currency,
63
+ currency: currency.toLowerCase(),
64
64
  status: "created",
65
65
  metadata: metadata as any,
66
66
  paymentMethodId: options?.paymentMethodId,
@@ -76,10 +76,21 @@ export class BillingService {
76
76
  intentId: string,
77
77
  returnUrl: string,
78
78
  authorize?: boolean,
79
+ userId?: string,
79
80
  ): Promise<{ url: string; intentId: string }> {
80
81
  const intent = await this.getIntent(intentId);
81
82
  this.assertStatus(intent, "created", "createSession");
82
83
 
84
+ // Verify intent ownership if userId is provided
85
+ if (userId && intent.userId && intent.userId !== userId) {
86
+ throw new PaymentError("Payment intent does not belong to this user");
87
+ }
88
+
89
+ // Associate intent with user if not already set
90
+ if (userId && !intent.userId) {
91
+ await this.intentRepo.updateById(intent.id, { userId });
92
+ }
93
+
83
94
  const result = await this.provider.createSession(intent, {
84
95
  returnUrl,
85
96
  authorize,
@@ -114,8 +125,20 @@ export class BillingService {
114
125
 
115
126
  /**
116
127
  * Process a webhook event by updating the intent status and emitting
117
- * the corresponding billing event.
128
+ * the corresponding payment event.
118
129
  */
130
+ /**
131
+ * Valid status transitions from webhook events.
132
+ * Only these transitions are allowed — all others are silently ignored.
133
+ */
134
+ protected static readonly VALID_WEBHOOK_TRANSITIONS: Record<
135
+ string,
136
+ string[]
137
+ > = {
138
+ processing: ["authorized", "captured", "failed"],
139
+ authorized: ["captured", "failed"],
140
+ };
141
+
119
142
  public async handleWebhookEvent(
120
143
  intentId: string,
121
144
  status: string,
@@ -124,9 +147,9 @@ export class BillingService {
124
147
  const intent = await this.getIntent(intentId);
125
148
 
126
149
  const eventMap = {
127
- authorized: "billing:authorized",
128
- captured: "billing:captured",
129
- failed: "billing:failed",
150
+ authorized: "payments:authorized",
151
+ captured: "payments:captured",
152
+ failed: "payments:failed",
130
153
  } as const;
131
154
 
132
155
  type WebhookStatus = keyof typeof eventMap;
@@ -137,6 +160,16 @@ export class BillingService {
137
160
 
138
161
  const webhookStatus = status as WebhookStatus;
139
162
 
163
+ // Validate status transition
164
+ const allowed = PaymentService.VALID_WEBHOOK_TRANSITIONS[intent.status];
165
+ if (!allowed?.includes(webhookStatus)) {
166
+ this.log.warn(
167
+ `Ignoring webhook: cannot transition ${intent.status} → ${webhookStatus}`,
168
+ { intentId: intent.id },
169
+ );
170
+ return;
171
+ }
172
+
140
173
  await this.intentRepo.updateById(intent.id, {
141
174
  status: webhookStatus,
142
175
  providerRaw: raw as any,
@@ -162,6 +195,12 @@ export class BillingService {
162
195
  this.assertStatus(intent, "authorized", "capture");
163
196
 
164
197
  const amount = finalAmount ?? intent.amount;
198
+ if (amount > intent.amount) {
199
+ throw new PaymentError(
200
+ `Capture amount ${amount} exceeds authorized amount ${intent.amount}`,
201
+ );
202
+ }
203
+
165
204
  if (intent.providerRef) {
166
205
  await this.provider.capturePayment(intent.providerRef, amount);
167
206
  }
@@ -171,7 +210,7 @@ export class BillingService {
171
210
  amount,
172
211
  });
173
212
 
174
- await this.alepha.events.emit("billing:captured", {
213
+ await this.alepha.events.emit("payments:captured", {
175
214
  intentId: intent.id,
176
215
  amount,
177
216
  currency: intent.currency,
@@ -196,7 +235,7 @@ export class BillingService {
196
235
  status: "voided",
197
236
  });
198
237
 
199
- await this.alepha.events.emit("billing:voided", {
238
+ await this.alepha.events.emit("payments:voided", {
200
239
  intentId: intent.id,
201
240
  amount: intent.amount,
202
241
  currency: intent.currency,
@@ -215,7 +254,29 @@ export class BillingService {
215
254
  reason?: string,
216
255
  ): Promise<RefundEntity> {
217
256
  const intent = await this.getIntent(intentId);
218
- this.assertStatus(intent, "captured", "refund");
257
+
258
+ // Allow refunds from both "captured" and "partially_refunded" states
259
+ if (
260
+ intent.status !== "captured" &&
261
+ intent.status !== "partially_refunded"
262
+ ) {
263
+ throw new PaymentError(
264
+ `Cannot refund: intent ${intent.id} is '${intent.status}', expected 'captured' or 'partially_refunded'`,
265
+ );
266
+ }
267
+
268
+ // Validate refund amount against remaining refundable amount
269
+ const existingRefunds = await this.refundRepo.findMany({
270
+ where: { intentId: { eq: intent.id } },
271
+ });
272
+ const totalRefunded = existingRefunds.reduce((sum, r) => sum + r.amount, 0);
273
+ const remaining = intent.amount - totalRefunded;
274
+
275
+ if (amount > remaining) {
276
+ throw new PaymentError(
277
+ `Refund amount ${amount} exceeds remaining refundable amount ${remaining}`,
278
+ );
279
+ }
219
280
 
220
281
  let refundProviderRef: string | undefined;
221
282
  if (intent.providerRef) {
@@ -236,9 +297,15 @@ export class BillingService {
236
297
  providerRef: refundProviderRef,
237
298
  });
238
299
 
239
- await this.intentRepo.updateById(intent.id, { status: "refunded" });
300
+ // Set status based on whether fully or partially refunded
301
+ const newTotalRefunded = totalRefunded + amount;
302
+ const newStatus =
303
+ newTotalRefunded >= intent.amount ? "refunded" : "partially_refunded";
304
+ await this.intentRepo.updateById(intent.id, {
305
+ status: newStatus,
306
+ });
240
307
 
241
- await this.alepha.events.emit("billing:refunded", {
308
+ await this.alepha.events.emit("payments:refunded", {
242
309
  intentId: intent.id,
243
310
  refundId: refund.id,
244
311
  amount,
@@ -260,12 +327,12 @@ export class BillingService {
260
327
  ): Promise<PaymentIntentEntity> {
261
328
  const intent = await this.intentRepo.create({
262
329
  amount,
263
- currency,
330
+ currency: currency.toLowerCase(),
264
331
  status: "captured",
265
332
  metadata: metadata as any,
266
333
  });
267
334
 
268
- await this.alepha.events.emit("billing:captured", {
335
+ await this.alepha.events.emit("payments:captured", {
269
336
  intentId: intent.id,
270
337
  amount,
271
338
  currency,
@@ -282,9 +349,18 @@ export class BillingService {
282
349
  const intent = await this.getIntent(intentId);
283
350
  this.assertStatus(intent, "created", "cancel");
284
351
 
285
- return await this.intentRepo.updateById(intent.id, {
352
+ const cancelled = await this.intentRepo.updateById(intent.id, {
286
353
  status: "cancelled",
287
354
  });
355
+
356
+ await this.alepha.events.emit("payments:cancelled", {
357
+ intentId: intent.id,
358
+ amount: intent.amount,
359
+ currency: intent.currency,
360
+ metadata: intent.metadata,
361
+ });
362
+
363
+ return cancelled;
288
364
  }
289
365
 
290
366
  /**
@@ -317,7 +393,7 @@ export class BillingService {
317
393
  operation: string,
318
394
  ): void {
319
395
  if (intent.status !== expected) {
320
- throw new BillingError(
396
+ throw new PaymentError(
321
397
  `Cannot ${operation}: intent ${intent.id} is '${intent.status}', expected '${expected}'`,
322
398
  );
323
399
  }
@@ -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
+ });