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.
- package/assets/swagger-ui/swagger-ui-bundle.js +1 -1
- package/dist/api/audits/index.d.ts +8 -8
- package/dist/api/invitations/index.d.ts +790 -0
- package/dist/api/invitations/index.d.ts.map +1 -0
- package/dist/api/invitations/index.js +665 -0
- package/dist/api/invitations/index.js.map +1 -0
- package/dist/api/jobs/index.browser.js +8 -9
- package/dist/api/jobs/index.browser.js.map +1 -1
- package/dist/api/jobs/index.d.ts +99 -43
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +257 -40
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/keys/index.d.ts +5 -5
- package/dist/api/notifications/index.browser.js +0 -1
- package/dist/api/notifications/index.browser.js.map +1 -1
- package/dist/api/notifications/index.d.ts +3 -3
- package/dist/api/notifications/index.d.ts.map +1 -1
- package/dist/api/notifications/index.js +0 -1
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/parameters/index.browser.js +112 -1
- package/dist/api/parameters/index.browser.js.map +1 -1
- package/dist/api/parameters/index.d.ts +90 -3
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +79 -12
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/{billing → api/payments}/index.d.ts +67 -49
- package/dist/api/payments/index.d.ts.map +1 -0
- package/dist/{billing → api/payments}/index.js +108 -74
- package/dist/api/payments/index.js.map +1 -0
- package/dist/api/subscriptions/index.d.ts +1692 -0
- package/dist/api/subscriptions/index.d.ts.map +1 -0
- package/dist/api/subscriptions/index.js +1870 -0
- package/dist/api/subscriptions/index.js.map +1 -0
- package/dist/api/users/index.d.ts +18 -2
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +167 -34
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +13 -13
- package/dist/api/workflows/index.browser.js +246 -0
- package/dist/api/workflows/index.browser.js.map +1 -0
- package/dist/api/workflows/index.d.ts +1618 -0
- package/dist/api/workflows/index.d.ts.map +1 -0
- package/dist/api/workflows/index.js +1504 -0
- package/dist/api/workflows/index.js.map +1 -0
- package/dist/cli/core/index.d.ts +44 -28
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +16 -61
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/vendor/index.d.ts +31 -8
- package/dist/cli/vendor/index.d.ts.map +1 -1
- package/dist/cli/vendor/index.js +79 -24
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/core/index.browser.js +21 -2
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.d.ts +33 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +21 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +21 -2
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js +21 -2
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/email/smtp/index.js +24 -8
- package/dist/email/smtp/index.js.map +1 -1
- package/dist/orm/core/index.browser.js +0 -18
- package/dist/orm/core/index.browser.js.map +1 -1
- package/dist/orm/core/index.bun.js +0 -17
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.d.ts +1 -13
- package/dist/orm/core/index.d.ts.map +1 -1
- package/dist/orm/core/index.js +0 -17
- package/dist/orm/core/index.js.map +1 -1
- package/dist/orm/postgres/index.bun.js +3 -3
- package/dist/orm/postgres/index.bun.js.map +1 -1
- package/dist/orm/postgres/index.d.ts.map +1 -1
- package/dist/orm/postgres/index.js +3 -3
- package/dist/orm/postgres/index.js.map +1 -1
- package/dist/react/router/index.browser.js +25 -3
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +16 -1
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +25 -3
- package/dist/react/router/index.js.map +1 -1
- package/dist/security/index.d.ts +28 -0
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +28 -0
- package/dist/security/index.js.map +1 -1
- package/package.json +37 -20
- package/src/api/invitations/__tests__/InvitationService.spec.ts +439 -0
- package/src/api/invitations/controllers/AdminInvitationController.ts +86 -0
- package/src/api/invitations/controllers/InvitationController.ts +84 -0
- package/src/api/invitations/entities/invitations.ts +33 -0
- package/src/api/invitations/index.ts +65 -0
- package/src/api/invitations/jobs/InvitationJobs.ts +37 -0
- package/src/api/invitations/providers/InvitationProvider.ts +45 -0
- package/src/api/invitations/schemas/createInvitationSchema.ts +12 -0
- package/src/api/invitations/schemas/invitationConfigAtom.ts +20 -0
- package/src/api/invitations/schemas/invitationQuerySchema.ts +15 -0
- package/src/api/invitations/schemas/invitationResourceSchema.ts +6 -0
- package/src/api/invitations/schemas/invitationWithResourceInfoSchema.ts +22 -0
- package/src/api/invitations/schemas/myInvitationsQuerySchema.ts +10 -0
- package/src/api/invitations/services/InvitationService.ts +556 -0
- package/src/api/jobs/__tests__/$job.spec.ts +876 -0
- package/src/api/jobs/controllers/AdminJobController.ts +44 -0
- package/src/api/jobs/entities/jobExecutionEntity.ts +0 -2
- package/src/api/jobs/index.ts +0 -3
- package/src/api/jobs/primitives/$job.ts +22 -11
- package/src/api/jobs/providers/JobProvider.ts +229 -19
- package/src/api/jobs/schemas/jobConfigAtom.ts +4 -0
- package/src/api/jobs/schemas/jobCronInfoSchema.ts +1 -0
- package/src/api/jobs/schemas/jobExecutionQuerySchema.ts +0 -1
- package/src/api/jobs/schemas/jobQueueDepthSchema.ts +1 -0
- package/src/api/jobs/schemas/jobRegistrationSchema.ts +1 -6
- package/src/api/jobs/services/JobService.ts +51 -12
- package/src/api/notifications/schemas/notificationQuerySchema.ts +0 -1
- package/src/api/parameters/__tests__/$parameter.spec.ts +327 -0
- package/src/api/parameters/controllers/AdminParameterController.ts +29 -3
- package/src/api/parameters/index.browser.ts +12 -0
- package/src/api/parameters/primitives/$parameter.ts +20 -3
- package/src/api/parameters/services/ParameterProvider.ts +48 -7
- package/src/{billing → api/payments}/__tests__/PaymentMethodService.spec.ts +32 -6
- package/src/api/payments/__tests__/PaymentService.spec.ts +279 -0
- package/src/{billing/controllers/AdminBillingController.ts → api/payments/controllers/AdminPaymentController.ts} +26 -21
- package/src/{billing/controllers/BillingController.ts → api/payments/controllers/PaymentController.ts} +23 -11
- package/src/{billing → api/payments}/entities/paymentIntents.ts +1 -0
- package/src/{billing/errors/BillingError.ts → api/payments/errors/PaymentError.ts} +1 -1
- package/src/{billing → api/payments}/index.ts +31 -25
- package/src/{billing/providers/MemoryBillingProvider.ts → api/payments/providers/MemoryPaymentProvider.ts} +4 -4
- package/src/{billing/providers/BillingProvider.ts → api/payments/providers/PaymentProvider.ts} +9 -2
- package/src/{billing → api/payments}/services/PaymentMethodService.ts +5 -5
- package/src/{billing/services/BillingService.ts → api/payments/services/PaymentService.ts} +94 -18
- package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
- package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
- package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
- package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
- package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
- package/src/api/subscriptions/entities/subscriptions.ts +68 -0
- package/src/api/subscriptions/index.ts +144 -0
- package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
- package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
- package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
- package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
- package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
- package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
- package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
- package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
- package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
- package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
- package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
- package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
- package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
- package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
- package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
- package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
- package/src/api/subscriptions/services/BillingService.ts +437 -0
- package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
- package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
- package/src/api/subscriptions/services/UsageService.ts +118 -0
- package/src/api/users/__tests__/AdminUserController.spec.ts +80 -1
- package/src/api/users/__tests__/CredentialService.spec.ts +177 -0
- package/src/api/users/__tests__/EmailVerification.spec.ts +29 -18
- package/src/api/users/__tests__/PasswordReset.spec.ts +3 -0
- package/src/api/users/__tests__/RegistrationService.spec.ts +148 -1
- package/src/api/users/__tests__/SessionService.spec.ts +142 -1
- package/src/api/users/atoms/realmAuthSettingsAtom.ts +10 -1
- package/src/api/users/controllers/UserController.ts +3 -8
- package/src/api/users/notifications/UserNotifications.ts +23 -0
- package/src/api/users/schemas/loginSchema.ts +1 -1
- package/src/api/users/services/CredentialService.ts +51 -4
- package/src/api/users/services/RegistrationService.ts +38 -9
- package/src/api/users/services/SessionService.ts +62 -9
- package/src/api/users/services/UserService.ts +21 -12
- package/src/api/workflows/__tests__/$workflow.spec.ts +616 -0
- package/src/api/workflows/controllers/AdminWorkflowController.ts +191 -0
- package/src/api/workflows/entities/workflowExecutions.ts +74 -0
- package/src/api/workflows/entities/workflowStepExecutions.ts +74 -0
- package/src/api/workflows/entities/workflowStepLogs.ts +13 -0
- package/src/api/workflows/index.browser.ts +22 -0
- package/src/api/workflows/index.ts +124 -0
- package/src/api/workflows/jobs/WorkflowJobs.ts +77 -0
- package/src/api/workflows/primitives/$workflow.ts +202 -0
- package/src/api/workflows/providers/WorkflowProvider.ts +1284 -0
- package/src/api/workflows/schemas/workflowActivitySchema.ts +15 -0
- package/src/api/workflows/schemas/workflowConfigAtom.ts +51 -0
- package/src/api/workflows/schemas/workflowExecutionDetailSchema.ts +18 -0
- package/src/api/workflows/schemas/workflowExecutionQuerySchema.ts +26 -0
- package/src/api/workflows/schemas/workflowExecutionResourceSchema.ts +30 -0
- package/src/api/workflows/schemas/workflowRegistrationSchema.ts +26 -0
- package/src/api/workflows/schemas/workflowStatsSchema.ts +16 -0
- package/src/api/workflows/schemas/workflowStepExecutionResourceSchema.ts +15 -0
- package/src/api/workflows/services/WorkflowService.ts +382 -0
- package/src/cli/core/templates/webAppRouterTs.ts +5 -58
- package/src/cli/vendor/__tests__/VendorService.spec.ts +283 -178
- package/src/cli/vendor/services/VendorService.ts +126 -27
- package/src/core/__tests__/TypeProvider.spec.ts +4 -2
- package/src/core/providers/SchemaValidator.ts +1 -1
- package/src/core/providers/TypeProvider.ts +46 -3
- package/src/orm/__tests__/enums.spec.ts +22 -29
- package/src/orm/__tests__/orm-showcase-tests.ts +430 -0
- package/src/orm/__tests__/orm-showcase.spec.ts +167 -0
- package/src/orm/core/providers/DatabaseTypeProvider.ts +0 -29
- package/src/orm/postgres/services/PostgresModelBuilder.ts +3 -6
- package/src/react/router/__tests__/$page.browser.spec.tsx +157 -0
- package/src/react/router/providers/ReactBrowserProvider.ts +39 -0
- package/src/react/router/providers/ReactBrowserRouterProvider.ts +22 -0
- package/src/security/__tests__/$secure-combinations.spec.ts +945 -0
- package/src/security/primitives/$secure.ts +28 -0
- package/dist/billing/index.d.ts.map +0 -1
- package/dist/billing/index.js.map +0 -1
- package/src/billing/__tests__/BillingService.spec.ts +0 -136
- /package/src/{billing → api/payments}/entities/paymentMethods.ts +0 -0
- /package/src/{billing → api/payments}/entities/refunds.ts +0 -0
- /package/src/{billing → api/payments}/schemas/intentSchemas.ts +0 -0
- /package/src/{billing → api/payments}/schemas/paymentMethodSchemas.ts +0 -0
- /package/src/{billing → api/payments}/schemas/refundSchemas.ts +0 -0
|
@@ -1,76 +1,82 @@
|
|
|
1
1
|
import { $module } from "alepha";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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/
|
|
10
|
-
export * from "./controllers/
|
|
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/
|
|
15
|
-
export * from "./providers/
|
|
16
|
-
export * from "./providers/
|
|
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
|
-
"
|
|
25
|
+
"payments:authorized": {
|
|
26
26
|
intentId: string;
|
|
27
27
|
amount: number;
|
|
28
28
|
currency: string;
|
|
29
29
|
metadata?: unknown;
|
|
30
30
|
};
|
|
31
|
-
"
|
|
31
|
+
"payments:captured": {
|
|
32
32
|
intentId: string;
|
|
33
33
|
amount: number;
|
|
34
34
|
currency: string;
|
|
35
35
|
metadata?: unknown;
|
|
36
36
|
};
|
|
37
|
-
"
|
|
37
|
+
"payments:failed": {
|
|
38
38
|
intentId: string;
|
|
39
39
|
amount: number;
|
|
40
40
|
currency: string;
|
|
41
41
|
metadata?: unknown;
|
|
42
42
|
};
|
|
43
|
-
"
|
|
43
|
+
"payments:voided": {
|
|
44
44
|
intentId: string;
|
|
45
45
|
amount: number;
|
|
46
46
|
currency: string;
|
|
47
47
|
metadata?: unknown;
|
|
48
48
|
};
|
|
49
|
-
"
|
|
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
|
|
60
|
-
name: "alepha.
|
|
65
|
+
export const AlephaApiPayments = $module({
|
|
66
|
+
name: "alepha.api.payments",
|
|
61
67
|
services: [
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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:
|
|
73
|
-
use:
|
|
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 "./
|
|
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
|
|
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: `/
|
|
42
|
+
url: `/payments/mock-checkout/${intent.id}`,
|
|
43
43
|
providerRef,
|
|
44
44
|
};
|
|
45
45
|
}
|
package/src/{billing/providers/BillingProvider.ts → api/payments/providers/PaymentProvider.ts}
RENAMED
|
@@ -24,7 +24,7 @@ export interface CreatePaymentMethodResult {
|
|
|
24
24
|
expYear?: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export abstract class
|
|
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
|
|
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 {
|
|
9
|
-
import {
|
|
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(
|
|
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
|
|
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
|
|
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 {
|
|
12
|
-
import {
|
|
11
|
+
import { PaymentError } from "../errors/PaymentError.ts";
|
|
12
|
+
import { PaymentProvider } from "../providers/PaymentProvider.ts";
|
|
13
13
|
|
|
14
|
-
export class
|
|
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(
|
|
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
|
|
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: "
|
|
128
|
-
captured: "
|
|
129
|
-
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|