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
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { $hook, $inject, Alepha } from "alepha";
|
|
2
|
+
import { PaymentService } from "alepha/api/payments";
|
|
3
|
+
import { DateTimeProvider } from "alepha/datetime";
|
|
4
|
+
import { $logger } from "alepha/logger";
|
|
5
|
+
import { $repository } from "alepha/orm";
|
|
6
|
+
import type { SubscriptionEventEntity } from "../entities/subscriptionEvents.ts";
|
|
7
|
+
import { subscriptionEvents } from "../entities/subscriptionEvents.ts";
|
|
8
|
+
import {
|
|
9
|
+
type SubscriptionEntity,
|
|
10
|
+
subscriptions,
|
|
11
|
+
} from "../entities/subscriptions.ts";
|
|
12
|
+
import { SubscriptionConfig } from "./SubscriptionConfig.ts";
|
|
13
|
+
|
|
14
|
+
// -----------------------------------------------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
interface PaymentEvent {
|
|
17
|
+
intentId: string;
|
|
18
|
+
amount: number;
|
|
19
|
+
currency: string;
|
|
20
|
+
metadata?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// -----------------------------------------------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
interface EventContext {
|
|
26
|
+
previousStatus?: string;
|
|
27
|
+
newStatus?: string;
|
|
28
|
+
previousPlanId?: string;
|
|
29
|
+
newPlanId?: string;
|
|
30
|
+
paymentIntentId?: string;
|
|
31
|
+
amount?: number;
|
|
32
|
+
currency?: string;
|
|
33
|
+
triggeredBy?: string;
|
|
34
|
+
userId?: string;
|
|
35
|
+
note?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// -----------------------------------------------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export class BillingService {
|
|
41
|
+
protected readonly alepha = $inject(Alepha);
|
|
42
|
+
protected readonly log = $logger();
|
|
43
|
+
protected readonly dateTime = $inject(DateTimeProvider);
|
|
44
|
+
protected readonly subscriptionRepo = $repository(subscriptions);
|
|
45
|
+
protected readonly eventRepo = $repository(subscriptionEvents);
|
|
46
|
+
protected readonly paymentService = $inject(PaymentService);
|
|
47
|
+
protected readonly config = $inject(SubscriptionConfig);
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
50
|
+
// Payment hook listeners
|
|
51
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* React to successful payment capture.
|
|
55
|
+
* Routes to the appropriate handler based on subscription status.
|
|
56
|
+
*/
|
|
57
|
+
protected readonly onPaymentCaptured = $hook({
|
|
58
|
+
on: "payments:captured",
|
|
59
|
+
handler: async (event) => {
|
|
60
|
+
const sub = await this.findByPaymentIntent(event.intentId);
|
|
61
|
+
if (!sub) return;
|
|
62
|
+
|
|
63
|
+
if (sub.status === "trialing") {
|
|
64
|
+
await this.activate(sub, event);
|
|
65
|
+
} else if (sub.status === "active") {
|
|
66
|
+
await this.renew(sub, event);
|
|
67
|
+
} else if (sub.status === "past_due") {
|
|
68
|
+
await this.recoverFromDunning(sub, event);
|
|
69
|
+
} else if (sub.status === "suspended") {
|
|
70
|
+
await this.reactivateFromPayment(sub, event);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* React to failed payment.
|
|
77
|
+
* Starts or advances the dunning flow.
|
|
78
|
+
*/
|
|
79
|
+
protected readonly onPaymentFailed = $hook({
|
|
80
|
+
on: "payments:failed",
|
|
81
|
+
handler: async (event) => {
|
|
82
|
+
const sub = await this.findByPaymentIntent(event.intentId);
|
|
83
|
+
if (!sub) return;
|
|
84
|
+
|
|
85
|
+
await this.handlePaymentFailure(sub, event);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
90
|
+
// Lookup
|
|
91
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Find a subscription by its last payment intent ID.
|
|
95
|
+
* Returns null if no subscription matches.
|
|
96
|
+
*/
|
|
97
|
+
protected async findByPaymentIntent(
|
|
98
|
+
intentId: string,
|
|
99
|
+
): Promise<SubscriptionEntity | null> {
|
|
100
|
+
const sub = await this.subscriptionRepo.findOne({
|
|
101
|
+
where: { lastPaymentIntentId: { eq: intentId } },
|
|
102
|
+
});
|
|
103
|
+
return sub ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
107
|
+
// Lifecycle transitions
|
|
108
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Trial to active transition.
|
|
112
|
+
* Sets the first paid billing period and records activation events.
|
|
113
|
+
*/
|
|
114
|
+
protected async activate(
|
|
115
|
+
sub: SubscriptionEntity,
|
|
116
|
+
event: PaymentEvent,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const orgId = sub.organizationId as string;
|
|
119
|
+
const now = this.dateTime.now();
|
|
120
|
+
const nowISO = now.toISOString();
|
|
121
|
+
const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
|
|
122
|
+
|
|
123
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
124
|
+
status: "active",
|
|
125
|
+
lastPaymentAt: nowISO,
|
|
126
|
+
lastPaymentIntentId: event.intentId,
|
|
127
|
+
currentPeriodStart: nowISO,
|
|
128
|
+
currentPeriodEnd: periodEnd,
|
|
129
|
+
nextBillingAt: periodEnd,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await this.recordEvent(sub.id, orgId, "trial_ended", {
|
|
133
|
+
previousStatus: "trialing",
|
|
134
|
+
newStatus: "active",
|
|
135
|
+
paymentIntentId: event.intentId,
|
|
136
|
+
amount: event.amount,
|
|
137
|
+
currency: event.currency,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await this.recordEvent(sub.id, orgId, "activated", {
|
|
141
|
+
previousStatus: "trialing",
|
|
142
|
+
newStatus: "active",
|
|
143
|
+
paymentIntentId: event.intentId,
|
|
144
|
+
amount: event.amount,
|
|
145
|
+
currency: event.currency,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
this.log.info("Subscription activated from trial", {
|
|
149
|
+
id: sub.id,
|
|
150
|
+
organizationId: orgId,
|
|
151
|
+
planId: sub.planId,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await this.alepha.events.emit("subscription:activated" as any, {
|
|
155
|
+
subscriptionId: sub.id,
|
|
156
|
+
organizationId: orgId,
|
|
157
|
+
planId: sub.planId,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Active to active cycle renewal.
|
|
165
|
+
* Applies any pending plan change, then advances the billing period.
|
|
166
|
+
*/
|
|
167
|
+
protected async renew(
|
|
168
|
+
sub: SubscriptionEntity,
|
|
169
|
+
event: PaymentEvent,
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
const orgId = sub.organizationId as string;
|
|
172
|
+
let effectivePlanId = sub.planId;
|
|
173
|
+
let effectiveInterval = sub.interval;
|
|
174
|
+
|
|
175
|
+
if (sub.pendingPlanId) {
|
|
176
|
+
effectivePlanId = sub.pendingPlanId;
|
|
177
|
+
effectiveInterval = sub.pendingInterval ?? sub.interval;
|
|
178
|
+
|
|
179
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
180
|
+
planId: effectivePlanId,
|
|
181
|
+
interval: effectiveInterval,
|
|
182
|
+
pendingPlanId: undefined,
|
|
183
|
+
pendingInterval: undefined,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await this.recordEvent(sub.id, orgId, "plan_changed", {
|
|
187
|
+
previousPlanId: sub.planId,
|
|
188
|
+
newPlanId: effectivePlanId,
|
|
189
|
+
note: `Plan changed on renewal from '${sub.planId}' to '${effectivePlanId}'`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const newPeriodStart = sub.currentPeriodEnd;
|
|
194
|
+
const newPeriodEnd = this.computeIntervalEnd(
|
|
195
|
+
newPeriodStart,
|
|
196
|
+
effectiveInterval,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
200
|
+
currentPeriodStart: newPeriodStart,
|
|
201
|
+
currentPeriodEnd: newPeriodEnd,
|
|
202
|
+
lastPaymentAt: this.dateTime.now().toISOString(),
|
|
203
|
+
lastPaymentIntentId: event.intentId,
|
|
204
|
+
nextBillingAt: newPeriodEnd,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await this.recordEvent(sub.id, orgId, "renewed", {
|
|
208
|
+
paymentIntentId: event.intentId,
|
|
209
|
+
amount: event.amount,
|
|
210
|
+
currency: event.currency,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
this.log.info("Subscription renewed", {
|
|
214
|
+
id: sub.id,
|
|
215
|
+
organizationId: orgId,
|
|
216
|
+
planId: effectivePlanId,
|
|
217
|
+
periodEnd: newPeriodEnd,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await this.alepha.events.emit("subscription:renewed" as any, {
|
|
221
|
+
subscriptionId: sub.id,
|
|
222
|
+
organizationId: orgId,
|
|
223
|
+
planId: effectivePlanId,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Recover from dunning: past_due to active.
|
|
231
|
+
* Resets all dunning state and records reactivation.
|
|
232
|
+
*/
|
|
233
|
+
protected async recoverFromDunning(
|
|
234
|
+
sub: SubscriptionEntity,
|
|
235
|
+
event: PaymentEvent,
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
const orgId = sub.organizationId as string;
|
|
238
|
+
const nowISO = this.dateTime.now().toISOString();
|
|
239
|
+
|
|
240
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
241
|
+
status: "active",
|
|
242
|
+
lastPaymentAt: nowISO,
|
|
243
|
+
lastPaymentIntentId: event.intentId,
|
|
244
|
+
dunningStartedAt: undefined,
|
|
245
|
+
dunningAttempt: 0,
|
|
246
|
+
dunningNextRetryAt: undefined,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await this.recordEvent(sub.id, orgId, "reactivated", {
|
|
250
|
+
previousStatus: "past_due",
|
|
251
|
+
newStatus: "active",
|
|
252
|
+
paymentIntentId: event.intentId,
|
|
253
|
+
amount: event.amount,
|
|
254
|
+
currency: event.currency,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
this.log.info("Subscription recovered from dunning", {
|
|
258
|
+
id: sub.id,
|
|
259
|
+
organizationId: orgId,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await this.alepha.events.emit("subscription:reactivated" as any, {
|
|
263
|
+
subscriptionId: sub.id,
|
|
264
|
+
organizationId: orgId,
|
|
265
|
+
planId: sub.planId,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Reactivate from suspended state after a successful payment.
|
|
273
|
+
* Resets dunning, sets a fresh billing period, and records reactivation.
|
|
274
|
+
*/
|
|
275
|
+
protected async reactivateFromPayment(
|
|
276
|
+
sub: SubscriptionEntity,
|
|
277
|
+
event: PaymentEvent,
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
const orgId = sub.organizationId as string;
|
|
280
|
+
const now = this.dateTime.now();
|
|
281
|
+
const nowISO = now.toISOString();
|
|
282
|
+
const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
|
|
283
|
+
|
|
284
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
285
|
+
status: "active",
|
|
286
|
+
currentPeriodStart: nowISO,
|
|
287
|
+
currentPeriodEnd: periodEnd,
|
|
288
|
+
nextBillingAt: periodEnd,
|
|
289
|
+
lastPaymentAt: nowISO,
|
|
290
|
+
lastPaymentIntentId: event.intentId,
|
|
291
|
+
dunningStartedAt: undefined,
|
|
292
|
+
dunningAttempt: 0,
|
|
293
|
+
dunningNextRetryAt: undefined,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await this.recordEvent(sub.id, orgId, "reactivated", {
|
|
297
|
+
previousStatus: "suspended",
|
|
298
|
+
newStatus: "active",
|
|
299
|
+
paymentIntentId: event.intentId,
|
|
300
|
+
amount: event.amount,
|
|
301
|
+
currency: event.currency,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
this.log.info("Subscription reactivated from suspended", {
|
|
305
|
+
id: sub.id,
|
|
306
|
+
organizationId: orgId,
|
|
307
|
+
planId: sub.planId,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await this.alepha.events.emit("subscription:reactivated" as any, {
|
|
311
|
+
subscriptionId: sub.id,
|
|
312
|
+
organizationId: orgId,
|
|
313
|
+
planId: sub.planId,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Handle a failed payment: start or advance dunning.
|
|
321
|
+
* Updates dunning state and transitions to past_due if needed.
|
|
322
|
+
*/
|
|
323
|
+
protected async handlePaymentFailure(
|
|
324
|
+
sub: SubscriptionEntity,
|
|
325
|
+
event: PaymentEvent,
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
const orgId = sub.organizationId as string;
|
|
328
|
+
const now = this.dateTime.now();
|
|
329
|
+
const nowISO = now.toISOString();
|
|
330
|
+
const settings = await this.config.getSettings();
|
|
331
|
+
const schedule = settings.dunningSchedule;
|
|
332
|
+
|
|
333
|
+
let attempt: number;
|
|
334
|
+
|
|
335
|
+
if (sub.dunningAttempt === 0) {
|
|
336
|
+
attempt = 1;
|
|
337
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
338
|
+
dunningStartedAt: nowISO,
|
|
339
|
+
dunningAttempt: attempt,
|
|
340
|
+
});
|
|
341
|
+
} else {
|
|
342
|
+
attempt = sub.dunningAttempt + 1;
|
|
343
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
344
|
+
dunningAttempt: attempt,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (sub.status !== "past_due") {
|
|
349
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
350
|
+
status: "past_due",
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await this.recordEvent(sub.id, orgId, "past_due", {
|
|
354
|
+
previousStatus: sub.status,
|
|
355
|
+
newStatus: "past_due",
|
|
356
|
+
paymentIntentId: event.intentId,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const scheduleDays = schedule[attempt - 1];
|
|
361
|
+
|
|
362
|
+
if (scheduleDays !== undefined) {
|
|
363
|
+
const nextRetry = now.add(scheduleDays, "days").toISOString();
|
|
364
|
+
|
|
365
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
366
|
+
dunningNextRetryAt: nextRetry,
|
|
367
|
+
});
|
|
368
|
+
} else {
|
|
369
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
370
|
+
dunningNextRetryAt: undefined,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await this.recordEvent(sub.id, orgId, "payment_failed", {
|
|
375
|
+
paymentIntentId: event.intentId,
|
|
376
|
+
amount: event.amount,
|
|
377
|
+
currency: event.currency,
|
|
378
|
+
note: `Dunning attempt ${attempt}/${schedule.length}`,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
this.log.warn("Subscription payment failed", {
|
|
382
|
+
id: sub.id,
|
|
383
|
+
organizationId: orgId,
|
|
384
|
+
attempt,
|
|
385
|
+
maxAttempts: schedule.length,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
await this.alepha.events.emit("subscription:payment_failed" as any, {
|
|
389
|
+
subscriptionId: sub.id,
|
|
390
|
+
organizationId: orgId,
|
|
391
|
+
planId: sub.planId,
|
|
392
|
+
attempt,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
397
|
+
// Helpers
|
|
398
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Compute the end of a billing interval from a start date.
|
|
402
|
+
*/
|
|
403
|
+
protected computeIntervalEnd(
|
|
404
|
+
start: string,
|
|
405
|
+
interval: "monthly" | "yearly",
|
|
406
|
+
): string {
|
|
407
|
+
const startDate = this.dateTime.of(start);
|
|
408
|
+
const unit = interval === "monthly" ? "months" : "years";
|
|
409
|
+
return startDate.add(1, unit).toISOString();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Record a subscription event in the event log.
|
|
414
|
+
*/
|
|
415
|
+
protected async recordEvent(
|
|
416
|
+
subscriptionId: string,
|
|
417
|
+
organizationId: string,
|
|
418
|
+
type: SubscriptionEventEntity["type"],
|
|
419
|
+
context?: EventContext,
|
|
420
|
+
): Promise<void> {
|
|
421
|
+
await this.eventRepo.create({
|
|
422
|
+
subscriptionId,
|
|
423
|
+
organizationId,
|
|
424
|
+
type,
|
|
425
|
+
previousStatus: context?.previousStatus,
|
|
426
|
+
newStatus: context?.newStatus,
|
|
427
|
+
previousPlanId: context?.previousPlanId,
|
|
428
|
+
newPlanId: context?.newPlanId,
|
|
429
|
+
paymentIntentId: context?.paymentIntentId,
|
|
430
|
+
amount: context?.amount,
|
|
431
|
+
currency: context?.currency,
|
|
432
|
+
triggeredBy: context?.triggeredBy,
|
|
433
|
+
userId: context?.userId,
|
|
434
|
+
note: context?.note,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { t } from "alepha";
|
|
2
|
+
import { $parameter } from "alepha/api/parameters";
|
|
3
|
+
import { BadRequestError } from "alepha/server";
|
|
4
|
+
import {
|
|
5
|
+
type PlanDefinition,
|
|
6
|
+
planDefinitionSchema,
|
|
7
|
+
} from "../schemas/planDefinitionSchema.ts";
|
|
8
|
+
import {
|
|
9
|
+
type SubscriptionSettings,
|
|
10
|
+
subscriptionSettingsSchema,
|
|
11
|
+
} from "../schemas/subscriptionSettingsSchema.ts";
|
|
12
|
+
|
|
13
|
+
export class SubscriptionConfig {
|
|
14
|
+
protected readonly plans = $parameter({
|
|
15
|
+
name: "subscriptions.plans",
|
|
16
|
+
description: "Subscription plan definitions",
|
|
17
|
+
schema: t.object({ plans: t.array(planDefinitionSchema) }),
|
|
18
|
+
default: { plans: [] },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
protected readonly settings = $parameter({
|
|
22
|
+
name: "subscriptions.settings",
|
|
23
|
+
description: "Global subscription settings",
|
|
24
|
+
schema: subscriptionSettingsSchema,
|
|
25
|
+
default: {
|
|
26
|
+
trialDays: 14,
|
|
27
|
+
gracePeriodDays: 7,
|
|
28
|
+
dunningSchedule: [1, 3, 5, 7],
|
|
29
|
+
cancelAtPeriodEnd: true,
|
|
30
|
+
prorateOnChange: true,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
public async getPlans(): Promise<PlanDefinition[]> {
|
|
35
|
+
return (await this.plans.get()).plans;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public async getSettings(): Promise<SubscriptionSettings> {
|
|
39
|
+
return this.settings.get();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public async getPlan(planId: string): Promise<PlanDefinition> {
|
|
43
|
+
const plans = await this.getPlans();
|
|
44
|
+
const plan = plans.find((p) => p.id === planId);
|
|
45
|
+
if (!plan) throw new BadRequestError(`Plan '${planId}' not found`);
|
|
46
|
+
return plan;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public async getPlanPricing(planId: string, interval: "monthly" | "yearly") {
|
|
50
|
+
const plan = await this.getPlan(planId);
|
|
51
|
+
const pricing = plan.pricing.find((p) => p.interval === interval);
|
|
52
|
+
if (!pricing)
|
|
53
|
+
throw new BadRequestError(`No ${interval} pricing for plan '${planId}'`);
|
|
54
|
+
return pricing;
|
|
55
|
+
}
|
|
56
|
+
}
|