alepha 0.19.2 → 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 +90 -34
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +267 -44
- package/dist/api/jobs/index.js.map +1 -1
- 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 +27 -21
- 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/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/config/index.d.ts +6 -28
- package/dist/cli/config/index.d.ts.map +1 -1
- package/dist/cli/config/index.js +5 -10
- package/dist/cli/config/index.js.map +1 -1
- package/dist/cli/core/index.d.ts +11669 -208
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +60 -69
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/devtools/index.d.ts +5 -0
- package/dist/cli/devtools/index.d.ts.map +1 -1
- package/dist/cli/devtools/index.js +4 -0
- package/dist/cli/devtools/index.js.map +1 -1
- package/dist/cli/platform/index.d.ts +69 -64
- package/dist/cli/platform/index.d.ts.map +1 -1
- package/dist/cli/platform/index.js +6 -2
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/cli/vendor/index.d.ts +38 -10
- package/dist/cli/vendor/index.d.ts.map +1 -1
- package/dist/cli/vendor/index.js +85 -26
- 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 +25 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +25 -2
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js +25 -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/logger/index.d.ts.map +1 -1
- package/dist/logger/index.js +1 -1
- package/dist/logger/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 +25 -73
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.d.ts +10 -32
- package/dist/orm/core/index.d.ts.map +1 -1
- package/dist/orm/core/index.js +25 -73
- 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 +2 -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 +239 -25
- 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/config/defineConfig.ts +17 -46
- package/src/cli/core/providers/ViteDevServerProvider.ts +45 -3
- package/src/cli/core/services/PackageManagerUtils.ts +3 -1
- package/src/cli/core/services/ProjectScaffolder.ts +5 -5
- package/src/cli/core/templates/agentMd.ts +14 -5
- package/src/cli/core/templates/webAppRouterTs.ts +5 -58
- package/src/cli/devtools/index.ts +21 -1
- package/src/cli/platform/index.ts +23 -2
- package/src/cli/vendor/__tests__/VendorService.spec.ts +283 -178
- package/src/cli/vendor/index.ts +20 -3
- package/src/cli/vendor/services/VendorService.ts +126 -27
- package/src/core/Alepha.ts +10 -0
- 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/logger/index.ts +6 -1
- 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/core/providers/DrizzleKitProvider.ts +56 -105
- 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/tsconfig.base.json +0 -1
- 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,1870 @@
|
|
|
1
|
+
import { $context, $hook, $inject, $module, Alepha, createMiddleware, t } from "alepha";
|
|
2
|
+
import { $secure } from "alepha/security";
|
|
3
|
+
import { $action, BadRequestError, ForbiddenError, NotFoundError, okSchema } from "alepha/server";
|
|
4
|
+
import { $entity, $repository, db, pageQuerySchema } from "alepha/orm";
|
|
5
|
+
import { $parameter } from "alepha/api/parameters";
|
|
6
|
+
import { DateTimeProvider } from "alepha/datetime";
|
|
7
|
+
import { $logger } from "alepha/logger";
|
|
8
|
+
import { $job } from "alepha/api/jobs";
|
|
9
|
+
import { PaymentService } from "alepha/api/payments";
|
|
10
|
+
import { $notification } from "alepha/api/notifications";
|
|
11
|
+
import { CacheProvider } from "alepha/cache";
|
|
12
|
+
//#region ../../src/api/subscriptions/schemas/cancelSubscriptionSchema.ts
|
|
13
|
+
const cancelSubscriptionSchema = t.object({
|
|
14
|
+
reason: t.optional(t.string()),
|
|
15
|
+
immediate: t.optional(t.boolean())
|
|
16
|
+
});
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region ../../src/api/subscriptions/schemas/changePlanSchema.ts
|
|
19
|
+
const changePlanSchema = t.object({
|
|
20
|
+
planId: t.string(),
|
|
21
|
+
interval: t.optional(t.enum(["monthly", "yearly"])),
|
|
22
|
+
immediate: t.optional(t.boolean())
|
|
23
|
+
});
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region ../../src/api/subscriptions/schemas/mrrSchema.ts
|
|
26
|
+
const mrrSchema = t.object({
|
|
27
|
+
total: t.integer(),
|
|
28
|
+
byPlan: t.record(t.text(), t.integer()),
|
|
29
|
+
growth: t.integer(),
|
|
30
|
+
newMrr: t.integer(),
|
|
31
|
+
expansionMrr: t.integer(),
|
|
32
|
+
contractionMrr: t.integer(),
|
|
33
|
+
churnMrr: t.integer()
|
|
34
|
+
});
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region ../../src/api/subscriptions/schemas/subscriptionQuerySchema.ts
|
|
37
|
+
const subscriptionQuerySchema = t.extend(pageQuerySchema, {
|
|
38
|
+
status: t.optional(t.enum([
|
|
39
|
+
"trialing",
|
|
40
|
+
"active",
|
|
41
|
+
"past_due",
|
|
42
|
+
"suspended",
|
|
43
|
+
"cancelled",
|
|
44
|
+
"expired"
|
|
45
|
+
])),
|
|
46
|
+
planId: t.optional(t.string()),
|
|
47
|
+
organizationId: t.optional(t.uuid())
|
|
48
|
+
});
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region ../../src/api/subscriptions/entities/subscriptions.ts
|
|
51
|
+
const subscriptions = $entity({
|
|
52
|
+
name: "subscriptions",
|
|
53
|
+
schema: t.object({
|
|
54
|
+
id: db.primaryKey(t.uuid()),
|
|
55
|
+
version: db.version(),
|
|
56
|
+
createdAt: db.createdAt(),
|
|
57
|
+
updatedAt: db.updatedAt(),
|
|
58
|
+
organizationId: db.organization(),
|
|
59
|
+
planId: t.string(),
|
|
60
|
+
interval: t.enum(["monthly", "yearly"]),
|
|
61
|
+
status: t.enum([
|
|
62
|
+
"trialing",
|
|
63
|
+
"active",
|
|
64
|
+
"past_due",
|
|
65
|
+
"suspended",
|
|
66
|
+
"cancelled",
|
|
67
|
+
"expired"
|
|
68
|
+
]),
|
|
69
|
+
currentPeriodStart: t.datetime(),
|
|
70
|
+
currentPeriodEnd: t.datetime(),
|
|
71
|
+
trialStart: t.optional(t.datetime()),
|
|
72
|
+
trialEnd: t.optional(t.datetime()),
|
|
73
|
+
cancelledAt: t.optional(t.datetime()),
|
|
74
|
+
cancelReason: t.optional(t.string()),
|
|
75
|
+
cancelAtPeriodEnd: t.boolean({ default: false }),
|
|
76
|
+
lastPaymentIntentId: t.optional(t.uuid()),
|
|
77
|
+
lastPaymentAt: t.optional(t.datetime()),
|
|
78
|
+
nextBillingAt: t.optional(t.datetime()),
|
|
79
|
+
dunningStartedAt: t.optional(t.datetime()),
|
|
80
|
+
dunningAttempt: t.integer({ default: 0 }),
|
|
81
|
+
dunningNextRetryAt: t.optional(t.datetime()),
|
|
82
|
+
pendingPlanId: t.optional(t.string()),
|
|
83
|
+
pendingInterval: t.optional(t.enum(["monthly", "yearly"])),
|
|
84
|
+
metadata: t.optional(t.record(t.text(), t.any()))
|
|
85
|
+
}),
|
|
86
|
+
indexes: [
|
|
87
|
+
{
|
|
88
|
+
columns: ["organizationId"],
|
|
89
|
+
unique: true
|
|
90
|
+
},
|
|
91
|
+
{ columns: ["status"] },
|
|
92
|
+
{ columns: ["planId", "status"] },
|
|
93
|
+
{ columns: ["nextBillingAt"] },
|
|
94
|
+
{ columns: ["trialEnd"] },
|
|
95
|
+
{ columns: ["dunningNextRetryAt"] },
|
|
96
|
+
{ columns: ["currentPeriodEnd"] }
|
|
97
|
+
]
|
|
98
|
+
});
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region ../../src/api/subscriptions/schemas/subscriptionResourceSchema.ts
|
|
101
|
+
const subscriptionResourceSchema = subscriptions.schema;
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region ../../src/api/subscriptions/schemas/subscriptionStatsSchema.ts
|
|
104
|
+
const subscriptionStatsSchema = t.object({
|
|
105
|
+
total: t.integer(),
|
|
106
|
+
trialing: t.integer(),
|
|
107
|
+
active: t.integer(),
|
|
108
|
+
pastDue: t.integer(),
|
|
109
|
+
suspended: t.integer(),
|
|
110
|
+
cancelled: t.integer(),
|
|
111
|
+
expired: t.integer(),
|
|
112
|
+
trialConversionRate: t.number(),
|
|
113
|
+
churnRate: t.number(),
|
|
114
|
+
byPlan: t.record(t.text(), t.object({
|
|
115
|
+
active: t.integer(),
|
|
116
|
+
trialing: t.integer(),
|
|
117
|
+
total: t.integer()
|
|
118
|
+
}))
|
|
119
|
+
});
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region ../../src/api/subscriptions/schemas/planDefinitionSchema.ts
|
|
122
|
+
const planDefinitionSchema = t.object({
|
|
123
|
+
id: t.string({
|
|
124
|
+
minLength: 1,
|
|
125
|
+
maxLength: 50
|
|
126
|
+
}),
|
|
127
|
+
name: t.string(),
|
|
128
|
+
description: t.optional(t.string()),
|
|
129
|
+
available: t.boolean({ default: true }),
|
|
130
|
+
pricing: t.array(t.object({
|
|
131
|
+
interval: t.enum(["monthly", "yearly"]),
|
|
132
|
+
amount: t.integer({ minimum: 0 }),
|
|
133
|
+
currency: t.string({
|
|
134
|
+
minLength: 3,
|
|
135
|
+
maxLength: 3
|
|
136
|
+
})
|
|
137
|
+
})),
|
|
138
|
+
trial: t.optional(t.object({
|
|
139
|
+
days: t.integer({
|
|
140
|
+
minimum: 0,
|
|
141
|
+
maximum: 365
|
|
142
|
+
}),
|
|
143
|
+
requirePaymentMethod: t.boolean({ default: false })
|
|
144
|
+
})),
|
|
145
|
+
features: t.array(t.string()),
|
|
146
|
+
limits: t.record(t.text(), t.integer()),
|
|
147
|
+
order: t.integer({ default: 0 }),
|
|
148
|
+
metadata: t.optional(t.record(t.text(), t.any()))
|
|
149
|
+
});
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region ../../src/api/subscriptions/schemas/subscriptionSettingsSchema.ts
|
|
152
|
+
const subscriptionSettingsSchema = t.object({
|
|
153
|
+
trialDays: t.integer({
|
|
154
|
+
default: 14,
|
|
155
|
+
minimum: 0,
|
|
156
|
+
maximum: 365
|
|
157
|
+
}),
|
|
158
|
+
gracePeriodDays: t.integer({
|
|
159
|
+
default: 7,
|
|
160
|
+
minimum: 0,
|
|
161
|
+
maximum: 30
|
|
162
|
+
}),
|
|
163
|
+
dunningSchedule: t.array(t.integer({ minimum: 1 })),
|
|
164
|
+
cancelAtPeriodEnd: t.boolean({ default: true }),
|
|
165
|
+
prorateOnChange: t.boolean({ default: true })
|
|
166
|
+
});
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region ../../src/api/subscriptions/services/SubscriptionConfig.ts
|
|
169
|
+
var SubscriptionConfig = class {
|
|
170
|
+
plans = $parameter({
|
|
171
|
+
name: "subscriptions.plans",
|
|
172
|
+
description: "Subscription plan definitions",
|
|
173
|
+
schema: t.object({ plans: t.array(planDefinitionSchema) }),
|
|
174
|
+
default: { plans: [] }
|
|
175
|
+
});
|
|
176
|
+
settings = $parameter({
|
|
177
|
+
name: "subscriptions.settings",
|
|
178
|
+
description: "Global subscription settings",
|
|
179
|
+
schema: subscriptionSettingsSchema,
|
|
180
|
+
default: {
|
|
181
|
+
trialDays: 14,
|
|
182
|
+
gracePeriodDays: 7,
|
|
183
|
+
dunningSchedule: [
|
|
184
|
+
1,
|
|
185
|
+
3,
|
|
186
|
+
5,
|
|
187
|
+
7
|
|
188
|
+
],
|
|
189
|
+
cancelAtPeriodEnd: true,
|
|
190
|
+
prorateOnChange: true
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
async getPlans() {
|
|
194
|
+
return (await this.plans.get()).plans;
|
|
195
|
+
}
|
|
196
|
+
async getSettings() {
|
|
197
|
+
return this.settings.get();
|
|
198
|
+
}
|
|
199
|
+
async getPlan(planId) {
|
|
200
|
+
const plan = (await this.getPlans()).find((p) => p.id === planId);
|
|
201
|
+
if (!plan) throw new BadRequestError(`Plan '${planId}' not found`);
|
|
202
|
+
return plan;
|
|
203
|
+
}
|
|
204
|
+
async getPlanPricing(planId, interval) {
|
|
205
|
+
const pricing = (await this.getPlan(planId)).pricing.find((p) => p.interval === interval);
|
|
206
|
+
if (!pricing) throw new BadRequestError(`No ${interval} pricing for plan '${planId}'`);
|
|
207
|
+
return pricing;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region ../../src/api/subscriptions/entities/subscriptionEvents.ts
|
|
212
|
+
const subscriptionEvents = $entity({
|
|
213
|
+
name: "subscription_events",
|
|
214
|
+
schema: t.object({
|
|
215
|
+
id: db.primaryKey(t.uuid()),
|
|
216
|
+
createdAt: db.createdAt(),
|
|
217
|
+
subscriptionId: db.ref(t.uuid(), () => subscriptions.cols.id, { onDelete: "cascade" }),
|
|
218
|
+
organizationId: db.organization(),
|
|
219
|
+
type: t.enum([
|
|
220
|
+
"created",
|
|
221
|
+
"trial_started",
|
|
222
|
+
"trial_ended",
|
|
223
|
+
"activated",
|
|
224
|
+
"renewed",
|
|
225
|
+
"payment_failed",
|
|
226
|
+
"payment_retried",
|
|
227
|
+
"past_due",
|
|
228
|
+
"suspended",
|
|
229
|
+
"reactivated",
|
|
230
|
+
"plan_changed",
|
|
231
|
+
"plan_change_scheduled",
|
|
232
|
+
"cancelled",
|
|
233
|
+
"expired",
|
|
234
|
+
"resumed"
|
|
235
|
+
]),
|
|
236
|
+
previousStatus: t.optional(t.string()),
|
|
237
|
+
newStatus: t.optional(t.string()),
|
|
238
|
+
previousPlanId: t.optional(t.string()),
|
|
239
|
+
newPlanId: t.optional(t.string()),
|
|
240
|
+
paymentIntentId: t.optional(t.uuid()),
|
|
241
|
+
amount: t.optional(t.integer()),
|
|
242
|
+
currency: t.optional(t.string()),
|
|
243
|
+
triggeredBy: t.optional(t.string()),
|
|
244
|
+
userId: t.optional(t.uuid()),
|
|
245
|
+
note: t.optional(t.string())
|
|
246
|
+
}),
|
|
247
|
+
indexes: [
|
|
248
|
+
{ columns: ["subscriptionId", "createdAt"] },
|
|
249
|
+
{ columns: ["organizationId", "createdAt"] },
|
|
250
|
+
{ columns: ["type"] }
|
|
251
|
+
]
|
|
252
|
+
});
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region ../../src/api/subscriptions/services/SubscriptionService.ts
|
|
255
|
+
var SubscriptionService = class {
|
|
256
|
+
alepha = $inject(Alepha);
|
|
257
|
+
log = $logger();
|
|
258
|
+
dateTime = $inject(DateTimeProvider);
|
|
259
|
+
subscriptionRepo = $repository(subscriptions);
|
|
260
|
+
eventRepo = $repository(subscriptionEvents);
|
|
261
|
+
config = $inject(SubscriptionConfig);
|
|
262
|
+
/**
|
|
263
|
+
* Find a subscription by organization ID.
|
|
264
|
+
* Returns null if no subscription exists.
|
|
265
|
+
*/
|
|
266
|
+
async getByOrganization(organizationId) {
|
|
267
|
+
return await this.subscriptionRepo.findOne({ where: { organizationId: { eq: organizationId } } }) ?? null;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Get a subscription by ID. Throws NotFoundError if not found.
|
|
271
|
+
*/
|
|
272
|
+
async getSubscription(id) {
|
|
273
|
+
return this.subscriptionRepo.getById(id);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Returns true if the subscription currently grants access.
|
|
277
|
+
* Accessible statuses: trialing, active, past_due (grace period),
|
|
278
|
+
* or cancelled with cancelAtPeriodEnd before period end.
|
|
279
|
+
*/
|
|
280
|
+
isAccessible(sub) {
|
|
281
|
+
if (sub.status === "trialing" || sub.status === "active" || sub.status === "past_due") return true;
|
|
282
|
+
if (sub.status === "cancelled" && sub.cancelAtPeriodEnd && this.dateTime.now().isBefore(sub.currentPeriodEnd)) return true;
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Record a subscription event in the event log.
|
|
287
|
+
*/
|
|
288
|
+
async recordEvent(subscriptionId, organizationId, type, context) {
|
|
289
|
+
await this.eventRepo.create({
|
|
290
|
+
subscriptionId,
|
|
291
|
+
organizationId,
|
|
292
|
+
type,
|
|
293
|
+
previousStatus: context?.previousStatus,
|
|
294
|
+
newStatus: context?.newStatus,
|
|
295
|
+
previousPlanId: context?.previousPlanId,
|
|
296
|
+
newPlanId: context?.newPlanId,
|
|
297
|
+
paymentIntentId: context?.paymentIntentId,
|
|
298
|
+
amount: context?.amount,
|
|
299
|
+
currency: context?.currency,
|
|
300
|
+
triggeredBy: context?.triggeredBy,
|
|
301
|
+
userId: context?.userId,
|
|
302
|
+
note: context?.note
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Compute the end of a billing interval from a start date.
|
|
307
|
+
*/
|
|
308
|
+
computeIntervalEnd(start, interval) {
|
|
309
|
+
const startDate = this.dateTime.of(start);
|
|
310
|
+
const unit = interval === "monthly" ? "months" : "years";
|
|
311
|
+
return startDate.add(1, unit).toISOString();
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Create a new subscription for an organization.
|
|
315
|
+
*/
|
|
316
|
+
async subscribe(organizationId, planId, interval, options) {
|
|
317
|
+
const plan = await this.config.getPlan(planId);
|
|
318
|
+
if (!plan.available) throw new BadRequestError(`Plan '${planId}' is not available for new subscriptions`);
|
|
319
|
+
await this.config.getPlanPricing(planId, interval);
|
|
320
|
+
if (await this.subscriptionRepo.findOne({ where: {
|
|
321
|
+
organizationId: { eq: organizationId },
|
|
322
|
+
status: { inArray: [
|
|
323
|
+
"trialing",
|
|
324
|
+
"active",
|
|
325
|
+
"past_due"
|
|
326
|
+
] }
|
|
327
|
+
} })) throw new BadRequestError("Organization already has an active subscription");
|
|
328
|
+
const settings = await this.config.getSettings();
|
|
329
|
+
const trialDays = options?.trialDays ?? plan.trial?.days ?? settings.trialDays;
|
|
330
|
+
const skipTrial = options?.skipTrial ?? false;
|
|
331
|
+
const now = this.dateTime.now();
|
|
332
|
+
const nowISO = now.toISOString();
|
|
333
|
+
if (trialDays > 0 && !skipTrial) {
|
|
334
|
+
const trialEnd = now.add(trialDays, "days").toISOString();
|
|
335
|
+
const entity = await this.subscriptionRepo.create({
|
|
336
|
+
organizationId,
|
|
337
|
+
planId,
|
|
338
|
+
interval,
|
|
339
|
+
status: "trialing",
|
|
340
|
+
currentPeriodStart: nowISO,
|
|
341
|
+
currentPeriodEnd: trialEnd,
|
|
342
|
+
trialStart: nowISO,
|
|
343
|
+
trialEnd,
|
|
344
|
+
nextBillingAt: trialEnd,
|
|
345
|
+
cancelAtPeriodEnd: false,
|
|
346
|
+
dunningAttempt: 0,
|
|
347
|
+
metadata: options?.metadata
|
|
348
|
+
});
|
|
349
|
+
await this.recordEvent(entity.id, organizationId, "created", { newStatus: "trialing" });
|
|
350
|
+
await this.recordEvent(entity.id, organizationId, "trial_started", { newStatus: "trialing" });
|
|
351
|
+
this.log.info("Subscription created with trial", {
|
|
352
|
+
id: entity.id,
|
|
353
|
+
organizationId,
|
|
354
|
+
planId,
|
|
355
|
+
trialDays
|
|
356
|
+
});
|
|
357
|
+
await this.alepha.events.emit("subscription:created", { subscription: entity });
|
|
358
|
+
return entity;
|
|
359
|
+
}
|
|
360
|
+
const periodEnd = this.computeIntervalEnd(nowISO, interval);
|
|
361
|
+
const entity = await this.subscriptionRepo.create({
|
|
362
|
+
organizationId,
|
|
363
|
+
planId,
|
|
364
|
+
interval,
|
|
365
|
+
status: "active",
|
|
366
|
+
currentPeriodStart: nowISO,
|
|
367
|
+
currentPeriodEnd: periodEnd,
|
|
368
|
+
nextBillingAt: periodEnd,
|
|
369
|
+
cancelAtPeriodEnd: false,
|
|
370
|
+
dunningAttempt: 0,
|
|
371
|
+
metadata: options?.metadata
|
|
372
|
+
});
|
|
373
|
+
await this.recordEvent(entity.id, organizationId, "created", { newStatus: "active" });
|
|
374
|
+
this.log.info("Subscription created", {
|
|
375
|
+
id: entity.id,
|
|
376
|
+
organizationId,
|
|
377
|
+
planId
|
|
378
|
+
});
|
|
379
|
+
await this.alepha.events.emit("subscription:created", { subscription: entity });
|
|
380
|
+
return entity;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Cancel a subscription.
|
|
384
|
+
* If immediate, the subscription expires right away.
|
|
385
|
+
* If at period end, the subscription remains accessible until the period ends.
|
|
386
|
+
*/
|
|
387
|
+
async cancel(subscriptionId, options) {
|
|
388
|
+
const sub = await this.subscriptionRepo.getById(subscriptionId);
|
|
389
|
+
const orgId = sub.organizationId;
|
|
390
|
+
if (sub.status !== "trialing" && sub.status !== "active" && sub.status !== "past_due") throw new BadRequestError(`Cannot cancel subscription with status '${sub.status}'`);
|
|
391
|
+
const settings = await this.config.getSettings();
|
|
392
|
+
const immediate = options?.immediate ?? !settings.cancelAtPeriodEnd;
|
|
393
|
+
const nowISO = this.dateTime.now().toISOString();
|
|
394
|
+
const previousStatus = sub.status;
|
|
395
|
+
if (immediate) {
|
|
396
|
+
await this.subscriptionRepo.updateById(subscriptionId, {
|
|
397
|
+
status: "expired",
|
|
398
|
+
cancelledAt: nowISO,
|
|
399
|
+
cancelReason: options?.reason,
|
|
400
|
+
cancelAtPeriodEnd: false
|
|
401
|
+
});
|
|
402
|
+
await this.recordEvent(subscriptionId, orgId, "cancelled", {
|
|
403
|
+
previousStatus,
|
|
404
|
+
newStatus: "expired",
|
|
405
|
+
triggeredBy: options?.cancelledBy ? "user" : "system",
|
|
406
|
+
userId: options?.cancelledBy,
|
|
407
|
+
note: options?.reason
|
|
408
|
+
});
|
|
409
|
+
this.log.info("Subscription cancelled immediately", {
|
|
410
|
+
id: subscriptionId,
|
|
411
|
+
organizationId: orgId
|
|
412
|
+
});
|
|
413
|
+
} else {
|
|
414
|
+
await this.subscriptionRepo.updateById(subscriptionId, {
|
|
415
|
+
status: "cancelled",
|
|
416
|
+
cancelledAt: nowISO,
|
|
417
|
+
cancelReason: options?.reason,
|
|
418
|
+
cancelAtPeriodEnd: true
|
|
419
|
+
});
|
|
420
|
+
await this.recordEvent(subscriptionId, orgId, "cancelled", {
|
|
421
|
+
previousStatus,
|
|
422
|
+
newStatus: "cancelled",
|
|
423
|
+
triggeredBy: options?.cancelledBy ? "user" : "system",
|
|
424
|
+
userId: options?.cancelledBy,
|
|
425
|
+
note: options?.reason
|
|
426
|
+
});
|
|
427
|
+
this.log.info("Subscription cancelled at period end", {
|
|
428
|
+
id: subscriptionId,
|
|
429
|
+
organizationId: orgId,
|
|
430
|
+
periodEnd: sub.currentPeriodEnd
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
await this.alepha.events.emit("subscription:cancelled", {
|
|
434
|
+
subscription: sub,
|
|
435
|
+
immediate,
|
|
436
|
+
reason: options?.reason
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Resume a cancelled subscription before its period ends.
|
|
441
|
+
* Only valid for subscriptions cancelled with cancelAtPeriodEnd.
|
|
442
|
+
*/
|
|
443
|
+
async resume(subscriptionId) {
|
|
444
|
+
const sub = await this.subscriptionRepo.getById(subscriptionId);
|
|
445
|
+
const orgId = sub.organizationId;
|
|
446
|
+
if (sub.status !== "cancelled") throw new BadRequestError(`Cannot resume subscription with status '${sub.status}', must be 'cancelled'`);
|
|
447
|
+
if (!sub.cancelAtPeriodEnd) throw new BadRequestError("Cannot resume a subscription that was not cancelled at period end");
|
|
448
|
+
if (!this.dateTime.now().isBefore(sub.currentPeriodEnd)) throw new BadRequestError("Cannot resume subscription, period has already ended");
|
|
449
|
+
await this.subscriptionRepo.updateById(subscriptionId, {
|
|
450
|
+
status: "active",
|
|
451
|
+
cancelledAt: void 0,
|
|
452
|
+
cancelReason: void 0,
|
|
453
|
+
cancelAtPeriodEnd: false
|
|
454
|
+
});
|
|
455
|
+
await this.recordEvent(subscriptionId, orgId, "resumed", {
|
|
456
|
+
previousStatus: "cancelled",
|
|
457
|
+
newStatus: "active"
|
|
458
|
+
});
|
|
459
|
+
this.log.info("Subscription resumed", {
|
|
460
|
+
id: subscriptionId,
|
|
461
|
+
organizationId: orgId
|
|
462
|
+
});
|
|
463
|
+
await this.alepha.events.emit("subscription:resumed", { subscription: sub });
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Change the plan of a subscription.
|
|
467
|
+
* If immediate, proration is calculated and the plan changes now.
|
|
468
|
+
* If at period end, the change is scheduled for the next renewal.
|
|
469
|
+
* Returns the net proration amount (positive = charge, negative = credit).
|
|
470
|
+
*/
|
|
471
|
+
async changePlan(subscriptionId, newPlanId, newInterval, options) {
|
|
472
|
+
const sub = await this.subscriptionRepo.getById(subscriptionId);
|
|
473
|
+
const orgId = sub.organizationId;
|
|
474
|
+
if (sub.status !== "active" && sub.status !== "trialing") throw new BadRequestError(`Cannot change plan for subscription with status '${sub.status}'`);
|
|
475
|
+
if (!(await this.config.getPlan(newPlanId)).available) throw new BadRequestError(`Plan '${newPlanId}' is not available for new subscriptions`);
|
|
476
|
+
const effectiveInterval = newInterval ?? sub.interval;
|
|
477
|
+
await this.config.getPlanPricing(newPlanId, effectiveInterval);
|
|
478
|
+
const settings = await this.config.getSettings();
|
|
479
|
+
if (!(options?.immediate ?? true)) {
|
|
480
|
+
await this.subscriptionRepo.updateById(subscriptionId, {
|
|
481
|
+
pendingPlanId: newPlanId,
|
|
482
|
+
pendingInterval: effectiveInterval
|
|
483
|
+
});
|
|
484
|
+
await this.recordEvent(subscriptionId, orgId, "plan_change_scheduled", {
|
|
485
|
+
previousPlanId: sub.planId,
|
|
486
|
+
newPlanId,
|
|
487
|
+
note: `Scheduled change to '${newPlanId}' (${effectiveInterval}) at period end`
|
|
488
|
+
});
|
|
489
|
+
this.log.info("Plan change scheduled for period end", {
|
|
490
|
+
id: subscriptionId,
|
|
491
|
+
organizationId: orgId,
|
|
492
|
+
newPlanId,
|
|
493
|
+
newInterval: effectiveInterval
|
|
494
|
+
});
|
|
495
|
+
await this.alepha.events.emit("subscription:plan_changed", {
|
|
496
|
+
subscription: sub,
|
|
497
|
+
previousPlanId: sub.planId,
|
|
498
|
+
newPlanId,
|
|
499
|
+
immediate: false
|
|
500
|
+
});
|
|
501
|
+
return 0;
|
|
502
|
+
}
|
|
503
|
+
const shouldProrate = options?.prorate ?? settings.prorateOnChange;
|
|
504
|
+
let netAmount = 0;
|
|
505
|
+
if (shouldProrate && sub.status === "active") netAmount = await this.calculateProration(sub, newPlanId, effectiveInterval);
|
|
506
|
+
const previousPlanId = sub.planId;
|
|
507
|
+
await this.subscriptionRepo.updateById(subscriptionId, {
|
|
508
|
+
planId: newPlanId,
|
|
509
|
+
interval: effectiveInterval,
|
|
510
|
+
pendingPlanId: void 0,
|
|
511
|
+
pendingInterval: void 0,
|
|
512
|
+
metadata: netAmount < 0 ? {
|
|
513
|
+
...sub.metadata,
|
|
514
|
+
credit: Math.abs(netAmount)
|
|
515
|
+
} : sub.metadata
|
|
516
|
+
});
|
|
517
|
+
await this.recordEvent(subscriptionId, orgId, "plan_changed", {
|
|
518
|
+
previousPlanId,
|
|
519
|
+
newPlanId,
|
|
520
|
+
amount: netAmount !== 0 ? Math.abs(netAmount) : void 0,
|
|
521
|
+
note: netAmount > 0 ? `Proration charge: ${netAmount}` : netAmount < 0 ? `Proration credit: ${Math.abs(netAmount)}` : void 0
|
|
522
|
+
});
|
|
523
|
+
this.log.info("Plan changed immediately", {
|
|
524
|
+
id: subscriptionId,
|
|
525
|
+
organizationId: orgId,
|
|
526
|
+
previousPlanId,
|
|
527
|
+
newPlanId,
|
|
528
|
+
netAmount
|
|
529
|
+
});
|
|
530
|
+
await this.alepha.events.emit("subscription:plan_changed", {
|
|
531
|
+
subscription: sub,
|
|
532
|
+
previousPlanId,
|
|
533
|
+
newPlanId,
|
|
534
|
+
immediate: true,
|
|
535
|
+
netAmount
|
|
536
|
+
});
|
|
537
|
+
return netAmount;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Reactivate a suspended subscription (admin action).
|
|
541
|
+
* Resets dunning state and starts a new billing period.
|
|
542
|
+
*/
|
|
543
|
+
async reactivate(subscriptionId) {
|
|
544
|
+
const sub = await this.subscriptionRepo.getById(subscriptionId);
|
|
545
|
+
const orgId = sub.organizationId;
|
|
546
|
+
if (sub.status !== "suspended") throw new BadRequestError(`Cannot reactivate subscription with status '${sub.status}', must be 'suspended'`);
|
|
547
|
+
const nowISO = this.dateTime.now().toISOString();
|
|
548
|
+
const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
|
|
549
|
+
await this.subscriptionRepo.updateById(subscriptionId, {
|
|
550
|
+
status: "active",
|
|
551
|
+
currentPeriodStart: nowISO,
|
|
552
|
+
currentPeriodEnd: periodEnd,
|
|
553
|
+
nextBillingAt: periodEnd,
|
|
554
|
+
dunningStartedAt: void 0,
|
|
555
|
+
dunningAttempt: 0,
|
|
556
|
+
dunningNextRetryAt: void 0
|
|
557
|
+
});
|
|
558
|
+
await this.recordEvent(subscriptionId, orgId, "reactivated", {
|
|
559
|
+
previousStatus: "suspended",
|
|
560
|
+
newStatus: "active"
|
|
561
|
+
});
|
|
562
|
+
this.log.info("Subscription reactivated", {
|
|
563
|
+
id: subscriptionId,
|
|
564
|
+
organizationId: orgId
|
|
565
|
+
});
|
|
566
|
+
await this.alepha.events.emit("subscription:reactivated", { subscription: sub });
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Extend the trial period of a trialing subscription.
|
|
570
|
+
*/
|
|
571
|
+
async extendTrial(subscriptionId, days) {
|
|
572
|
+
const sub = await this.subscriptionRepo.getById(subscriptionId);
|
|
573
|
+
if (sub.status !== "trialing") throw new BadRequestError(`Cannot extend trial for subscription with status '${sub.status}', must be 'trialing'`);
|
|
574
|
+
if (!sub.trialEnd) throw new BadRequestError("Subscription has no trial end date set");
|
|
575
|
+
const newTrialEnd = this.dateTime.of(sub.trialEnd).add(days, "days").toISOString();
|
|
576
|
+
await this.subscriptionRepo.updateById(subscriptionId, {
|
|
577
|
+
trialEnd: newTrialEnd,
|
|
578
|
+
currentPeriodEnd: newTrialEnd,
|
|
579
|
+
nextBillingAt: newTrialEnd
|
|
580
|
+
});
|
|
581
|
+
this.log.info("Trial extended", {
|
|
582
|
+
id: subscriptionId,
|
|
583
|
+
organizationId: sub.organizationId,
|
|
584
|
+
days,
|
|
585
|
+
newTrialEnd
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Check if an organization has access to a specific feature.
|
|
590
|
+
*/
|
|
591
|
+
async can(organizationId, feature) {
|
|
592
|
+
const sub = await this.getByOrganization(organizationId);
|
|
593
|
+
if (!sub || !this.isAccessible(sub)) return false;
|
|
594
|
+
return (await this.config.getPlan(sub.planId)).features.includes(feature);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Get the usage limit for a resource.
|
|
598
|
+
* Returns -1 for unlimited, 0 for no access.
|
|
599
|
+
*/
|
|
600
|
+
async limit(organizationId, resource) {
|
|
601
|
+
const sub = await this.getByOrganization(organizationId);
|
|
602
|
+
if (!sub || !this.isAccessible(sub)) return 0;
|
|
603
|
+
return (await this.config.getPlan(sub.planId)).limits[resource] ?? 0;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Get the full entitlements snapshot for an organization.
|
|
607
|
+
*/
|
|
608
|
+
async getEntitlements(organizationId) {
|
|
609
|
+
const sub = await this.getByOrganization(organizationId);
|
|
610
|
+
if (!sub) throw new NotFoundError(`No subscription found for organization '${organizationId}'`);
|
|
611
|
+
const plan = await this.config.getPlan(sub.planId);
|
|
612
|
+
return {
|
|
613
|
+
planId: plan.id,
|
|
614
|
+
planName: plan.name,
|
|
615
|
+
status: sub.status,
|
|
616
|
+
features: plan.features,
|
|
617
|
+
limits: plan.limits,
|
|
618
|
+
trialEndsAt: sub.trialEnd,
|
|
619
|
+
periodEndsAt: sub.currentPeriodEnd,
|
|
620
|
+
cancelledAt: sub.cancelledAt
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Find subscriptions with pagination and filtering.
|
|
625
|
+
*/
|
|
626
|
+
async findSubscriptions(query = {}) {
|
|
627
|
+
query.sort ??= "-createdAt";
|
|
628
|
+
const where = this.subscriptionRepo.createQueryWhere();
|
|
629
|
+
if (query.status) where.status = { eq: query.status };
|
|
630
|
+
if (query.planId) where.planId = { eq: query.planId };
|
|
631
|
+
if (query.organizationId) where.organizationId = { eq: query.organizationId };
|
|
632
|
+
return this.subscriptionRepo.paginate(query, { where }, { count: true });
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Get the event history for a subscription, ordered by most recent first.
|
|
636
|
+
*/
|
|
637
|
+
async getHistory(subscriptionId) {
|
|
638
|
+
return this.eventRepo.findMany({
|
|
639
|
+
where: { subscriptionId: { eq: subscriptionId } },
|
|
640
|
+
orderBy: {
|
|
641
|
+
column: "createdAt",
|
|
642
|
+
direction: "desc"
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Get aggregated subscription statistics.
|
|
648
|
+
*/
|
|
649
|
+
async getStats() {
|
|
650
|
+
const [trialing, active, pastDue, suspended, cancelled, expired] = await Promise.all([
|
|
651
|
+
this.subscriptionRepo.count({ status: { eq: "trialing" } }),
|
|
652
|
+
this.subscriptionRepo.count({ status: { eq: "active" } }),
|
|
653
|
+
this.subscriptionRepo.count({ status: { eq: "past_due" } }),
|
|
654
|
+
this.subscriptionRepo.count({ status: { eq: "suspended" } }),
|
|
655
|
+
this.subscriptionRepo.count({ status: { eq: "cancelled" } }),
|
|
656
|
+
this.subscriptionRepo.count({ status: { eq: "expired" } })
|
|
657
|
+
]);
|
|
658
|
+
const total = trialing + active + pastDue + suspended + cancelled + expired;
|
|
659
|
+
const trialEndedEvents = await this.eventRepo.count({ type: { eq: "trial_ended" } });
|
|
660
|
+
const activatedEvents = await this.eventRepo.count({ type: { eq: "activated" } });
|
|
661
|
+
const trialConversionRate = trialEndedEvents > 0 ? activatedEvents / trialEndedEvents : 0;
|
|
662
|
+
const cancelledEvents = await this.eventRepo.count({ type: { eq: "cancelled" } });
|
|
663
|
+
const totalSubscribed = active + trialing + pastDue;
|
|
664
|
+
const churnRate = totalSubscribed + cancelledEvents > 0 ? cancelledEvents / (totalSubscribed + cancelledEvents) : 0;
|
|
665
|
+
const plans = await this.config.getPlans();
|
|
666
|
+
const byPlan = {};
|
|
667
|
+
for (const plan of plans) {
|
|
668
|
+
const [planActive, planTrialing] = await Promise.all([this.subscriptionRepo.count({
|
|
669
|
+
planId: { eq: plan.id },
|
|
670
|
+
status: { eq: "active" }
|
|
671
|
+
}), this.subscriptionRepo.count({
|
|
672
|
+
planId: { eq: plan.id },
|
|
673
|
+
status: { eq: "trialing" }
|
|
674
|
+
})]);
|
|
675
|
+
byPlan[plan.id] = {
|
|
676
|
+
active: planActive,
|
|
677
|
+
trialing: planTrialing,
|
|
678
|
+
total: planActive + planTrialing
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
total,
|
|
683
|
+
trialing,
|
|
684
|
+
active,
|
|
685
|
+
pastDue,
|
|
686
|
+
suspended,
|
|
687
|
+
cancelled,
|
|
688
|
+
expired,
|
|
689
|
+
trialConversionRate,
|
|
690
|
+
churnRate,
|
|
691
|
+
byPlan
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Get revenue data from recent subscription events.
|
|
696
|
+
* Sums amounts from renewed and activated events within the specified window.
|
|
697
|
+
*/
|
|
698
|
+
async getRevenue(days = 30) {
|
|
699
|
+
const cutoff = this.dateTime.now().subtract(days, "days").toISOString();
|
|
700
|
+
const events = await this.eventRepo.findMany({ where: {
|
|
701
|
+
type: { inArray: ["renewed", "activated"] },
|
|
702
|
+
createdAt: { gt: cutoff }
|
|
703
|
+
} });
|
|
704
|
+
let total = 0;
|
|
705
|
+
for (const event of events) total += event.amount ?? 0;
|
|
706
|
+
return {
|
|
707
|
+
total,
|
|
708
|
+
count: events.length
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Calculate proration for a mid-cycle plan change.
|
|
713
|
+
* Returns the net amount: positive = charge, negative = credit.
|
|
714
|
+
*/
|
|
715
|
+
async calculateProration(sub, newPlanId, newInterval) {
|
|
716
|
+
const oldPricing = await this.config.getPlanPricing(sub.planId, sub.interval);
|
|
717
|
+
const newPricing = await this.config.getPlanPricing(newPlanId, newInterval);
|
|
718
|
+
const now = this.dateTime.now();
|
|
719
|
+
const periodStart = this.dateTime.of(sub.currentPeriodStart);
|
|
720
|
+
const daysInPeriod = this.dateTime.of(sub.currentPeriodEnd).diff(periodStart, "days");
|
|
721
|
+
if (daysInPeriod <= 0) return 0;
|
|
722
|
+
const daysRemaining = daysInPeriod - now.diff(periodStart, "days");
|
|
723
|
+
const oldDailyRate = oldPricing.amount / daysInPeriod;
|
|
724
|
+
const newDailyRate = newPricing.amount / daysInPeriod;
|
|
725
|
+
const credit = Math.round(daysRemaining * oldDailyRate);
|
|
726
|
+
return Math.round(daysRemaining * newDailyRate) - credit;
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
//#endregion
|
|
730
|
+
//#region ../../src/api/subscriptions/controllers/AdminSubscriptionController.ts
|
|
731
|
+
var AdminSubscriptionController = class {
|
|
732
|
+
url = "/subscriptions";
|
|
733
|
+
group = "admin:subscriptions";
|
|
734
|
+
service = $inject(SubscriptionService);
|
|
735
|
+
config = $inject(SubscriptionConfig);
|
|
736
|
+
/**
|
|
737
|
+
* Find subscriptions with pagination and filtering.
|
|
738
|
+
*/
|
|
739
|
+
findSubscriptions = $action({
|
|
740
|
+
path: this.url,
|
|
741
|
+
group: this.group,
|
|
742
|
+
use: [$secure({ permissions: ["admin:subscription:read"] })],
|
|
743
|
+
description: "Find subscriptions with pagination and filtering",
|
|
744
|
+
schema: {
|
|
745
|
+
query: subscriptionQuerySchema,
|
|
746
|
+
response: t.page(subscriptionResourceSchema)
|
|
747
|
+
},
|
|
748
|
+
handler: ({ query }) => this.service.findSubscriptions(query)
|
|
749
|
+
});
|
|
750
|
+
/**
|
|
751
|
+
* Get a subscription by ID.
|
|
752
|
+
*/
|
|
753
|
+
getSubscription = $action({
|
|
754
|
+
path: `${this.url}/:id`,
|
|
755
|
+
group: this.group,
|
|
756
|
+
use: [$secure({ permissions: ["admin:subscription:read"] })],
|
|
757
|
+
description: "Get a subscription by ID",
|
|
758
|
+
schema: {
|
|
759
|
+
params: t.object({ id: t.uuid() }),
|
|
760
|
+
response: subscriptionResourceSchema
|
|
761
|
+
},
|
|
762
|
+
handler: ({ params }) => this.service.getSubscription(params.id)
|
|
763
|
+
});
|
|
764
|
+
/**
|
|
765
|
+
* Get aggregated subscription statistics.
|
|
766
|
+
*/
|
|
767
|
+
getStats = $action({
|
|
768
|
+
path: `${this.url}/stats`,
|
|
769
|
+
group: this.group,
|
|
770
|
+
use: [$secure({ permissions: ["admin:subscription:read"] })],
|
|
771
|
+
description: "Get aggregated subscription statistics",
|
|
772
|
+
schema: { response: subscriptionStatsSchema },
|
|
773
|
+
handler: () => this.service.getStats()
|
|
774
|
+
});
|
|
775
|
+
/**
|
|
776
|
+
* Get revenue data from recent subscription events.
|
|
777
|
+
*/
|
|
778
|
+
getRevenue = $action({
|
|
779
|
+
path: `${this.url}/revenue`,
|
|
780
|
+
group: this.group,
|
|
781
|
+
use: [$secure({ permissions: ["admin:subscription:read"] })],
|
|
782
|
+
description: "Get revenue data from recent subscription events",
|
|
783
|
+
schema: {
|
|
784
|
+
query: t.object({ days: t.optional(t.integer({
|
|
785
|
+
minimum: 1,
|
|
786
|
+
maximum: 365
|
|
787
|
+
})) }),
|
|
788
|
+
response: t.object({
|
|
789
|
+
total: t.integer(),
|
|
790
|
+
count: t.integer()
|
|
791
|
+
})
|
|
792
|
+
},
|
|
793
|
+
handler: ({ query }) => this.service.getRevenue(query.days)
|
|
794
|
+
});
|
|
795
|
+
/**
|
|
796
|
+
* Get Monthly Recurring Revenue breakdown.
|
|
797
|
+
*/
|
|
798
|
+
getMrr = $action({
|
|
799
|
+
path: `${this.url}/mrr`,
|
|
800
|
+
group: this.group,
|
|
801
|
+
use: [$secure({ permissions: ["admin:subscription:read"] })],
|
|
802
|
+
description: "Get Monthly Recurring Revenue breakdown",
|
|
803
|
+
schema: { response: mrrSchema },
|
|
804
|
+
handler: async () => {
|
|
805
|
+
const activeSubs = await this.service.findSubscriptions({
|
|
806
|
+
status: "active",
|
|
807
|
+
size: 1e3
|
|
808
|
+
});
|
|
809
|
+
const plans = await this.config.getPlans();
|
|
810
|
+
const byPlan = {};
|
|
811
|
+
let total = 0;
|
|
812
|
+
for (const sub of activeSubs.content) {
|
|
813
|
+
const plan = plans.find((p) => p.id === sub.planId);
|
|
814
|
+
if (!plan) continue;
|
|
815
|
+
const pricing = plan.pricing.find((p) => p.interval === sub.interval);
|
|
816
|
+
if (!pricing) continue;
|
|
817
|
+
const monthlyAmount = sub.interval === "yearly" ? Math.round(pricing.amount / 12) : pricing.amount;
|
|
818
|
+
byPlan[sub.planId] = (byPlan[sub.planId] ?? 0) + monthlyAmount;
|
|
819
|
+
total += monthlyAmount;
|
|
820
|
+
}
|
|
821
|
+
return {
|
|
822
|
+
total,
|
|
823
|
+
byPlan,
|
|
824
|
+
growth: 0,
|
|
825
|
+
newMrr: 0,
|
|
826
|
+
expansionMrr: 0,
|
|
827
|
+
contractionMrr: 0,
|
|
828
|
+
churnMrr: 0
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
/**
|
|
833
|
+
* Force a plan change for a subscription (admin action).
|
|
834
|
+
*/
|
|
835
|
+
adminChangePlan = $action({
|
|
836
|
+
method: "POST",
|
|
837
|
+
path: `${this.url}/:id/change-plan`,
|
|
838
|
+
group: this.group,
|
|
839
|
+
use: [$secure({ permissions: ["admin:subscription:update"] })],
|
|
840
|
+
description: "Force a plan change for a subscription",
|
|
841
|
+
schema: {
|
|
842
|
+
params: t.object({ id: t.uuid() }),
|
|
843
|
+
body: changePlanSchema,
|
|
844
|
+
response: subscriptionResourceSchema
|
|
845
|
+
},
|
|
846
|
+
handler: async ({ params, body }) => {
|
|
847
|
+
await this.service.changePlan(params.id, body.planId, body.interval, { immediate: body.immediate });
|
|
848
|
+
return this.service.getSubscription(params.id);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
/**
|
|
852
|
+
* Force cancel a subscription (admin action).
|
|
853
|
+
*/
|
|
854
|
+
adminCancel = $action({
|
|
855
|
+
method: "POST",
|
|
856
|
+
path: `${this.url}/:id/cancel`,
|
|
857
|
+
group: this.group,
|
|
858
|
+
use: [$secure({ permissions: ["admin:subscription:update"] })],
|
|
859
|
+
description: "Force cancel a subscription",
|
|
860
|
+
schema: {
|
|
861
|
+
params: t.object({ id: t.uuid() }),
|
|
862
|
+
body: cancelSubscriptionSchema,
|
|
863
|
+
response: okSchema
|
|
864
|
+
},
|
|
865
|
+
handler: async ({ params, body }) => {
|
|
866
|
+
await this.service.cancel(params.id, {
|
|
867
|
+
reason: body.reason,
|
|
868
|
+
immediate: body.immediate
|
|
869
|
+
});
|
|
870
|
+
return { ok: true };
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
/**
|
|
874
|
+
* Reactivate a suspended subscription (admin action).
|
|
875
|
+
*/
|
|
876
|
+
adminReactivate = $action({
|
|
877
|
+
method: "POST",
|
|
878
|
+
path: `${this.url}/:id/reactivate`,
|
|
879
|
+
group: this.group,
|
|
880
|
+
use: [$secure({ permissions: ["admin:subscription:update"] })],
|
|
881
|
+
description: "Reactivate a suspended subscription",
|
|
882
|
+
schema: {
|
|
883
|
+
params: t.object({ id: t.uuid() }),
|
|
884
|
+
response: okSchema
|
|
885
|
+
},
|
|
886
|
+
handler: async ({ params }) => {
|
|
887
|
+
await this.service.reactivate(params.id);
|
|
888
|
+
return { ok: true };
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
/**
|
|
892
|
+
* Extend the trial period for a trialing subscription (admin action).
|
|
893
|
+
*/
|
|
894
|
+
adminExtendTrial = $action({
|
|
895
|
+
method: "POST",
|
|
896
|
+
path: `${this.url}/:id/extend-trial`,
|
|
897
|
+
group: this.group,
|
|
898
|
+
use: [$secure({ permissions: ["admin:subscription:update"] })],
|
|
899
|
+
description: "Extend the trial period for a subscription",
|
|
900
|
+
schema: {
|
|
901
|
+
params: t.object({ id: t.uuid() }),
|
|
902
|
+
body: t.object({ days: t.integer({
|
|
903
|
+
minimum: 1,
|
|
904
|
+
maximum: 365
|
|
905
|
+
}) }),
|
|
906
|
+
response: okSchema
|
|
907
|
+
},
|
|
908
|
+
handler: async ({ params, body }) => {
|
|
909
|
+
await this.service.extendTrial(params.id, body.days);
|
|
910
|
+
return { ok: true };
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
};
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region ../../src/api/subscriptions/schemas/createSubscriptionSchema.ts
|
|
916
|
+
const createSubscriptionSchema = t.object({
|
|
917
|
+
planId: t.string(),
|
|
918
|
+
interval: t.enum(["monthly", "yearly"]),
|
|
919
|
+
paymentMethodId: t.optional(t.uuid()),
|
|
920
|
+
skipTrial: t.optional(t.boolean()),
|
|
921
|
+
metadata: t.optional(t.record(t.text(), t.any()))
|
|
922
|
+
});
|
|
923
|
+
//#endregion
|
|
924
|
+
//#region ../../src/api/subscriptions/schemas/entitlementsSchema.ts
|
|
925
|
+
const entitlementsSchema = t.object({
|
|
926
|
+
planId: t.string(),
|
|
927
|
+
planName: t.string(),
|
|
928
|
+
status: t.enum([
|
|
929
|
+
"trialing",
|
|
930
|
+
"active",
|
|
931
|
+
"past_due",
|
|
932
|
+
"suspended",
|
|
933
|
+
"cancelled",
|
|
934
|
+
"expired"
|
|
935
|
+
]),
|
|
936
|
+
features: t.array(t.string()),
|
|
937
|
+
limits: t.record(t.text(), t.integer()),
|
|
938
|
+
trialEndsAt: t.optional(t.datetime()),
|
|
939
|
+
periodEndsAt: t.datetime(),
|
|
940
|
+
cancelledAt: t.optional(t.datetime())
|
|
941
|
+
});
|
|
942
|
+
//#endregion
|
|
943
|
+
//#region ../../src/api/subscriptions/schemas/planResourceSchema.ts
|
|
944
|
+
const planResourceSchema = t.object({
|
|
945
|
+
id: t.string(),
|
|
946
|
+
name: t.string(),
|
|
947
|
+
description: t.optional(t.string()),
|
|
948
|
+
pricing: t.array(t.object({
|
|
949
|
+
interval: t.enum(["monthly", "yearly"]),
|
|
950
|
+
amount: t.integer(),
|
|
951
|
+
currency: t.string()
|
|
952
|
+
})),
|
|
953
|
+
features: t.array(t.string()),
|
|
954
|
+
limits: t.record(t.text(), t.integer()),
|
|
955
|
+
trial: t.optional(t.object({
|
|
956
|
+
days: t.integer(),
|
|
957
|
+
requirePaymentMethod: t.boolean()
|
|
958
|
+
})),
|
|
959
|
+
order: t.integer()
|
|
960
|
+
});
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region ../../src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts
|
|
963
|
+
const subscriptionEventResourceSchema = subscriptionEvents.schema;
|
|
964
|
+
//#endregion
|
|
965
|
+
//#region ../../src/api/subscriptions/controllers/SubscriptionController.ts
|
|
966
|
+
var SubscriptionController = class {
|
|
967
|
+
url = "/subscriptions";
|
|
968
|
+
group = "subscriptions";
|
|
969
|
+
service = $inject(SubscriptionService);
|
|
970
|
+
config = $inject(SubscriptionConfig);
|
|
971
|
+
/**
|
|
972
|
+
* List available subscription plans with pricing.
|
|
973
|
+
*/
|
|
974
|
+
getPlans = $action({
|
|
975
|
+
path: `${this.url}/plans`,
|
|
976
|
+
group: this.group,
|
|
977
|
+
description: "List available subscription plans",
|
|
978
|
+
schema: { response: t.array(planResourceSchema) },
|
|
979
|
+
handler: async () => {
|
|
980
|
+
return (await this.config.getPlans()).filter((p) => p.available).map((p) => ({
|
|
981
|
+
id: p.id,
|
|
982
|
+
name: p.name,
|
|
983
|
+
description: p.description,
|
|
984
|
+
pricing: p.pricing,
|
|
985
|
+
features: p.features,
|
|
986
|
+
limits: p.limits,
|
|
987
|
+
trial: p.trial,
|
|
988
|
+
order: p.order
|
|
989
|
+
}));
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
/**
|
|
993
|
+
* Get the current organization's subscription.
|
|
994
|
+
*/
|
|
995
|
+
getMySubscription = $action({
|
|
996
|
+
path: `${this.url}/mine`,
|
|
997
|
+
group: this.group,
|
|
998
|
+
use: [$secure()],
|
|
999
|
+
description: "Get the current organization subscription",
|
|
1000
|
+
schema: { response: subscriptionResourceSchema },
|
|
1001
|
+
handler: async ({ user }) => {
|
|
1002
|
+
const sub = await this.service.getByOrganization(user.organization);
|
|
1003
|
+
if (!sub) throw new NotFoundError("No subscription found for your organization");
|
|
1004
|
+
return sub;
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
/**
|
|
1008
|
+
* Create a new subscription for the current organization.
|
|
1009
|
+
*/
|
|
1010
|
+
subscribe = $action({
|
|
1011
|
+
method: "POST",
|
|
1012
|
+
path: this.url,
|
|
1013
|
+
group: this.group,
|
|
1014
|
+
use: [$secure({ permissions: ["subscription:create"] })],
|
|
1015
|
+
description: "Create a new subscription",
|
|
1016
|
+
schema: {
|
|
1017
|
+
body: createSubscriptionSchema,
|
|
1018
|
+
response: subscriptionResourceSchema
|
|
1019
|
+
},
|
|
1020
|
+
handler: ({ body, user }) => this.service.subscribe(user.organization, body.planId, body.interval, {
|
|
1021
|
+
skipTrial: body.skipTrial,
|
|
1022
|
+
metadata: body.metadata
|
|
1023
|
+
})
|
|
1024
|
+
});
|
|
1025
|
+
/**
|
|
1026
|
+
* Change the plan for the current organization's subscription.
|
|
1027
|
+
*/
|
|
1028
|
+
changePlan = $action({
|
|
1029
|
+
method: "POST",
|
|
1030
|
+
path: `${this.url}/mine/change-plan`,
|
|
1031
|
+
group: this.group,
|
|
1032
|
+
use: [$secure({ permissions: ["subscription:update"] })],
|
|
1033
|
+
description: "Upgrade or downgrade the subscription plan",
|
|
1034
|
+
schema: {
|
|
1035
|
+
body: changePlanSchema,
|
|
1036
|
+
response: subscriptionResourceSchema
|
|
1037
|
+
},
|
|
1038
|
+
handler: async ({ body, user }) => {
|
|
1039
|
+
const sub = await this.service.getByOrganization(user.organization);
|
|
1040
|
+
if (!sub) throw new NotFoundError("No subscription found for your organization");
|
|
1041
|
+
await this.service.changePlan(sub.id, body.planId, body.interval, { immediate: body.immediate });
|
|
1042
|
+
return this.service.getSubscription(sub.id);
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
/**
|
|
1046
|
+
* Cancel the current organization's subscription.
|
|
1047
|
+
*/
|
|
1048
|
+
cancel = $action({
|
|
1049
|
+
method: "POST",
|
|
1050
|
+
path: `${this.url}/mine/cancel`,
|
|
1051
|
+
group: this.group,
|
|
1052
|
+
use: [$secure({ permissions: ["subscription:update"] })],
|
|
1053
|
+
description: "Cancel the current subscription",
|
|
1054
|
+
schema: {
|
|
1055
|
+
body: cancelSubscriptionSchema,
|
|
1056
|
+
response: okSchema
|
|
1057
|
+
},
|
|
1058
|
+
handler: async ({ body, user }) => {
|
|
1059
|
+
const sub = await this.service.getByOrganization(user.organization);
|
|
1060
|
+
if (!sub) throw new NotFoundError("No subscription found for your organization");
|
|
1061
|
+
await this.service.cancel(sub.id, {
|
|
1062
|
+
reason: body.reason,
|
|
1063
|
+
immediate: body.immediate
|
|
1064
|
+
});
|
|
1065
|
+
return { ok: true };
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
/**
|
|
1069
|
+
* Resume a cancelled subscription before the period ends.
|
|
1070
|
+
*/
|
|
1071
|
+
resume = $action({
|
|
1072
|
+
method: "POST",
|
|
1073
|
+
path: `${this.url}/mine/resume`,
|
|
1074
|
+
group: this.group,
|
|
1075
|
+
use: [$secure({ permissions: ["subscription:update"] })],
|
|
1076
|
+
description: "Resume a cancelled subscription",
|
|
1077
|
+
schema: { response: okSchema },
|
|
1078
|
+
handler: async ({ user }) => {
|
|
1079
|
+
const sub = await this.service.getByOrganization(user.organization);
|
|
1080
|
+
if (!sub) throw new NotFoundError("No subscription found for your organization");
|
|
1081
|
+
await this.service.resume(sub.id);
|
|
1082
|
+
return { ok: true };
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
/**
|
|
1086
|
+
* Get the billing event history for the current organization's subscription.
|
|
1087
|
+
*/
|
|
1088
|
+
getSubscriptionHistory = $action({
|
|
1089
|
+
path: `${this.url}/mine/history`,
|
|
1090
|
+
group: this.group,
|
|
1091
|
+
use: [$secure()],
|
|
1092
|
+
description: "Get the subscription billing event history",
|
|
1093
|
+
schema: { response: t.array(subscriptionEventResourceSchema) },
|
|
1094
|
+
handler: async ({ user }) => {
|
|
1095
|
+
const sub = await this.service.getByOrganization(user.organization);
|
|
1096
|
+
if (!sub) throw new NotFoundError("No subscription found for your organization");
|
|
1097
|
+
return this.service.getHistory(sub.id);
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
/**
|
|
1101
|
+
* Get the feature and usage limit entitlements for the current organization.
|
|
1102
|
+
*/
|
|
1103
|
+
getEntitlements = $action({
|
|
1104
|
+
path: `${this.url}/mine/entitlements`,
|
|
1105
|
+
group: this.group,
|
|
1106
|
+
use: [$secure()],
|
|
1107
|
+
description: "Get the feature and limit entitlements for the current organization",
|
|
1108
|
+
schema: { response: entitlementsSchema },
|
|
1109
|
+
handler: ({ user }) => this.service.getEntitlements(user.organization)
|
|
1110
|
+
});
|
|
1111
|
+
};
|
|
1112
|
+
//#endregion
|
|
1113
|
+
//#region ../../src/api/subscriptions/jobs/SubscriptionJobs.ts
|
|
1114
|
+
var SubscriptionJobs = class {
|
|
1115
|
+
log = $logger();
|
|
1116
|
+
dateTime = $inject(DateTimeProvider);
|
|
1117
|
+
paymentService = $inject(PaymentService);
|
|
1118
|
+
config = $inject(SubscriptionConfig);
|
|
1119
|
+
subscriptionRepo = $repository(subscriptions);
|
|
1120
|
+
eventRepo = $repository(subscriptionEvents);
|
|
1121
|
+
/**
|
|
1122
|
+
* Record a subscription event in the event log.
|
|
1123
|
+
*/
|
|
1124
|
+
async recordEvent(subscriptionId, organizationId, type, context) {
|
|
1125
|
+
await this.eventRepo.create({
|
|
1126
|
+
subscriptionId,
|
|
1127
|
+
organizationId,
|
|
1128
|
+
type,
|
|
1129
|
+
previousStatus: context?.previousStatus,
|
|
1130
|
+
newStatus: context?.newStatus,
|
|
1131
|
+
paymentIntentId: context?.paymentIntentId,
|
|
1132
|
+
amount: context?.amount,
|
|
1133
|
+
currency: context?.currency,
|
|
1134
|
+
triggeredBy: context?.triggeredBy,
|
|
1135
|
+
note: context?.note
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Creates payment intents for subscriptions due for renewal.
|
|
1140
|
+
* Runs hourly.
|
|
1141
|
+
*/
|
|
1142
|
+
billingCycle = $job({
|
|
1143
|
+
cron: "0 * * * *",
|
|
1144
|
+
lock: true,
|
|
1145
|
+
timeout: [10, "minute"],
|
|
1146
|
+
handler: async ({ now }) => {
|
|
1147
|
+
const nowISO = now.toISOString();
|
|
1148
|
+
const due = await this.subscriptionRepo.findMany({ where: {
|
|
1149
|
+
nextBillingAt: { lte: nowISO },
|
|
1150
|
+
status: { inArray: ["active", "trialing"] }
|
|
1151
|
+
} });
|
|
1152
|
+
this.log.info(`Billing cycle: processing ${due.length} subscription(s)`);
|
|
1153
|
+
for (const sub of due) try {
|
|
1154
|
+
const pricing = await this.config.getPlanPricing(sub.planId, sub.interval);
|
|
1155
|
+
const intent = await this.paymentService.createIntent(pricing.amount, pricing.currency, { subscriptionId: sub.id });
|
|
1156
|
+
await this.subscriptionRepo.updateById(sub.id, { lastPaymentIntentId: intent.id });
|
|
1157
|
+
this.log.debug("Created payment intent for subscription", {
|
|
1158
|
+
subscriptionId: sub.id,
|
|
1159
|
+
intentId: intent.id
|
|
1160
|
+
});
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
this.log.error("Failed to create payment intent for subscription", {
|
|
1163
|
+
subscriptionId: sub.id,
|
|
1164
|
+
error: err
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
/**
|
|
1170
|
+
* Retries failed payments on the dunning schedule.
|
|
1171
|
+
* Runs hourly.
|
|
1172
|
+
*/
|
|
1173
|
+
dunningRetry = $job({
|
|
1174
|
+
cron: "0 * * * *",
|
|
1175
|
+
lock: true,
|
|
1176
|
+
timeout: [10, "minute"],
|
|
1177
|
+
handler: async ({ now }) => {
|
|
1178
|
+
const nowISO = now.toISOString();
|
|
1179
|
+
const pastDue = await this.subscriptionRepo.findMany({ where: {
|
|
1180
|
+
dunningNextRetryAt: { lte: nowISO },
|
|
1181
|
+
status: { eq: "past_due" }
|
|
1182
|
+
} });
|
|
1183
|
+
this.log.info(`Dunning retry: processing ${pastDue.length} subscription(s)`);
|
|
1184
|
+
const settings = await this.config.getSettings();
|
|
1185
|
+
for (const sub of pastDue) try {
|
|
1186
|
+
const pricing = await this.config.getPlanPricing(sub.planId, sub.interval);
|
|
1187
|
+
const intent = await this.paymentService.createIntent(pricing.amount, pricing.currency, { subscriptionId: sub.id });
|
|
1188
|
+
const newAttempt = sub.dunningAttempt + 1;
|
|
1189
|
+
const scheduleDays = settings.dunningSchedule[newAttempt - 1];
|
|
1190
|
+
const nextRetry = scheduleDays !== void 0 ? now.add(scheduleDays, "days").toISOString() : void 0;
|
|
1191
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
1192
|
+
lastPaymentIntentId: intent.id,
|
|
1193
|
+
dunningAttempt: newAttempt,
|
|
1194
|
+
dunningNextRetryAt: nextRetry
|
|
1195
|
+
});
|
|
1196
|
+
await this.recordEvent(sub.id, sub.organizationId, "payment_retried", {
|
|
1197
|
+
paymentIntentId: intent.id,
|
|
1198
|
+
note: `Dunning retry attempt ${newAttempt}`
|
|
1199
|
+
});
|
|
1200
|
+
this.log.debug("Dunning retry payment intent created", {
|
|
1201
|
+
subscriptionId: sub.id,
|
|
1202
|
+
attempt: newAttempt
|
|
1203
|
+
});
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
this.log.error("Failed to create dunning retry intent", {
|
|
1206
|
+
subscriptionId: sub.id,
|
|
1207
|
+
error: err
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
/**
|
|
1213
|
+
* Handles trial expirations.
|
|
1214
|
+
* Runs hourly.
|
|
1215
|
+
*/
|
|
1216
|
+
trialExpiry = $job({
|
|
1217
|
+
cron: "0 * * * *",
|
|
1218
|
+
lock: true,
|
|
1219
|
+
handler: async ({ now }) => {
|
|
1220
|
+
const nowISO = now.toISOString();
|
|
1221
|
+
const expired = await this.subscriptionRepo.findMany({ where: {
|
|
1222
|
+
trialEnd: { lte: nowISO },
|
|
1223
|
+
status: { eq: "trialing" }
|
|
1224
|
+
} });
|
|
1225
|
+
this.log.info(`Trial expiry: processing ${expired.length} subscription(s)`);
|
|
1226
|
+
for (const sub of expired) try {
|
|
1227
|
+
const pricing = await this.config.getPlanPricing(sub.planId, sub.interval);
|
|
1228
|
+
const intent = await this.paymentService.createIntent(pricing.amount, pricing.currency, { subscriptionId: sub.id });
|
|
1229
|
+
await this.subscriptionRepo.updateById(sub.id, { lastPaymentIntentId: intent.id });
|
|
1230
|
+
this.log.debug("Created payment intent for trial expiry", {
|
|
1231
|
+
subscriptionId: sub.id,
|
|
1232
|
+
intentId: intent.id
|
|
1233
|
+
});
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
this.log.error("Failed to process trial expiry", {
|
|
1236
|
+
subscriptionId: sub.id,
|
|
1237
|
+
error: err
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
/**
|
|
1243
|
+
* Expires cancelled subscriptions that reached period end.
|
|
1244
|
+
* Runs hourly.
|
|
1245
|
+
*/
|
|
1246
|
+
expirationSweep = $job({
|
|
1247
|
+
cron: "0 * * * *",
|
|
1248
|
+
lock: true,
|
|
1249
|
+
handler: async ({ now }) => {
|
|
1250
|
+
const nowISO = now.toISOString();
|
|
1251
|
+
const toExpire = await this.subscriptionRepo.findMany({ where: {
|
|
1252
|
+
currentPeriodEnd: { lte: nowISO },
|
|
1253
|
+
status: { eq: "cancelled" },
|
|
1254
|
+
cancelAtPeriodEnd: { eq: true }
|
|
1255
|
+
} });
|
|
1256
|
+
this.log.info(`Expiration sweep: expiring ${toExpire.length} subscription(s)`);
|
|
1257
|
+
for (const sub of toExpire) try {
|
|
1258
|
+
await this.subscriptionRepo.updateById(sub.id, { status: "expired" });
|
|
1259
|
+
await this.recordEvent(sub.id, sub.organizationId, "expired", {
|
|
1260
|
+
previousStatus: "cancelled",
|
|
1261
|
+
newStatus: "expired"
|
|
1262
|
+
});
|
|
1263
|
+
this.log.debug("Subscription expired", { subscriptionId: sub.id });
|
|
1264
|
+
} catch (err) {
|
|
1265
|
+
this.log.error("Failed to expire subscription", {
|
|
1266
|
+
subscriptionId: sub.id,
|
|
1267
|
+
error: err
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
/**
|
|
1273
|
+
* Suspends past_due subscriptions where grace period has elapsed.
|
|
1274
|
+
* Runs daily at 2 AM.
|
|
1275
|
+
*/
|
|
1276
|
+
gracePeriodSweep = $job({
|
|
1277
|
+
cron: "0 2 * * *",
|
|
1278
|
+
lock: true,
|
|
1279
|
+
handler: async ({ now }) => {
|
|
1280
|
+
const gracePeriodDays = (await this.config.getSettings()).gracePeriodDays;
|
|
1281
|
+
const toSuspend = (await this.subscriptionRepo.findMany({ where: { status: { eq: "past_due" } } })).filter((sub) => {
|
|
1282
|
+
if (!sub.dunningStartedAt) return false;
|
|
1283
|
+
const graceEnd = this.dateTime.of(sub.dunningStartedAt).add(gracePeriodDays, "days");
|
|
1284
|
+
return !now.isBefore(graceEnd.toISOString());
|
|
1285
|
+
});
|
|
1286
|
+
this.log.info(`Grace period sweep: suspending ${toSuspend.length} subscription(s)`);
|
|
1287
|
+
for (const sub of toSuspend) try {
|
|
1288
|
+
await this.subscriptionRepo.updateById(sub.id, { status: "suspended" });
|
|
1289
|
+
await this.recordEvent(sub.id, sub.organizationId, "suspended", {
|
|
1290
|
+
previousStatus: "past_due",
|
|
1291
|
+
newStatus: "suspended"
|
|
1292
|
+
});
|
|
1293
|
+
this.log.debug("Subscription suspended after grace period", { subscriptionId: sub.id });
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
this.log.error("Failed to suspend subscription", {
|
|
1296
|
+
subscriptionId: sub.id,
|
|
1297
|
+
error: err
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
/**
|
|
1303
|
+
* Purges old subscription events older than 365 days.
|
|
1304
|
+
* Runs daily at 3 AM.
|
|
1305
|
+
*/
|
|
1306
|
+
purgeEvents = $job({
|
|
1307
|
+
cron: "0 3 * * *",
|
|
1308
|
+
lock: true,
|
|
1309
|
+
handler: async ({ now }) => {
|
|
1310
|
+
const cutoff = now.subtract(365, "days").toISOString();
|
|
1311
|
+
const old = await this.eventRepo.findMany({ where: { createdAt: { lt: cutoff } } });
|
|
1312
|
+
this.log.info(`Purge events: removing ${old.length} old event(s)`);
|
|
1313
|
+
for (const event of old) await this.eventRepo.deleteById(event.id);
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
};
|
|
1317
|
+
//#endregion
|
|
1318
|
+
//#region ../../src/api/subscriptions/notifications/SubscriptionNotifications.ts
|
|
1319
|
+
var SubscriptionNotifications = class {
|
|
1320
|
+
/**
|
|
1321
|
+
* Sent when a trial is ending soon.
|
|
1322
|
+
*/
|
|
1323
|
+
trialEnding = $notification({
|
|
1324
|
+
name: "subscription-trial-ending",
|
|
1325
|
+
category: "subscriptions",
|
|
1326
|
+
schema: t.object({
|
|
1327
|
+
planName: t.text(),
|
|
1328
|
+
trialEndDate: t.text(),
|
|
1329
|
+
amount: t.text(),
|
|
1330
|
+
interval: t.text()
|
|
1331
|
+
}),
|
|
1332
|
+
email: {
|
|
1333
|
+
subject: "Your trial is ending soon",
|
|
1334
|
+
body: (v) => `Your ${v.planName} trial is ending on ${v.trialEndDate}. You'll be charged ${v.amount}/${v.interval}.`
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
/**
|
|
1338
|
+
* Sent when a payment fails. Critical notification.
|
|
1339
|
+
*/
|
|
1340
|
+
paymentFailed = $notification({
|
|
1341
|
+
name: "subscription-payment-failed",
|
|
1342
|
+
category: "subscriptions",
|
|
1343
|
+
critical: true,
|
|
1344
|
+
schema: t.object({
|
|
1345
|
+
planName: t.text(),
|
|
1346
|
+
amount: t.text(),
|
|
1347
|
+
retryDate: t.optional(t.text())
|
|
1348
|
+
}),
|
|
1349
|
+
email: {
|
|
1350
|
+
subject: "Payment failed for your subscription",
|
|
1351
|
+
body: (v) => `We couldn't charge your card for ${v.planName} (${v.amount}). ${v.retryDate ? `We'll retry on ${v.retryDate}.` : "Please update your payment method."}`
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
/**
|
|
1355
|
+
* Sent when a subscription is suspended due to failed payments. Critical notification.
|
|
1356
|
+
*/
|
|
1357
|
+
subscriptionSuspended = $notification({
|
|
1358
|
+
name: "subscription-suspended",
|
|
1359
|
+
category: "subscriptions",
|
|
1360
|
+
critical: true,
|
|
1361
|
+
schema: t.object({ planName: t.text() }),
|
|
1362
|
+
email: {
|
|
1363
|
+
subject: "Your subscription has been suspended",
|
|
1364
|
+
body: (v) => `Your ${v.planName} subscription has been suspended due to failed payments. Update your payment method to reactivate.`
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
/**
|
|
1368
|
+
* Sent when a subscription is successfully renewed.
|
|
1369
|
+
*/
|
|
1370
|
+
subscriptionRenewed = $notification({
|
|
1371
|
+
name: "subscription-renewed",
|
|
1372
|
+
category: "subscriptions",
|
|
1373
|
+
schema: t.object({
|
|
1374
|
+
planName: t.text(),
|
|
1375
|
+
amount: t.text(),
|
|
1376
|
+
nextBillingDate: t.text()
|
|
1377
|
+
}),
|
|
1378
|
+
email: {
|
|
1379
|
+
subject: "Payment received — subscription renewed",
|
|
1380
|
+
body: (v) => `Your ${v.planName} subscription has been renewed. Amount: ${v.amount}. Next billing: ${v.nextBillingDate}.`
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
/**
|
|
1384
|
+
* Sent when a subscription plan is changed.
|
|
1385
|
+
*/
|
|
1386
|
+
planChanged = $notification({
|
|
1387
|
+
name: "subscription-plan-changed",
|
|
1388
|
+
category: "subscriptions",
|
|
1389
|
+
schema: t.object({
|
|
1390
|
+
oldPlanName: t.text(),
|
|
1391
|
+
newPlanName: t.text(),
|
|
1392
|
+
effectiveDate: t.text()
|
|
1393
|
+
}),
|
|
1394
|
+
email: {
|
|
1395
|
+
subject: "Your subscription plan has been changed",
|
|
1396
|
+
body: (v) => `Your plan has been changed from ${v.oldPlanName} to ${v.newPlanName}, effective ${v.effectiveDate}.`
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
/**
|
|
1400
|
+
* Sent when a subscription is cancelled.
|
|
1401
|
+
*/
|
|
1402
|
+
cancellationConfirmed = $notification({
|
|
1403
|
+
name: "subscription-cancelled",
|
|
1404
|
+
category: "subscriptions",
|
|
1405
|
+
schema: t.object({
|
|
1406
|
+
planName: t.text(),
|
|
1407
|
+
accessUntil: t.optional(t.text())
|
|
1408
|
+
}),
|
|
1409
|
+
email: {
|
|
1410
|
+
subject: "Your subscription has been cancelled",
|
|
1411
|
+
body: (v) => `Your ${v.planName} subscription has been cancelled.${v.accessUntil ? ` You'll have access until ${v.accessUntil}.` : ""}`
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
};
|
|
1415
|
+
//#endregion
|
|
1416
|
+
//#region ../../src/api/subscriptions/services/BillingService.ts
|
|
1417
|
+
var BillingService = class {
|
|
1418
|
+
alepha = $inject(Alepha);
|
|
1419
|
+
log = $logger();
|
|
1420
|
+
dateTime = $inject(DateTimeProvider);
|
|
1421
|
+
subscriptionRepo = $repository(subscriptions);
|
|
1422
|
+
eventRepo = $repository(subscriptionEvents);
|
|
1423
|
+
paymentService = $inject(PaymentService);
|
|
1424
|
+
config = $inject(SubscriptionConfig);
|
|
1425
|
+
/**
|
|
1426
|
+
* React to successful payment capture.
|
|
1427
|
+
* Routes to the appropriate handler based on subscription status.
|
|
1428
|
+
*/
|
|
1429
|
+
onPaymentCaptured = $hook({
|
|
1430
|
+
on: "payments:captured",
|
|
1431
|
+
handler: async (event) => {
|
|
1432
|
+
const sub = await this.findByPaymentIntent(event.intentId);
|
|
1433
|
+
if (!sub) return;
|
|
1434
|
+
if (sub.status === "trialing") await this.activate(sub, event);
|
|
1435
|
+
else if (sub.status === "active") await this.renew(sub, event);
|
|
1436
|
+
else if (sub.status === "past_due") await this.recoverFromDunning(sub, event);
|
|
1437
|
+
else if (sub.status === "suspended") await this.reactivateFromPayment(sub, event);
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
/**
|
|
1441
|
+
* React to failed payment.
|
|
1442
|
+
* Starts or advances the dunning flow.
|
|
1443
|
+
*/
|
|
1444
|
+
onPaymentFailed = $hook({
|
|
1445
|
+
on: "payments:failed",
|
|
1446
|
+
handler: async (event) => {
|
|
1447
|
+
const sub = await this.findByPaymentIntent(event.intentId);
|
|
1448
|
+
if (!sub) return;
|
|
1449
|
+
await this.handlePaymentFailure(sub, event);
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
/**
|
|
1453
|
+
* Find a subscription by its last payment intent ID.
|
|
1454
|
+
* Returns null if no subscription matches.
|
|
1455
|
+
*/
|
|
1456
|
+
async findByPaymentIntent(intentId) {
|
|
1457
|
+
return await this.subscriptionRepo.findOne({ where: { lastPaymentIntentId: { eq: intentId } } }) ?? null;
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Trial to active transition.
|
|
1461
|
+
* Sets the first paid billing period and records activation events.
|
|
1462
|
+
*/
|
|
1463
|
+
async activate(sub, event) {
|
|
1464
|
+
const orgId = sub.organizationId;
|
|
1465
|
+
const nowISO = this.dateTime.now().toISOString();
|
|
1466
|
+
const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
|
|
1467
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
1468
|
+
status: "active",
|
|
1469
|
+
lastPaymentAt: nowISO,
|
|
1470
|
+
lastPaymentIntentId: event.intentId,
|
|
1471
|
+
currentPeriodStart: nowISO,
|
|
1472
|
+
currentPeriodEnd: periodEnd,
|
|
1473
|
+
nextBillingAt: periodEnd
|
|
1474
|
+
});
|
|
1475
|
+
await this.recordEvent(sub.id, orgId, "trial_ended", {
|
|
1476
|
+
previousStatus: "trialing",
|
|
1477
|
+
newStatus: "active",
|
|
1478
|
+
paymentIntentId: event.intentId,
|
|
1479
|
+
amount: event.amount,
|
|
1480
|
+
currency: event.currency
|
|
1481
|
+
});
|
|
1482
|
+
await this.recordEvent(sub.id, orgId, "activated", {
|
|
1483
|
+
previousStatus: "trialing",
|
|
1484
|
+
newStatus: "active",
|
|
1485
|
+
paymentIntentId: event.intentId,
|
|
1486
|
+
amount: event.amount,
|
|
1487
|
+
currency: event.currency
|
|
1488
|
+
});
|
|
1489
|
+
this.log.info("Subscription activated from trial", {
|
|
1490
|
+
id: sub.id,
|
|
1491
|
+
organizationId: orgId,
|
|
1492
|
+
planId: sub.planId
|
|
1493
|
+
});
|
|
1494
|
+
await this.alepha.events.emit("subscription:activated", {
|
|
1495
|
+
subscriptionId: sub.id,
|
|
1496
|
+
organizationId: orgId,
|
|
1497
|
+
planId: sub.planId
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Active to active cycle renewal.
|
|
1502
|
+
* Applies any pending plan change, then advances the billing period.
|
|
1503
|
+
*/
|
|
1504
|
+
async renew(sub, event) {
|
|
1505
|
+
const orgId = sub.organizationId;
|
|
1506
|
+
let effectivePlanId = sub.planId;
|
|
1507
|
+
let effectiveInterval = sub.interval;
|
|
1508
|
+
if (sub.pendingPlanId) {
|
|
1509
|
+
effectivePlanId = sub.pendingPlanId;
|
|
1510
|
+
effectiveInterval = sub.pendingInterval ?? sub.interval;
|
|
1511
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
1512
|
+
planId: effectivePlanId,
|
|
1513
|
+
interval: effectiveInterval,
|
|
1514
|
+
pendingPlanId: void 0,
|
|
1515
|
+
pendingInterval: void 0
|
|
1516
|
+
});
|
|
1517
|
+
await this.recordEvent(sub.id, orgId, "plan_changed", {
|
|
1518
|
+
previousPlanId: sub.planId,
|
|
1519
|
+
newPlanId: effectivePlanId,
|
|
1520
|
+
note: `Plan changed on renewal from '${sub.planId}' to '${effectivePlanId}'`
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
const newPeriodStart = sub.currentPeriodEnd;
|
|
1524
|
+
const newPeriodEnd = this.computeIntervalEnd(newPeriodStart, effectiveInterval);
|
|
1525
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
1526
|
+
currentPeriodStart: newPeriodStart,
|
|
1527
|
+
currentPeriodEnd: newPeriodEnd,
|
|
1528
|
+
lastPaymentAt: this.dateTime.now().toISOString(),
|
|
1529
|
+
lastPaymentIntentId: event.intentId,
|
|
1530
|
+
nextBillingAt: newPeriodEnd
|
|
1531
|
+
});
|
|
1532
|
+
await this.recordEvent(sub.id, orgId, "renewed", {
|
|
1533
|
+
paymentIntentId: event.intentId,
|
|
1534
|
+
amount: event.amount,
|
|
1535
|
+
currency: event.currency
|
|
1536
|
+
});
|
|
1537
|
+
this.log.info("Subscription renewed", {
|
|
1538
|
+
id: sub.id,
|
|
1539
|
+
organizationId: orgId,
|
|
1540
|
+
planId: effectivePlanId,
|
|
1541
|
+
periodEnd: newPeriodEnd
|
|
1542
|
+
});
|
|
1543
|
+
await this.alepha.events.emit("subscription:renewed", {
|
|
1544
|
+
subscriptionId: sub.id,
|
|
1545
|
+
organizationId: orgId,
|
|
1546
|
+
planId: effectivePlanId
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Recover from dunning: past_due to active.
|
|
1551
|
+
* Resets all dunning state and records reactivation.
|
|
1552
|
+
*/
|
|
1553
|
+
async recoverFromDunning(sub, event) {
|
|
1554
|
+
const orgId = sub.organizationId;
|
|
1555
|
+
const nowISO = this.dateTime.now().toISOString();
|
|
1556
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
1557
|
+
status: "active",
|
|
1558
|
+
lastPaymentAt: nowISO,
|
|
1559
|
+
lastPaymentIntentId: event.intentId,
|
|
1560
|
+
dunningStartedAt: void 0,
|
|
1561
|
+
dunningAttempt: 0,
|
|
1562
|
+
dunningNextRetryAt: void 0
|
|
1563
|
+
});
|
|
1564
|
+
await this.recordEvent(sub.id, orgId, "reactivated", {
|
|
1565
|
+
previousStatus: "past_due",
|
|
1566
|
+
newStatus: "active",
|
|
1567
|
+
paymentIntentId: event.intentId,
|
|
1568
|
+
amount: event.amount,
|
|
1569
|
+
currency: event.currency
|
|
1570
|
+
});
|
|
1571
|
+
this.log.info("Subscription recovered from dunning", {
|
|
1572
|
+
id: sub.id,
|
|
1573
|
+
organizationId: orgId
|
|
1574
|
+
});
|
|
1575
|
+
await this.alepha.events.emit("subscription:reactivated", {
|
|
1576
|
+
subscriptionId: sub.id,
|
|
1577
|
+
organizationId: orgId,
|
|
1578
|
+
planId: sub.planId
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Reactivate from suspended state after a successful payment.
|
|
1583
|
+
* Resets dunning, sets a fresh billing period, and records reactivation.
|
|
1584
|
+
*/
|
|
1585
|
+
async reactivateFromPayment(sub, event) {
|
|
1586
|
+
const orgId = sub.organizationId;
|
|
1587
|
+
const nowISO = this.dateTime.now().toISOString();
|
|
1588
|
+
const periodEnd = this.computeIntervalEnd(nowISO, sub.interval);
|
|
1589
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
1590
|
+
status: "active",
|
|
1591
|
+
currentPeriodStart: nowISO,
|
|
1592
|
+
currentPeriodEnd: periodEnd,
|
|
1593
|
+
nextBillingAt: periodEnd,
|
|
1594
|
+
lastPaymentAt: nowISO,
|
|
1595
|
+
lastPaymentIntentId: event.intentId,
|
|
1596
|
+
dunningStartedAt: void 0,
|
|
1597
|
+
dunningAttempt: 0,
|
|
1598
|
+
dunningNextRetryAt: void 0
|
|
1599
|
+
});
|
|
1600
|
+
await this.recordEvent(sub.id, orgId, "reactivated", {
|
|
1601
|
+
previousStatus: "suspended",
|
|
1602
|
+
newStatus: "active",
|
|
1603
|
+
paymentIntentId: event.intentId,
|
|
1604
|
+
amount: event.amount,
|
|
1605
|
+
currency: event.currency
|
|
1606
|
+
});
|
|
1607
|
+
this.log.info("Subscription reactivated from suspended", {
|
|
1608
|
+
id: sub.id,
|
|
1609
|
+
organizationId: orgId,
|
|
1610
|
+
planId: sub.planId
|
|
1611
|
+
});
|
|
1612
|
+
await this.alepha.events.emit("subscription:reactivated", {
|
|
1613
|
+
subscriptionId: sub.id,
|
|
1614
|
+
organizationId: orgId,
|
|
1615
|
+
planId: sub.planId
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Handle a failed payment: start or advance dunning.
|
|
1620
|
+
* Updates dunning state and transitions to past_due if needed.
|
|
1621
|
+
*/
|
|
1622
|
+
async handlePaymentFailure(sub, event) {
|
|
1623
|
+
const orgId = sub.organizationId;
|
|
1624
|
+
const now = this.dateTime.now();
|
|
1625
|
+
const nowISO = now.toISOString();
|
|
1626
|
+
const schedule = (await this.config.getSettings()).dunningSchedule;
|
|
1627
|
+
let attempt;
|
|
1628
|
+
if (sub.dunningAttempt === 0) {
|
|
1629
|
+
attempt = 1;
|
|
1630
|
+
await this.subscriptionRepo.updateById(sub.id, {
|
|
1631
|
+
dunningStartedAt: nowISO,
|
|
1632
|
+
dunningAttempt: attempt
|
|
1633
|
+
});
|
|
1634
|
+
} else {
|
|
1635
|
+
attempt = sub.dunningAttempt + 1;
|
|
1636
|
+
await this.subscriptionRepo.updateById(sub.id, { dunningAttempt: attempt });
|
|
1637
|
+
}
|
|
1638
|
+
if (sub.status !== "past_due") {
|
|
1639
|
+
await this.subscriptionRepo.updateById(sub.id, { status: "past_due" });
|
|
1640
|
+
await this.recordEvent(sub.id, orgId, "past_due", {
|
|
1641
|
+
previousStatus: sub.status,
|
|
1642
|
+
newStatus: "past_due",
|
|
1643
|
+
paymentIntentId: event.intentId
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
const scheduleDays = schedule[attempt - 1];
|
|
1647
|
+
if (scheduleDays !== void 0) {
|
|
1648
|
+
const nextRetry = now.add(scheduleDays, "days").toISOString();
|
|
1649
|
+
await this.subscriptionRepo.updateById(sub.id, { dunningNextRetryAt: nextRetry });
|
|
1650
|
+
} else await this.subscriptionRepo.updateById(sub.id, { dunningNextRetryAt: void 0 });
|
|
1651
|
+
await this.recordEvent(sub.id, orgId, "payment_failed", {
|
|
1652
|
+
paymentIntentId: event.intentId,
|
|
1653
|
+
amount: event.amount,
|
|
1654
|
+
currency: event.currency,
|
|
1655
|
+
note: `Dunning attempt ${attempt}/${schedule.length}`
|
|
1656
|
+
});
|
|
1657
|
+
this.log.warn("Subscription payment failed", {
|
|
1658
|
+
id: sub.id,
|
|
1659
|
+
organizationId: orgId,
|
|
1660
|
+
attempt,
|
|
1661
|
+
maxAttempts: schedule.length
|
|
1662
|
+
});
|
|
1663
|
+
await this.alepha.events.emit("subscription:payment_failed", {
|
|
1664
|
+
subscriptionId: sub.id,
|
|
1665
|
+
organizationId: orgId,
|
|
1666
|
+
planId: sub.planId,
|
|
1667
|
+
attempt
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Compute the end of a billing interval from a start date.
|
|
1672
|
+
*/
|
|
1673
|
+
computeIntervalEnd(start, interval) {
|
|
1674
|
+
const startDate = this.dateTime.of(start);
|
|
1675
|
+
const unit = interval === "monthly" ? "months" : "years";
|
|
1676
|
+
return startDate.add(1, unit).toISOString();
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Record a subscription event in the event log.
|
|
1680
|
+
*/
|
|
1681
|
+
async recordEvent(subscriptionId, organizationId, type, context) {
|
|
1682
|
+
await this.eventRepo.create({
|
|
1683
|
+
subscriptionId,
|
|
1684
|
+
organizationId,
|
|
1685
|
+
type,
|
|
1686
|
+
previousStatus: context?.previousStatus,
|
|
1687
|
+
newStatus: context?.newStatus,
|
|
1688
|
+
previousPlanId: context?.previousPlanId,
|
|
1689
|
+
newPlanId: context?.newPlanId,
|
|
1690
|
+
paymentIntentId: context?.paymentIntentId,
|
|
1691
|
+
amount: context?.amount,
|
|
1692
|
+
currency: context?.currency,
|
|
1693
|
+
triggeredBy: context?.triggeredBy,
|
|
1694
|
+
userId: context?.userId,
|
|
1695
|
+
note: context?.note
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
//#endregion
|
|
1700
|
+
//#region ../../src/api/subscriptions/services/UsageService.ts
|
|
1701
|
+
/**
|
|
1702
|
+
* Tracks and enforces per-organization resource usage limits.
|
|
1703
|
+
*
|
|
1704
|
+
* Usage counters are keyed by `organizationId:resource:YYYY-MM` and stored in the cache.
|
|
1705
|
+
* Limits are resolved from the organization's current subscription plan.
|
|
1706
|
+
*/
|
|
1707
|
+
var UsageService = class {
|
|
1708
|
+
cache = $inject(CacheProvider);
|
|
1709
|
+
dateTime = $inject(DateTimeProvider);
|
|
1710
|
+
subscriptionService = $inject(SubscriptionService);
|
|
1711
|
+
/**
|
|
1712
|
+
* Increment a resource counter for the current period and return the usage result.
|
|
1713
|
+
*
|
|
1714
|
+
* @param organizationId The organization to track usage for.
|
|
1715
|
+
* @param resource The resource identifier (e.g., "api_calls", "seats").
|
|
1716
|
+
* @param amount Amount to increment by (default: 1).
|
|
1717
|
+
*/
|
|
1718
|
+
async increment(organizationId, resource, amount = 1) {
|
|
1719
|
+
const limit = await this.subscriptionService.limit(organizationId, resource);
|
|
1720
|
+
const key = this.buildKey(organizationId, resource);
|
|
1721
|
+
const current = await this.cache.incr("subscriptions:usage", key, amount);
|
|
1722
|
+
return {
|
|
1723
|
+
allowed: limit === -1 || current <= limit,
|
|
1724
|
+
current,
|
|
1725
|
+
limit,
|
|
1726
|
+
remaining: limit === -1 ? -1 : Math.max(0, limit - current)
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Get the current usage for a resource without incrementing.
|
|
1731
|
+
*
|
|
1732
|
+
* @param organizationId The organization to query usage for.
|
|
1733
|
+
* @param resource The resource identifier.
|
|
1734
|
+
*/
|
|
1735
|
+
async getUsage(organizationId, resource) {
|
|
1736
|
+
const limit = await this.subscriptionService.limit(organizationId, resource);
|
|
1737
|
+
const key = this.buildKey(organizationId, resource);
|
|
1738
|
+
const current = await this.cache.incr("subscriptions:usage", key, 0);
|
|
1739
|
+
return {
|
|
1740
|
+
allowed: limit === -1 || current <= limit,
|
|
1741
|
+
current,
|
|
1742
|
+
limit,
|
|
1743
|
+
remaining: limit === -1 ? -1 : Math.max(0, limit - current)
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Reset all usage counters for an organization.
|
|
1748
|
+
*
|
|
1749
|
+
* Used at the start of a new billing period.
|
|
1750
|
+
*
|
|
1751
|
+
* @param organizationId The organization whose counters to reset.
|
|
1752
|
+
*/
|
|
1753
|
+
async resetForPeriod(organizationId) {
|
|
1754
|
+
const pattern = this.buildKey(organizationId, "*");
|
|
1755
|
+
await this.cache.invalidateKeys("subscriptions:usage", [pattern]);
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* Build the cache key for a usage counter.
|
|
1759
|
+
*
|
|
1760
|
+
* Format: `organizationId:resource:YYYY-MM`
|
|
1761
|
+
*/
|
|
1762
|
+
buildKey(organizationId, resource) {
|
|
1763
|
+
return `${organizationId}:${resource}:${this.dateTime.now().format("YYYY-MM")}`;
|
|
1764
|
+
}
|
|
1765
|
+
};
|
|
1766
|
+
//#endregion
|
|
1767
|
+
//#region ../../src/api/subscriptions/middleware/$requireLimit.ts
|
|
1768
|
+
/**
|
|
1769
|
+
* Middleware that enforces a per-organization usage limit for a resource.
|
|
1770
|
+
*
|
|
1771
|
+
* Resolves the organization from `args[0].user.organization`, increments the
|
|
1772
|
+
* usage counter for the given resource, and throws `ForbiddenError` if the
|
|
1773
|
+
* plan limit has been reached.
|
|
1774
|
+
* Throws `ForbiddenError` if no organization is present or the limit is exceeded.
|
|
1775
|
+
*
|
|
1776
|
+
* ```typescript
|
|
1777
|
+
* class ApiController {
|
|
1778
|
+
* search = $action({
|
|
1779
|
+
* use: [$requireLimit("api_calls")],
|
|
1780
|
+
* handler: async ({ query }) => { ... },
|
|
1781
|
+
* });
|
|
1782
|
+
* }
|
|
1783
|
+
* ```
|
|
1784
|
+
*
|
|
1785
|
+
* @param resource The resource identifier to track (e.g., "api_calls", "exports").
|
|
1786
|
+
*/
|
|
1787
|
+
const $requireLimit = (resource) => {
|
|
1788
|
+
const { alepha } = $context();
|
|
1789
|
+
const usageService = alepha.inject(UsageService);
|
|
1790
|
+
return createMiddleware({
|
|
1791
|
+
name: "$requireLimit",
|
|
1792
|
+
options: { resource },
|
|
1793
|
+
handler: ({ next }) => {
|
|
1794
|
+
return async (...args) => {
|
|
1795
|
+
const user = args[0]?.user;
|
|
1796
|
+
if (!user?.organization) throw new ForbiddenError("Organization required");
|
|
1797
|
+
const result = await usageService.increment(user.organization, resource);
|
|
1798
|
+
if (!result.allowed) throw new ForbiddenError(`Usage limit for '${resource}' has been reached (${result.current}/${result.limit})`);
|
|
1799
|
+
return next(...args);
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
};
|
|
1804
|
+
//#endregion
|
|
1805
|
+
//#region ../../src/api/subscriptions/middleware/$requirePlan.ts
|
|
1806
|
+
/**
|
|
1807
|
+
* Middleware that gates access to a handler behind a subscription feature flag.
|
|
1808
|
+
*
|
|
1809
|
+
* Resolves the organization from `args[0].user.organization` and checks whether
|
|
1810
|
+
* the organization's current plan includes the given feature.
|
|
1811
|
+
* Throws `ForbiddenError` if no organization is present or the feature is not available.
|
|
1812
|
+
*
|
|
1813
|
+
* ```typescript
|
|
1814
|
+
* class ReportController {
|
|
1815
|
+
* generate = $action({
|
|
1816
|
+
* use: [$requirePlan("advanced_reports")],
|
|
1817
|
+
* handler: async ({ user }) => { ... },
|
|
1818
|
+
* });
|
|
1819
|
+
* }
|
|
1820
|
+
* ```
|
|
1821
|
+
*
|
|
1822
|
+
* @param feature The feature identifier to check against the plan's feature list.
|
|
1823
|
+
*/
|
|
1824
|
+
const $requirePlan = (feature) => {
|
|
1825
|
+
const { alepha } = $context();
|
|
1826
|
+
const subscriptionService = alepha.inject(SubscriptionService);
|
|
1827
|
+
return createMiddleware({
|
|
1828
|
+
name: "$requirePlan",
|
|
1829
|
+
options: { feature },
|
|
1830
|
+
handler: ({ next }) => {
|
|
1831
|
+
return async (...args) => {
|
|
1832
|
+
const user = args[0]?.user;
|
|
1833
|
+
if (!user?.organization) throw new ForbiddenError("Organization required");
|
|
1834
|
+
if (!await subscriptionService.can(user.organization, feature)) throw new ForbiddenError(`Feature '${feature}' not available on your plan`);
|
|
1835
|
+
return next(...args);
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
};
|
|
1840
|
+
//#endregion
|
|
1841
|
+
//#region ../../src/api/subscriptions/index.ts
|
|
1842
|
+
/**
|
|
1843
|
+
* Subscription management module — plan-based access control, billing integration,
|
|
1844
|
+
* usage limits, and lifecycle events (trial, renewal, cancellation, suspension).
|
|
1845
|
+
*
|
|
1846
|
+
* Depends on `AlephaPayments` for payment processing — register it in your app
|
|
1847
|
+
* alongside this module. Use `SubscriptionConfig` to declare your plans and limits.
|
|
1848
|
+
*
|
|
1849
|
+
* @module alepha.api.subscriptions
|
|
1850
|
+
*/
|
|
1851
|
+
const AlephaApiSubscriptions = $module({
|
|
1852
|
+
name: "alepha.api.subscriptions",
|
|
1853
|
+
services: [
|
|
1854
|
+
SubscriptionConfig,
|
|
1855
|
+
SubscriptionService,
|
|
1856
|
+
BillingService,
|
|
1857
|
+
UsageService,
|
|
1858
|
+
SubscriptionJobs,
|
|
1859
|
+
SubscriptionNotifications,
|
|
1860
|
+
SubscriptionController,
|
|
1861
|
+
AdminSubscriptionController
|
|
1862
|
+
],
|
|
1863
|
+
register: (alepha) => {
|
|
1864
|
+
alepha.with(SubscriptionConfig).with(SubscriptionService).with(BillingService).with(UsageService).with(SubscriptionJobs).with(SubscriptionNotifications).with(SubscriptionController).with(AdminSubscriptionController);
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
//#endregion
|
|
1868
|
+
export { $requireLimit, $requirePlan, AdminSubscriptionController, AlephaApiSubscriptions, BillingService, SubscriptionConfig, SubscriptionController, SubscriptionJobs, SubscriptionNotifications, SubscriptionService, UsageService, cancelSubscriptionSchema, changePlanSchema, createSubscriptionSchema, entitlementsSchema, mrrSchema, planDefinitionSchema, planResourceSchema, subscriptionEventResourceSchema, subscriptionEvents, subscriptionQuerySchema, subscriptionResourceSchema, subscriptionSettingsSchema, subscriptionStatsSchema, subscriptions };
|
|
1869
|
+
|
|
1870
|
+
//# sourceMappingURL=index.js.map
|