@m5kdev/backend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +621 -0
  2. package/README.md +22 -0
  3. package/package.json +205 -0
  4. package/src/lib/posthog.ts +5 -0
  5. package/src/lib/sentry.ts +8 -0
  6. package/src/modules/access/access.repository.ts +36 -0
  7. package/src/modules/access/access.service.ts +81 -0
  8. package/src/modules/access/access.test.ts +216 -0
  9. package/src/modules/access/access.utils.ts +46 -0
  10. package/src/modules/ai/ai.db.ts +38 -0
  11. package/src/modules/ai/ai.prompt.ts +47 -0
  12. package/src/modules/ai/ai.repository.ts +53 -0
  13. package/src/modules/ai/ai.router.ts +148 -0
  14. package/src/modules/ai/ai.service.ts +310 -0
  15. package/src/modules/ai/ai.trpc.ts +22 -0
  16. package/src/modules/ai/ideogram/ideogram.constants.ts +170 -0
  17. package/src/modules/ai/ideogram/ideogram.dto.ts +64 -0
  18. package/src/modules/ai/ideogram/ideogram.prompt.ts +858 -0
  19. package/src/modules/ai/ideogram/ideogram.repository.ts +39 -0
  20. package/src/modules/ai/ideogram/ideogram.service.ts +14 -0
  21. package/src/modules/auth/auth.db.ts +224 -0
  22. package/src/modules/auth/auth.dto.ts +47 -0
  23. package/src/modules/auth/auth.lib.ts +349 -0
  24. package/src/modules/auth/auth.middleware.ts +62 -0
  25. package/src/modules/auth/auth.repository.ts +672 -0
  26. package/src/modules/auth/auth.service.ts +261 -0
  27. package/src/modules/auth/auth.trpc.ts +208 -0
  28. package/src/modules/auth/auth.utils.ts +117 -0
  29. package/src/modules/base/base.abstract.ts +62 -0
  30. package/src/modules/base/base.dto.ts +206 -0
  31. package/src/modules/base/base.grants.test.ts +861 -0
  32. package/src/modules/base/base.grants.ts +199 -0
  33. package/src/modules/base/base.repository.ts +433 -0
  34. package/src/modules/base/base.service.ts +154 -0
  35. package/src/modules/base/base.types.ts +7 -0
  36. package/src/modules/billing/billing.db.ts +27 -0
  37. package/src/modules/billing/billing.repository.ts +328 -0
  38. package/src/modules/billing/billing.router.ts +77 -0
  39. package/src/modules/billing/billing.service.ts +177 -0
  40. package/src/modules/billing/billing.trpc.ts +17 -0
  41. package/src/modules/clay/clay.repository.ts +29 -0
  42. package/src/modules/clay/clay.service.ts +61 -0
  43. package/src/modules/connect/connect.db.ts +32 -0
  44. package/src/modules/connect/connect.dto.ts +44 -0
  45. package/src/modules/connect/connect.linkedin.ts +70 -0
  46. package/src/modules/connect/connect.oauth.ts +288 -0
  47. package/src/modules/connect/connect.repository.ts +65 -0
  48. package/src/modules/connect/connect.router.ts +76 -0
  49. package/src/modules/connect/connect.service.ts +171 -0
  50. package/src/modules/connect/connect.trpc.ts +26 -0
  51. package/src/modules/connect/connect.types.ts +27 -0
  52. package/src/modules/crypto/crypto.db.ts +15 -0
  53. package/src/modules/crypto/crypto.repository.ts +13 -0
  54. package/src/modules/crypto/crypto.service.ts +57 -0
  55. package/src/modules/email/email.service.ts +222 -0
  56. package/src/modules/file/file.repository.ts +95 -0
  57. package/src/modules/file/file.router.ts +108 -0
  58. package/src/modules/file/file.service.ts +186 -0
  59. package/src/modules/recurrence/recurrence.db.ts +79 -0
  60. package/src/modules/recurrence/recurrence.repository.ts +70 -0
  61. package/src/modules/recurrence/recurrence.service.ts +105 -0
  62. package/src/modules/recurrence/recurrence.trpc.ts +82 -0
  63. package/src/modules/social/social.dto.ts +22 -0
  64. package/src/modules/social/social.linkedin.test.ts +277 -0
  65. package/src/modules/social/social.linkedin.ts +593 -0
  66. package/src/modules/social/social.service.ts +112 -0
  67. package/src/modules/social/social.types.ts +43 -0
  68. package/src/modules/tag/tag.db.ts +41 -0
  69. package/src/modules/tag/tag.dto.ts +18 -0
  70. package/src/modules/tag/tag.repository.ts +222 -0
  71. package/src/modules/tag/tag.service.ts +48 -0
  72. package/src/modules/tag/tag.trpc.ts +62 -0
  73. package/src/modules/uploads/0581796b-8845-420d-bd95-cd7de79f6d37.webm +0 -0
  74. package/src/modules/uploads/33b1e649-6727-4bd0-94d0-a0b363646865.webm +0 -0
  75. package/src/modules/uploads/49a8c4c0-54d7-4c94-bef4-c93c029f9ed0.webm +0 -0
  76. package/src/modules/uploads/50e31e38-a2f0-47ca-8b7d-2d7fcad9267d.webm +0 -0
  77. package/src/modules/uploads/72ac8cf9-c3a7-4cd8-8a78-6d8e137a4c7e.webm +0 -0
  78. package/src/modules/uploads/75293649-d966-46cd-a675-67518958ae9c.png +0 -0
  79. package/src/modules/uploads/88b7b867-ce15-4891-bf73-81305a7de1f7.wav +0 -0
  80. package/src/modules/uploads/a5d6fee8-6a59-42c6-9d4a-ac8a3c5e7245.webm +0 -0
  81. package/src/modules/uploads/c13a9785-ca5a-4983-af30-b338ed76d370.webm +0 -0
  82. package/src/modules/uploads/caa1a5a7-71ba-4381-902d-7e2cafdf6dcb.webm +0 -0
  83. package/src/modules/uploads/cbeb0b81-374d-445b-914b-40ace7c8e031.webm +0 -0
  84. package/src/modules/uploads/d626aa82-b10f-493f-aee7-87bfb3361dfc.webm +0 -0
  85. package/src/modules/uploads/d7de4c16-de0c-495d-9612-e72260a6ecca.png +0 -0
  86. package/src/modules/uploads/e532e38a-6421-400e-8a5f-8e7bc8ce411b.wav +0 -0
  87. package/src/modules/uploads/e86ec867-6adf-4c51-84e0-00b0836625e8.webm +0 -0
  88. package/src/modules/utils/applyPagination.ts +13 -0
  89. package/src/modules/utils/applySorting.ts +21 -0
  90. package/src/modules/utils/getConditionsFromFilters.ts +216 -0
  91. package/src/modules/video/video.service.ts +89 -0
  92. package/src/modules/webhook/webhook.constants.ts +9 -0
  93. package/src/modules/webhook/webhook.db.ts +15 -0
  94. package/src/modules/webhook/webhook.dto.ts +9 -0
  95. package/src/modules/webhook/webhook.repository.ts +68 -0
  96. package/src/modules/webhook/webhook.router.ts +29 -0
  97. package/src/modules/webhook/webhook.service.ts +78 -0
  98. package/src/modules/workflow/workflow.db.ts +29 -0
  99. package/src/modules/workflow/workflow.repository.ts +171 -0
  100. package/src/modules/workflow/workflow.service.ts +56 -0
  101. package/src/modules/workflow/workflow.trpc.ts +26 -0
  102. package/src/modules/workflow/workflow.types.ts +30 -0
  103. package/src/modules/workflow/workflow.utils.ts +259 -0
  104. package/src/test/stubs/utils.ts +2 -0
  105. package/src/trpc/context.ts +21 -0
  106. package/src/trpc/index.ts +3 -0
  107. package/src/trpc/procedures.ts +43 -0
  108. package/src/trpc/utils.ts +20 -0
  109. package/src/types.ts +22 -0
  110. package/src/utils/errors.ts +148 -0
  111. package/src/utils/logger.ts +8 -0
  112. package/src/utils/posthog.ts +43 -0
  113. package/src/utils/types.ts +5 -0
