@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.
- package/LICENSE +621 -0
- package/README.md +22 -0
- package/package.json +205 -0
- package/src/lib/posthog.ts +5 -0
- package/src/lib/sentry.ts +8 -0
- package/src/modules/access/access.repository.ts +36 -0
- package/src/modules/access/access.service.ts +81 -0
- package/src/modules/access/access.test.ts +216 -0
- package/src/modules/access/access.utils.ts +46 -0
- package/src/modules/ai/ai.db.ts +38 -0
- package/src/modules/ai/ai.prompt.ts +47 -0
- package/src/modules/ai/ai.repository.ts +53 -0
- package/src/modules/ai/ai.router.ts +148 -0
- package/src/modules/ai/ai.service.ts +310 -0
- package/src/modules/ai/ai.trpc.ts +22 -0
- package/src/modules/ai/ideogram/ideogram.constants.ts +170 -0
- package/src/modules/ai/ideogram/ideogram.dto.ts +64 -0
- package/src/modules/ai/ideogram/ideogram.prompt.ts +858 -0
- package/src/modules/ai/ideogram/ideogram.repository.ts +39 -0
- package/src/modules/ai/ideogram/ideogram.service.ts +14 -0
- package/src/modules/auth/auth.db.ts +224 -0
- package/src/modules/auth/auth.dto.ts +47 -0
- package/src/modules/auth/auth.lib.ts +349 -0
- package/src/modules/auth/auth.middleware.ts +62 -0
- package/src/modules/auth/auth.repository.ts +672 -0
- package/src/modules/auth/auth.service.ts +261 -0
- package/src/modules/auth/auth.trpc.ts +208 -0
- package/src/modules/auth/auth.utils.ts +117 -0
- package/src/modules/base/base.abstract.ts +62 -0
- package/src/modules/base/base.dto.ts +206 -0
- package/src/modules/base/base.grants.test.ts +861 -0
- package/src/modules/base/base.grants.ts +199 -0
- package/src/modules/base/base.repository.ts +433 -0
- package/src/modules/base/base.service.ts +154 -0
- package/src/modules/base/base.types.ts +7 -0
- package/src/modules/billing/billing.db.ts +27 -0
- package/src/modules/billing/billing.repository.ts +328 -0
- package/src/modules/billing/billing.router.ts +77 -0
- package/src/modules/billing/billing.service.ts +177 -0
- package/src/modules/billing/billing.trpc.ts +17 -0
- package/src/modules/clay/clay.repository.ts +29 -0
- package/src/modules/clay/clay.service.ts +61 -0
- package/src/modules/connect/connect.db.ts +32 -0
- package/src/modules/connect/connect.dto.ts +44 -0
- package/src/modules/connect/connect.linkedin.ts +70 -0
- package/src/modules/connect/connect.oauth.ts +288 -0
- package/src/modules/connect/connect.repository.ts +65 -0
- package/src/modules/connect/connect.router.ts +76 -0
- package/src/modules/connect/connect.service.ts +171 -0
- package/src/modules/connect/connect.trpc.ts +26 -0
- package/src/modules/connect/connect.types.ts +27 -0
- package/src/modules/crypto/crypto.db.ts +15 -0
- package/src/modules/crypto/crypto.repository.ts +13 -0
- package/src/modules/crypto/crypto.service.ts +57 -0
- package/src/modules/email/email.service.ts +222 -0
- package/src/modules/file/file.repository.ts +95 -0
- package/src/modules/file/file.router.ts +108 -0
- package/src/modules/file/file.service.ts +186 -0
- package/src/modules/recurrence/recurrence.db.ts +79 -0
- package/src/modules/recurrence/recurrence.repository.ts +70 -0
- package/src/modules/recurrence/recurrence.service.ts +105 -0
- package/src/modules/recurrence/recurrence.trpc.ts +82 -0
- package/src/modules/social/social.dto.ts +22 -0
- package/src/modules/social/social.linkedin.test.ts +277 -0
- package/src/modules/social/social.linkedin.ts +593 -0
- package/src/modules/social/social.service.ts +112 -0
- package/src/modules/social/social.types.ts +43 -0
- package/src/modules/tag/tag.db.ts +41 -0
- package/src/modules/tag/tag.dto.ts +18 -0
- package/src/modules/tag/tag.repository.ts +222 -0
- package/src/modules/tag/tag.service.ts +48 -0
- package/src/modules/tag/tag.trpc.ts +62 -0
- package/src/modules/uploads/0581796b-8845-420d-bd95-cd7de79f6d37.webm +0 -0
- package/src/modules/uploads/33b1e649-6727-4bd0-94d0-a0b363646865.webm +0 -0
- package/src/modules/uploads/49a8c4c0-54d7-4c94-bef4-c93c029f9ed0.webm +0 -0
- package/src/modules/uploads/50e31e38-a2f0-47ca-8b7d-2d7fcad9267d.webm +0 -0
- package/src/modules/uploads/72ac8cf9-c3a7-4cd8-8a78-6d8e137a4c7e.webm +0 -0
- package/src/modules/uploads/75293649-d966-46cd-a675-67518958ae9c.png +0 -0
- package/src/modules/uploads/88b7b867-ce15-4891-bf73-81305a7de1f7.wav +0 -0
- package/src/modules/uploads/a5d6fee8-6a59-42c6-9d4a-ac8a3c5e7245.webm +0 -0
- package/src/modules/uploads/c13a9785-ca5a-4983-af30-b338ed76d370.webm +0 -0
- package/src/modules/uploads/caa1a5a7-71ba-4381-902d-7e2cafdf6dcb.webm +0 -0
- package/src/modules/uploads/cbeb0b81-374d-445b-914b-40ace7c8e031.webm +0 -0
- package/src/modules/uploads/d626aa82-b10f-493f-aee7-87bfb3361dfc.webm +0 -0
- package/src/modules/uploads/d7de4c16-de0c-495d-9612-e72260a6ecca.png +0 -0
- package/src/modules/uploads/e532e38a-6421-400e-8a5f-8e7bc8ce411b.wav +0 -0
- package/src/modules/uploads/e86ec867-6adf-4c51-84e0-00b0836625e8.webm +0 -0
- package/src/modules/utils/applyPagination.ts +13 -0
- package/src/modules/utils/applySorting.ts +21 -0
- package/src/modules/utils/getConditionsFromFilters.ts +216 -0
- package/src/modules/video/video.service.ts +89 -0
- package/src/modules/webhook/webhook.constants.ts +9 -0
- package/src/modules/webhook/webhook.db.ts +15 -0
- package/src/modules/webhook/webhook.dto.ts +9 -0
- package/src/modules/webhook/webhook.repository.ts +68 -0
- package/src/modules/webhook/webhook.router.ts +29 -0
- package/src/modules/webhook/webhook.service.ts +78 -0
- package/src/modules/workflow/workflow.db.ts +29 -0
- package/src/modules/workflow/workflow.repository.ts +171 -0
- package/src/modules/workflow/workflow.service.ts +56 -0
- package/src/modules/workflow/workflow.trpc.ts +26 -0
- package/src/modules/workflow/workflow.types.ts +30 -0
- package/src/modules/workflow/workflow.utils.ts +259 -0
- package/src/test/stubs/utils.ts +2 -0
- package/src/trpc/context.ts +21 -0
- package/src/trpc/index.ts +3 -0
- package/src/trpc/procedures.ts +43 -0
- package/src/trpc/utils.ts +20 -0
- package/src/types.ts +22 -0
- package/src/utils/errors.ts +148 -0
- package/src/utils/logger.ts +8 -0
- package/src/utils/posthog.ts +43 -0
- 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,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
|
+
}
|