@@ -0,0 +1,154 @@
1
+ import type { QueryFilter, QueryInput } from "@m5kdev/commons/modules/schemas/query.schema";
2
+ import { err, ok } from "neverthrow";
3
+ import type { Session, User } from "#modules/auth/auth.lib";
4
+ import { Base } from "#modules/base/base.abstract";
5
+ import type { ServerResult, ServerResultAsync } from "#modules/base/base.dto";
6
+ import {
7
+ checkPermissionAsync,
8
+ checkPermissionSync,
9
+ type Entity,
10
+ type ResourceActionGrant,
11
+ type ResourceGrant,
12
+ } from "#modules/base/base.grants";
13
+ import type { BaseExternaRepository, BaseRepository } from "#modules/base/base.repository";
14
+ import type { Context } from "#trpc";
15
+ export class BaseService<
16
+ Repositories extends Record<string, BaseRepository<any, any, any> | BaseExternaRepository>,
17
+ Services extends Record<string, BaseService<any, any>>,
18
+ > extends Base {
19
+ constructor(
20
+ public repository: Repositories = {} as Repositories,
21
+ public service: Services = {} as Services
22
+ ) {
23
+ super("service");
24
+ this.repository = repository;
25
+ this.service = service;
26
+ }
27
+
28
+ addUserFilter(
29
+ value: string,
30
+ query?: QueryInput,
31
+ columnId = "userId",
32
+ method: QueryFilter["method"] = "equals"
33
+ ): QueryInput {
34
+ const userFilter: QueryFilter = {
35
+ columnId,
36
+ type: "string",
37
+ method,
38
+ value,
39
+ };
40
+ return query
41
+ ? { ...query, filters: [...(query?.filters ?? []), userFilter] }
42
+ : { filters: [userFilter] };
43
+ }
44
+
45
+ addContextFilter(
46
+ ctx: Awaited<ReturnType<Context>>,
47
+ include: { user?: boolean; organization?: boolean; team?: boolean } = {
48
+ user: true,
49
+ organization: false,
50
+ team: false,
51
+ },
52
+ query?: QueryInput,
53
+ map: Record<string, { columnId: string; method: QueryFilter["method"] }> = {
54
+ userId: {
55
+ columnId: "userId",
56
+ method: "equals",
57
+ },
58
+ organizationId: {
59
+ columnId: "organizationId",
60
+ method: "equals",
61
+ },
62
+ teamId: {
63
+ columnId: "teamId",
64
+ method: "equals",
65
+ },
66
+ }
67
+ ): QueryInput {
68
+ const filters: QueryFilter[] = [];
69
+ if (include.user) {
70
+ filters.push({
71
+ columnId: map.userId.columnId,
72
+ type: "string",
73
+ method: map.userId.method,
74
+ value: ctx.user.id,
75
+ });
76
+ }
77
+ if (include.organization) {
78
+ filters.push({
79
+ columnId: map.organizationId.columnId,
80
+ type: "string",
81
+ method: map.organizationId.method,
82
+ value: ctx.session.activeOrganizationId ?? "",
83
+ });
84
+ }
85
+ if (include.team) {
86
+ filters.push({
87
+ columnId: map.teamId.columnId,
88
+ type: "string",
89
+ method: map.teamId.method,
90
+ value: ctx.session.activeTeamId ?? "",
91
+ });
92
+ }
93
+ return query ? { ...query, filters: [...(query?.filters ?? []), ...filters] } : { filters };
94
+ }
95
+ }
96
+
97
+ export class BasePermissionService<
98
+ Repositories extends Record<string, BaseRepository<any, any, any> | BaseExternaRepository>,
99
+ Services extends Record<string, BaseService<any, any>>,
100
+ > extends BaseService<Repositories, Services> {
101
+ grants: ResourceGrant[];
102
+ constructor(repository: Repositories, service: Services, grants: ResourceGrant[] = []) {
103
+ super(repository, service);
104
+ this.grants = grants;
105
+ }
106
+
107
+ accessGuard<T extends Entity>(
108
+ ctx: { session: Session; user: User },
109
+ action: string,
110
+ entities?: T | T[],
111
+ grants?: ResourceActionGrant[]
112
+ ): ServerResult<true> {
113
+ const hasPermission = this.checkPermission(ctx, action, entities, grants);
114
+ if (!hasPermission) return this.error("FORBIDDEN");
115
+ return ok(true);
116
+ }
117
+
118
+ async accessGuardAsync<T extends Entity>(
119
+ ctx: { session: Session; user: User },
120
+ action: string,
121
+ getEntities: () => ServerResultAsync<T | T[] | undefined>,
122
+ grants?: ResourceActionGrant[]
123
+ ): ServerResultAsync<true> {
124
+ const hasPermission = await this.checkPermissionAsync(ctx, action, getEntities, grants);
125
+ if (hasPermission.isErr()) return err(hasPermission.error);
126
+ if (!hasPermission.value) return this.error("FORBIDDEN");
127
+ return ok(true);
128
+ }
129
+
130
+ checkPermission<T extends Entity>(
131
+ ctx: { session: Session; user: User },
132
+ action: string,
133
+ entities?: T | T[],
134
+ grants?: ResourceActionGrant[]
135
+ ): boolean {
136
+ const actionGrants = grants ?? this.grants.filter((grant) => grant.action === action);
137
+ return checkPermissionSync(ctx, actionGrants, entities);
138
+ }
139
+
140
+ async checkPermissionAsync<T extends Entity>(
141
+ ctx: { session: Session; user: User },
142
+ action: string,
143
+ getEntities: () => ServerResultAsync<T | T[] | undefined>,
144
+ grants?: ResourceActionGrant[]
145
+ ): ServerResultAsync<boolean> {
146
+ const actionGrants = grants ?? this.grants.filter((grant) => grant.action === action);
147
+ const permission = await checkPermissionAsync(ctx, actionGrants, getEntities);
148
+ if (permission.isErr())
149
+ return this.error("INTERNAL_SERVER_ERROR", "Failed to check permission", {
150
+ cause: permission.error,
151
+ });
152
+ return ok(permission.value);
153
+ }
154
+ }
@@ -0,0 +1,7 @@
1
+ export type ServerErrorLayer =
2
+ | "unknown"
3
+ | "repository"
4
+ | "service"
5
+ | "controller"
6
+ | "auth"
7
+ | "workflow";
@@ -0,0 +1,27 @@
1
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+ import { v4 as uuidv4 } from "uuid";
3
+
4
+ export const subscriptions = sqliteTable("subscriptions", {
5
+ id: text("id").primaryKey().$default(uuidv4),
6
+ createdAt: integer("created_at", { mode: "timestamp" })
7
+ .notNull()
8
+ .$default(() => new Date()),
9
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
10
+ plan: text("plan").notNull(),
11
+ referenceId: text("reference_id").notNull(),
12
+ stripeCustomerId: text("stripe_customer_id"),
13
+ stripeSubscriptionId: text("stripe_subscription_id"),
14
+ status: text("status").notNull(),
15
+ periodStart: integer("period_start", { mode: "timestamp" }),
16
+ periodEnd: integer("period_end", { mode: "timestamp" }),
17
+ priceId: text("price_id"),
18
+ interval: text("interval"),
19
+ unitAmount: integer("unit_amount", { mode: "number" }),
20
+ discounts: text("discounts", { mode: "json" }).$type<string[]>(),
21
+ cancelAtPeriodEnd: integer("cancel_at_period_end", { mode: "boolean" }),
22
+ cancelAt: integer("cancel_at", { mode: "timestamp" }),
23
+ canceledAt: integer("canceled_at", { mode: "timestamp" }),
24
+ seats: integer("seats", { mode: "number" }),
25
+ trialStart: integer("trial_start", { mode: "timestamp" }),
26
+ trialEnd: integer("trial_end", { mode: "timestamp" }),
27
+ });
@@ -0,0 +1,328 @@
1
+ import type { BillingSchema } from "@m5kdev/commons/modules/billing/billing.schema";
2
+ import type { StripePlan } from "@m5kdev/commons/modules/billing/billing.types";
3
+ import { and, desc, eq, type InferSelectModel, inArray } from "drizzle-orm";
4
+ import type { LibSQLDatabase } from "drizzle-orm/libsql";
5
+ import { err, ok } from "neverthrow";
6
+ import type { Stripe } from "stripe";
7
+ import * as auth from "#modules/auth/auth.db";
8
+ import type { ServerResult, ServerResultAsync } from "#modules/base/base.dto";
9
+ import { BaseTableRepository } from "#modules/base/base.repository";
10
+ import * as billing from "#modules/billing/billing.db";
11
+ import { posthogCapture } from "#utils/posthog";
12
+
13
+ const schema = { ...auth, ...billing };
14
+ type Schema = typeof schema;
15
+ type Orm = LibSQLDatabase<Schema>;
16
+
17
+ export class BillingRepository extends BaseTableRepository<
18
+ Orm,
19
+ Schema,
20
+ Record<string, never>,
21
+ Schema["subscriptions"]
22
+ > {
23
+ public stripe: Stripe;
24
+ public plans: StripePlan[];
25
+ public trial?: StripePlan;
26
+
27
+ constructor(options: {
28
+ orm: Orm;
29
+ schema: Schema;
30
+ table: Schema["subscriptions"];
31
+ libs: { stripe: Stripe };
32
+ config: {
33
+ trial?: StripePlan;
34
+ plans: StripePlan[];
35
+ };
36
+ }) {
37
+ const { libs, config, ...rest } = options;
38
+ super(rest);
39
+ this.stripe = libs.stripe;
40
+ this.plans = config.plans;
41
+ this.trial = config.trial;
42
+ }
43
+ hasTrial(): boolean {
44
+ return !!this.trial;
45
+ }
46
+
47
+ getPlanByPriceId(priceId: string): StripePlan | undefined {
48
+ return this.plans.find(
49
+ (plan) => plan.priceId === priceId || plan.annualDiscountPriceId === priceId
50
+ );
51
+ }
52
+
53
+ getCustomerByEmail(email: string): ServerResultAsync<Stripe.Customer | null> {
54
+ return this.throwableAsync(async () => {
55
+ const customers = await this.stripe.customers.list({
56
+ email,
57
+ limit: 1,
58
+ });
59
+ return ok(customers.data[0] ?? null);
60
+ });
61
+ }
62
+
63
+ getUserByCustomerId(
64
+ customerId: string
65
+ ): ServerResultAsync<InferSelectModel<Schema["users"]> | null> {
66
+ return this.throwableAsync(async () => {
67
+ const [user] = await this.orm
68
+ .select()
69
+ .from(this.schema.users)
70
+ .where(eq(this.schema.users.stripeCustomerId, customerId))
71
+ .limit(1);
72
+ return ok(user ?? null);
73
+ });
74
+ }
75
+
76
+ createCustomer({
77
+ email,
78
+ name,
79
+ userId,
80
+ }: {
81
+ email: string;
82
+ name?: string;
83
+ userId: string;
84
+ }): ServerResultAsync<Stripe.Customer> {
85
+ return this.throwableAsync(async () => {
86
+ const customer = await this.stripe.customers.create({
87
+ email,
88
+ name,
89
+ metadata: {
90
+ userId,
91
+ },
92
+ });
93
+ return ok(customer);
94
+ });
95
+ }
96
+
97
+ async createTrialSubscription(customerId: string): ServerResultAsync<Stripe.Subscription> {
98
+ if (!this.trial) return this.error("NOT_FOUND", "Trial plan not found");
99
+ const stripeSubscription = await this.createSubscription({
100
+ customerId,
101
+ priceId: this.trial.priceId,
102
+ trialDays: this.trial.freeTrial?.days ?? 7,
103
+ });
104
+ if (stripeSubscription.isErr()) return err(stripeSubscription.error);
105
+ if (!stripeSubscription.value)
106
+ return this.error("INTERNAL_SERVER_ERROR", "Failed to create trial subscription");
107
+ return ok(stripeSubscription.value);
108
+ }
109
+
110
+ createSubscription({
111
+ customerId,
112
+ priceId,
113
+ quantity = 1,
114
+ trialDays,
115
+ }: {
116
+ customerId: string;
117
+ priceId: string;
118
+ quantity?: number;
119
+ trialDays?: number;
120
+ }): ServerResultAsync<Stripe.Subscription> {
121
+ return this.throwableAsync(async () => {
122
+ const stripeSubscription = await this.stripe.subscriptions.create({
123
+ customer: customerId,
124
+ items: [{ price: priceId, quantity }], // quantity = seats if you want
125
+ ...(trialDays
126
+ ? {
127
+ trial_period_days: trialDays,
128
+ trial_settings: {
129
+ end_behavior: {
130
+ missing_payment_method: "cancel",
131
+ },
132
+ },
133
+ }
134
+ : {}),
135
+ });
136
+ return ok(stripeSubscription);
137
+ });
138
+ }
139
+
140
+ updateUserCustomerId({
141
+ userId,
142
+ customerId,
143
+ }: {
144
+ userId: string;
145
+ customerId: string;
146
+ }): ServerResultAsync<InferSelectModel<Schema["users"]>> {
147
+ return this.throwableAsync(async () => {
148
+ const [user] = await this.orm
149
+ .update(this.schema.users)
150
+ .set({ stripeCustomerId: customerId })
151
+ .where(eq(this.schema.users.id, userId))
152
+ .returning();
153
+ if (!user) return this.error("NOT_FOUND", "User not found");
154
+ return ok(user);
155
+ });
156
+ }
157
+
158
+ getLatestSubscription(referenceId: string): ServerResultAsync<BillingSchema | null> {
159
+ return this.throwableAsync(async () => {
160
+ const subscriptions = await this.orm
161
+ .select()
162
+ .from(this.schema.subscriptions)
163
+ .where(eq(this.schema.subscriptions.referenceId, referenceId))
164
+ .orderBy(desc(this.schema.subscriptions.createdAt))
165
+ .limit(1);
166
+
167
+ return ok(subscriptions[0] ?? null);
168
+ });
169
+ }
170
+
171
+ getActiveSubscription(referenceId: string): ServerResultAsync<BillingSchema | null> {
172
+ return this.throwableAsync(async () => {
173
+ const [subscription] = await this.orm
174
+ .select()
175
+ .from(this.schema.subscriptions)
176
+ .where(
177
+ and(
178
+ eq(this.schema.subscriptions.referenceId, referenceId),
179
+ inArray(this.schema.subscriptions.status, ["active", "trialing"])
180
+ )
181
+ )
182
+ .orderBy(desc(this.schema.subscriptions.createdAt))
183
+ .limit(1);
184
+
185
+ return ok(subscription ?? null);
186
+ });
187
+ }
188
+
189
+ listInvoices(customerId: string): ServerResultAsync<Stripe.Invoice[]> {
190
+ return this.throwableAsync(async () => {
191
+ const invoices = await this.stripe.invoices.list({
192
+ customer: customerId,
193
+ });
194
+ return ok(invoices.data);
195
+ });
196
+ }
197
+
198
+ createCheckoutSession({
199
+ customerId,
200
+ priceId,
201
+ userId,
202
+ }: {
203
+ customerId: string;
204
+ priceId: string;
205
+ userId: string;
206
+ }): ServerResultAsync<Stripe.Checkout.Session> {
207
+ return this.throwableAsync(async () => {
208
+ const session = await this.stripe.checkout.sessions.create({
209
+ client_reference_id: userId,
210
+ customer: customerId,
211
+ success_url: `${process.env.VITE_SERVER_URL}/stripe/success`,
212
+ cancel_url: `${process.env.VITE_APP_URL}/billing`,
213
+ mode: "subscription",
214
+ line_items: [
215
+ {
216
+ price: priceId,
217
+ quantity: 1,
218
+ },
219
+ ],
220
+ });
221
+ return ok(session);
222
+ });
223
+ }
224
+
225
+ createBillingPortalSession(customerId: string): ServerResultAsync<Stripe.BillingPortal.Session> {
226
+ return this.throwableAsync(async () => {
227
+ const session = await this.stripe.billingPortal.sessions.create({
228
+ customer: customerId,
229
+ return_url: `${process.env.VITE_SERVER_URL}/stripe/success`,
230
+ });
231
+ return ok(session);
232
+ });
233
+ }
234
+
235
+ async syncStripeData({
236
+ customerId,
237
+ userId,
238
+ }: {
239
+ customerId: string;
240
+ userId: string;
241
+ }): ServerResultAsync<boolean> {
242
+ return this.throwableAsync(async () => {
243
+ // Fetch latest subscription data from Stripe
244
+
245
+ const stripeSubscriptions = await this.stripe.subscriptions.list({
246
+ customer: customerId,
247
+ limit: 1,
248
+ status: "all",
249
+ expand: ["data.default_payment_method"],
250
+ });
251
+ const [stripeSubscription] = stripeSubscriptions.data;
252
+ if (!stripeSubscription) return this.error("NOT_FOUND", "Subscription not found");
253
+
254
+ const plan = this.getPlanByPriceId(stripeSubscription.items.data[0]?.price.id!);
255
+ if (!plan)
256
+ return this.error(
257
+ "NOT_FOUND",
258
+ `Plan not found for price ID: ${stripeSubscription.items.data[0]?.price.id}`
259
+ );
260
+
261
+ const values = {
262
+ stripeCustomerId: customerId,
263
+ referenceId: userId,
264
+ plan: plan.name,
265
+ status: stripeSubscription.status,
266
+ seats: stripeSubscription.items.data[0]?.quantity || 1,
267
+ periodEnd: new Date(stripeSubscription.items.data[0]?.current_period_end! * 1000),
268
+ periodStart: new Date(stripeSubscription.items.data[0]?.current_period_start! * 1000),
269
+ priceId: stripeSubscription.items.data[0]?.price.id!,
270
+ interval: stripeSubscription.items.data[0]?.price.recurring?.interval,
271
+ unitAmount: stripeSubscription.items.data[0]?.price.unit_amount,
272
+ discounts: stripeSubscription.discounts.map((discount) =>
273
+ typeof discount === "string" ? discount : discount.id
274
+ ),
275
+ stripeSubscriptionId: stripeSubscription.id,
276
+ cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
277
+ cancelAt: stripeSubscription.cancel_at
278
+ ? new Date(stripeSubscription.cancel_at * 1000)
279
+ : null,
280
+ canceledAt: stripeSubscription.canceled_at
281
+ ? new Date(stripeSubscription.canceled_at * 1000)
282
+ : null,
283
+ ...(stripeSubscription.trial_start && stripeSubscription.trial_end
284
+ ? {
285
+ trialStart: new Date(stripeSubscription.trial_start * 1000),
286
+ trialEnd: new Date(stripeSubscription.trial_end * 1000),
287
+ }
288
+ : {}),
289
+ };
290
+
291
+ const existingSubscription = await this.getActiveSubscription(userId);
292
+ if (existingSubscription.isErr()) return err(existingSubscription.error);
293
+
294
+ if (!existingSubscription.value) {
295
+ await this.orm.insert(this.schema.subscriptions).values(values);
296
+ posthogCapture({
297
+ distinctId: userId,
298
+ event: "stripe.subscription_created",
299
+ properties: values,
300
+ });
301
+ return ok(true);
302
+ }
303
+
304
+ await this.orm
305
+ .update(this.schema.subscriptions)
306
+ .set({ ...values, updatedAt: new Date() })
307
+ .where(eq(this.schema.subscriptions.id, existingSubscription.value.id));
308
+ posthogCapture({
309
+ distinctId: userId,
310
+ event: "stripe.subscription_updated",
311
+ properties: values,
312
+ });
313
+
314
+ return ok(false);
315
+ });
316
+ }
317
+
318
+ constructEvent(
319
+ body: Buffer | string,
320
+ signature: string,
321
+ secret: string
322
+ ): ServerResult<Stripe.Event> {
323
+ return this.throwable(() => {
324
+ const event = this.stripe.webhooks.constructEvent(body, signature, secret);
325
+ return ok(event);
326
+ });
327
+ }
328
+ }
@@ -0,0 +1,77 @@
1
+ import bodyParser from "body-parser";
2
+ import { Router } from "express";
3
+ import type { AuthMiddleware, AuthRequest } from "#modules/auth/auth.middleware";
4
+ import type { BillingService } from "#modules/billing/billing.service";
5
+
6
+ export function createBillingRouter(
7
+ authMiddleware: AuthMiddleware,
8
+ service: BillingService
9
+ ): Router {
10
+ const billingRouter = Router();
11
+
12
+ billingRouter.get("/checkout/:priceId", authMiddleware, async (req: AuthRequest, res) => {
13
+ const user = req.user!;
14
+
15
+ const session = await service.createCheckoutSession({ priceId: req.params.priceId }, { user });
16
+ if (session.isErr()) {
17
+ return res.status(500).json({ message: session.error.message });
18
+ }
19
+
20
+ if (!session.value.url) {
21
+ return res.status(500).json({ message: "Failed to create checkout session" });
22
+ }
23
+
24
+ return res.redirect(session.value.url);
25
+ });
26
+
27
+ billingRouter.get("/portal", authMiddleware, async (req: AuthRequest, res) => {
28
+ const user = req.user!;
29
+
30
+ const session = await service.createBillingPortalSession({ user });
31
+
32
+ if (session.isErr()) {
33
+ return res.status(500).json({ message: session.error.message });
34
+ }
35
+
36
+ return res.redirect(session.value.url);
37
+ });
38
+
39
+ billingRouter.get("/success", authMiddleware, async (req: AuthRequest, res) => {
40
+ const user = req.user!;
41
+
42
+ if (!user.stripeCustomerId) {
43
+ return res.redirect(`${process.env.VITE_APP_URL}/billing`);
44
+ }
45
+
46
+ const result = await service.syncStripeData(user.stripeCustomerId);
47
+
48
+ if (result.isErr()) {
49
+ return res.redirect(`${process.env.VITE_APP_URL}/billing?error=SYNC_FAILED`);
50
+ }
51
+
52
+ return res.redirect(`${process.env.VITE_APP_URL}/billing`);
53
+ });
54
+
55
+ billingRouter.post("/webhook", bodyParser.raw({ type: "application/json" }), async (req, res) => {
56
+ const signature = req.headers["stripe-signature"];
57
+
58
+ if (!signature) return res.status(400).json({ message: "No signature" });
59
+
60
+ if (typeof signature !== "string")
61
+ return res.status(500).json({ message: "Signature is not a string" });
62
+
63
+ const event = service.constructEvent(req.body, signature);
64
+ if (event.isErr()) {
65
+ return res.status(500).json({ message: event.error.message });
66
+ }
67
+
68
+ const result = await service.processEvent(event.value);
69
+ if (result.isErr()) {
70
+ return res.status(500).json({ message: result.error.message });
71
+ }
72
+
73
+ return res.status(200).json({ received: true });
74
+ });
75
+
76
+ return billingRouter;
77
+ }