paykitjs 0.1.0-alpha.2
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 +21 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/api/define-route.d.ts +94 -0
- package/dist/api/define-route.js +153 -0
- package/dist/api/methods.d.ts +422 -0
- package/dist/api/methods.js +67 -0
- package/dist/cli/commands/check.js +92 -0
- package/dist/cli/commands/init.js +264 -0
- package/dist/cli/commands/push.js +73 -0
- package/dist/cli/commands/telemetry.js +16 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +21 -0
- package/dist/cli/templates/index.js +64 -0
- package/dist/cli/utils/detect.js +67 -0
- package/dist/cli/utils/format.js +58 -0
- package/dist/cli/utils/get-config.js +117 -0
- package/dist/cli/utils/telemetry.js +103 -0
- package/dist/client/index.d.ts +25 -0
- package/dist/client/index.js +27 -0
- package/dist/core/context.d.ts +17 -0
- package/dist/core/context.js +23 -0
- package/dist/core/create-paykit.d.ts +7 -0
- package/dist/core/create-paykit.js +52 -0
- package/dist/core/error-codes.d.ts +12 -0
- package/dist/core/error-codes.js +10 -0
- package/dist/core/errors.d.ts +41 -0
- package/dist/core/errors.js +47 -0
- package/dist/core/logger.d.ts +11 -0
- package/dist/core/logger.js +51 -0
- package/dist/core/utils.js +21 -0
- package/dist/customer/customer.api.js +47 -0
- package/dist/customer/customer.service.js +342 -0
- package/dist/customer/customer.types.d.ts +31 -0
- package/dist/database/index.d.ts +8 -0
- package/dist/database/index.js +32 -0
- package/dist/database/migrations/0000_init.sql +157 -0
- package/dist/database/migrations/meta/0000_snapshot.json +1222 -0
- package/dist/database/migrations/meta/_journal.json +13 -0
- package/dist/database/schema.d.ts +1767 -0
- package/dist/database/schema.js +150 -0
- package/dist/entitlement/entitlement.api.js +33 -0
- package/dist/entitlement/entitlement.service.d.ts +17 -0
- package/dist/entitlement/entitlement.service.js +123 -0
- package/dist/handlers/next.d.ts +9 -0
- package/dist/handlers/next.js +9 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +6 -0
- package/dist/invoice/invoice.service.js +54 -0
- package/dist/payment/payment.service.js +49 -0
- package/dist/payment-method/payment-method.service.js +78 -0
- package/dist/product/product-sync.service.js +111 -0
- package/dist/product/product.service.js +127 -0
- package/dist/providers/provider.d.ts +159 -0
- package/dist/providers/stripe.js +547 -0
- package/dist/subscription/subscription.api.js +24 -0
- package/dist/subscription/subscription.service.js +896 -0
- package/dist/subscription/subscription.types.d.ts +18 -0
- package/dist/subscription/subscription.types.js +11 -0
- package/dist/testing/testing.api.js +29 -0
- package/dist/testing/testing.service.js +49 -0
- package/dist/types/events.d.ts +181 -0
- package/dist/types/instance.d.ts +88 -0
- package/dist/types/models.d.ts +11 -0
- package/dist/types/options.d.ts +32 -0
- package/dist/types/plugin.d.ts +11 -0
- package/dist/types/schema.d.ts +99 -0
- package/dist/types/schema.js +192 -0
- package/dist/webhook/webhook.api.js +29 -0
- package/dist/webhook/webhook.service.js +143 -0
- package/package.json +72 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { PAYKIT_ERROR_CODES, PayKitError } from "../core/errors.js";
|
|
2
|
+
import { getCustomerWithDetails, getProviderCustomerIdForCustomer, hardDeleteCustomer, listCustomers, syncCustomerWithDefaults } from "./customer.service.js";
|
|
3
|
+
import { definePayKitMethod, returnUrl } from "../api/define-route.js";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
//#region src/customer/customer.api.ts
|
|
6
|
+
const upsertCustomerSchema = z.object({
|
|
7
|
+
id: z.string(),
|
|
8
|
+
email: z.string().optional(),
|
|
9
|
+
name: z.string().optional(),
|
|
10
|
+
metadata: z.record(z.string(), z.string()).optional()
|
|
11
|
+
});
|
|
12
|
+
const customerIdSchema = z.object({ id: z.string() });
|
|
13
|
+
const listCustomersSchema = z.object({
|
|
14
|
+
limit: z.number().int().positive().optional(),
|
|
15
|
+
offset: z.number().int().min(0).optional(),
|
|
16
|
+
planIds: z.array(z.string()).optional()
|
|
17
|
+
}).optional();
|
|
18
|
+
const upsertCustomer = definePayKitMethod({ input: upsertCustomerSchema }, async (ctx) => syncCustomerWithDefaults(ctx.paykit, ctx.input));
|
|
19
|
+
const getCustomer = definePayKitMethod({ input: customerIdSchema }, async (ctx) => getCustomerWithDetails(ctx.paykit, ctx.input.id));
|
|
20
|
+
const deleteCustomer = definePayKitMethod({ input: customerIdSchema }, async (ctx) => {
|
|
21
|
+
await hardDeleteCustomer(ctx.paykit, ctx.input.id);
|
|
22
|
+
return { success: true };
|
|
23
|
+
});
|
|
24
|
+
const listCustomersMethod = definePayKitMethod({ input: listCustomersSchema }, async (ctx) => listCustomers(ctx.paykit, ctx.input));
|
|
25
|
+
/** Opens the provider customer portal for the resolved customer. */
|
|
26
|
+
const customerPortal = definePayKitMethod({
|
|
27
|
+
input: z.object({ returnUrl: returnUrl() }),
|
|
28
|
+
requireCustomer: true,
|
|
29
|
+
route: {
|
|
30
|
+
client: true,
|
|
31
|
+
method: "POST",
|
|
32
|
+
path: "/customer-portal"
|
|
33
|
+
}
|
|
34
|
+
}, async (ctx) => {
|
|
35
|
+
const providerCustomerId = await getProviderCustomerIdForCustomer(ctx.paykit.database, {
|
|
36
|
+
customerId: ctx.customer.id,
|
|
37
|
+
providerId: ctx.paykit.provider.id
|
|
38
|
+
});
|
|
39
|
+
if (!providerCustomerId) throw PayKitError.from("NOT_FOUND", PAYKIT_ERROR_CODES.PROVIDER_CUSTOMER_NOT_FOUND);
|
|
40
|
+
const { url } = await ctx.paykit.stripe.createPortalSession({
|
|
41
|
+
providerCustomerId,
|
|
42
|
+
returnUrl: ctx.input.returnUrl
|
|
43
|
+
});
|
|
44
|
+
return { url };
|
|
45
|
+
});
|
|
46
|
+
//#endregion
|
|
47
|
+
export { customerPortal, deleteCustomer, getCustomer, listCustomersMethod, upsertCustomer };
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { PAYKIT_ERROR_CODES, PayKitError } from "../core/errors.js";
|
|
2
|
+
import { customer, entitlement, invoice, paymentMethod, product, subscription } from "../database/schema.js";
|
|
3
|
+
import { getLatestProduct } from "../product/product.service.js";
|
|
4
|
+
import { getActiveSubscriptionInGroup, getCurrentSubscriptions, getScheduledSubscriptionsInGroup, insertSubscriptionRecord } from "../subscription/subscription.service.js";
|
|
5
|
+
import { and, count, countDistinct, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
|
6
|
+
//#region src/customer/customer.service.ts
|
|
7
|
+
async function syncCustomer(database, input) {
|
|
8
|
+
const now = /* @__PURE__ */ new Date();
|
|
9
|
+
const existing = await database.query.customer.findFirst({ where: eq(customer.id, input.id) });
|
|
10
|
+
if (existing) {
|
|
11
|
+
const row = (await database.update(customer).set({
|
|
12
|
+
email: input.email ?? existing.email ?? null,
|
|
13
|
+
name: input.name ?? existing.name ?? null,
|
|
14
|
+
metadata: input.metadata ?? existing.metadata ?? null,
|
|
15
|
+
deletedAt: null,
|
|
16
|
+
updatedAt: now
|
|
17
|
+
}).where(eq(customer.id, existing.id)).returning())[0];
|
|
18
|
+
if (!row) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.CUSTOMER_UPDATE_FAILED);
|
|
19
|
+
return row;
|
|
20
|
+
}
|
|
21
|
+
const row = (await database.insert(customer).values({
|
|
22
|
+
id: input.id,
|
|
23
|
+
email: input.email ?? null,
|
|
24
|
+
name: input.name ?? null,
|
|
25
|
+
metadata: input.metadata ?? null,
|
|
26
|
+
deletedAt: null
|
|
27
|
+
}).returning())[0];
|
|
28
|
+
if (!row) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.CUSTOMER_CREATE_FAILED);
|
|
29
|
+
return row;
|
|
30
|
+
}
|
|
31
|
+
async function ensureDefaultPlansForCustomer(ctx, customerId) {
|
|
32
|
+
const defaultPlans = ctx.plans.plans.filter((plan) => plan.isDefault);
|
|
33
|
+
if (defaultPlans.length === 0) return;
|
|
34
|
+
for (const defaultPlan of defaultPlans) {
|
|
35
|
+
if (!defaultPlan.group) continue;
|
|
36
|
+
if (await getActiveSubscriptionInGroup(ctx.database, {
|
|
37
|
+
customerId,
|
|
38
|
+
group: defaultPlan.group
|
|
39
|
+
})) continue;
|
|
40
|
+
if ((await getScheduledSubscriptionsInGroup(ctx.database, {
|
|
41
|
+
customerId,
|
|
42
|
+
group: defaultPlan.group
|
|
43
|
+
})).length > 0) continue;
|
|
44
|
+
const storedPlan = await getLatestProduct(ctx.database, defaultPlan.id);
|
|
45
|
+
if (!storedPlan) continue;
|
|
46
|
+
if (storedPlan.priceAmount !== null) {
|
|
47
|
+
ctx.logger.warn({
|
|
48
|
+
planId: defaultPlan.id,
|
|
49
|
+
customerId
|
|
50
|
+
}, "skipping default plan: paid default plans are not auto-attached yet");
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
await insertSubscriptionRecord(ctx.database, {
|
|
54
|
+
customerId,
|
|
55
|
+
planFeatures: defaultPlan.includes,
|
|
56
|
+
productInternalId: storedPlan.internalId,
|
|
57
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
58
|
+
status: "active"
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function syncCustomerWithDefaults(ctx, input) {
|
|
63
|
+
const syncedCustomer = await syncCustomer(ctx.database, input);
|
|
64
|
+
await ensureDefaultPlansForCustomer(ctx, syncedCustomer.id);
|
|
65
|
+
const providerCustomer = await ensureTestingProviderCustomer(ctx, syncedCustomer.id);
|
|
66
|
+
if (!providerCustomer) return syncedCustomer;
|
|
67
|
+
return {
|
|
68
|
+
...syncedCustomer,
|
|
69
|
+
provider: {
|
|
70
|
+
...syncedCustomer.provider ?? {},
|
|
71
|
+
[ctx.provider.id]: providerCustomer
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async function applyCustomerWebhookAction(database, action) {
|
|
76
|
+
if (action.type === "customer.upsert") {
|
|
77
|
+
await syncCustomer(database, action.data);
|
|
78
|
+
return action.data.id;
|
|
79
|
+
}
|
|
80
|
+
await deleteCustomerFromDatabase(database, action.data.id);
|
|
81
|
+
return action.data.id;
|
|
82
|
+
}
|
|
83
|
+
async function getCustomerById(database, customerId) {
|
|
84
|
+
return await database.query.customer.findFirst({ where: and(eq(customer.id, customerId), isNull(customer.deletedAt)) }) ?? null;
|
|
85
|
+
}
|
|
86
|
+
async function getCustomerByIdOrThrow(database, customerId) {
|
|
87
|
+
const existingCustomer = await getCustomerById(database, customerId);
|
|
88
|
+
if (!existingCustomer) throw PayKitError.from("NOT_FOUND", PAYKIT_ERROR_CODES.CUSTOMER_NOT_FOUND);
|
|
89
|
+
return existingCustomer;
|
|
90
|
+
}
|
|
91
|
+
async function getCustomerWithDetails(ctx, customerId) {
|
|
92
|
+
const customerRow = await getCustomerById(ctx.database, customerId);
|
|
93
|
+
if (!customerRow) return null;
|
|
94
|
+
const subRows = await ctx.database.select({
|
|
95
|
+
planGroup: product.group,
|
|
96
|
+
planId: product.id,
|
|
97
|
+
status: subscription.status,
|
|
98
|
+
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
99
|
+
currentPeriodStart: subscription.currentPeriodStartAt,
|
|
100
|
+
currentPeriodEnd: subscription.currentPeriodEndAt
|
|
101
|
+
}).from(subscription).innerJoin(product, eq(product.internalId, subscription.productInternalId)).where(and(eq(subscription.customerId, customerId), inArray(subscription.status, [
|
|
102
|
+
"active",
|
|
103
|
+
"trialing",
|
|
104
|
+
"past_due",
|
|
105
|
+
"scheduled"
|
|
106
|
+
]), or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`))).orderBy(desc(subscription.createdAt));
|
|
107
|
+
const subscriptionsByGroup = /* @__PURE__ */ new Map();
|
|
108
|
+
for (const row of subRows) {
|
|
109
|
+
const currentGroup = subscriptionsByGroup.get(row.planGroup) ?? [];
|
|
110
|
+
currentGroup.push(row.planId);
|
|
111
|
+
subscriptionsByGroup.set(row.planGroup, currentGroup);
|
|
112
|
+
}
|
|
113
|
+
for (const [group, planIds] of subscriptionsByGroup) {
|
|
114
|
+
if (planIds.length < 2) continue;
|
|
115
|
+
ctx.logger.warn({
|
|
116
|
+
customerId,
|
|
117
|
+
group,
|
|
118
|
+
planIds
|
|
119
|
+
}, "multiple active subscriptions detected while reading customer details");
|
|
120
|
+
}
|
|
121
|
+
const entRows = await ctx.database.select({
|
|
122
|
+
featureId: entitlement.featureId,
|
|
123
|
+
balance: entitlement.balance,
|
|
124
|
+
limit: entitlement.limit,
|
|
125
|
+
nextResetAt: entitlement.nextResetAt
|
|
126
|
+
}).from(entitlement).innerJoin(subscription, eq(subscription.id, entitlement.subscriptionId)).where(and(eq(entitlement.customerId, customerId), inArray(subscription.status, [
|
|
127
|
+
"active",
|
|
128
|
+
"trialing",
|
|
129
|
+
"past_due"
|
|
130
|
+
]), or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`)));
|
|
131
|
+
const entitlements = {};
|
|
132
|
+
for (const row of entRows) {
|
|
133
|
+
const isUnlimited = row.limit === null;
|
|
134
|
+
entitlements[row.featureId] = {
|
|
135
|
+
featureId: row.featureId,
|
|
136
|
+
balance: row.balance ?? 0,
|
|
137
|
+
limit: row.limit ?? 0,
|
|
138
|
+
usage: isUnlimited ? 0 : (row.limit ?? 0) - (row.balance ?? 0),
|
|
139
|
+
unlimited: isUnlimited,
|
|
140
|
+
nextResetAt: row.nextResetAt
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
...customerRow,
|
|
145
|
+
subscriptions: subRows.map((row) => ({
|
|
146
|
+
planId: row.planId,
|
|
147
|
+
status: row.status,
|
|
148
|
+
cancelAtPeriodEnd: row.cancelAtPeriodEnd,
|
|
149
|
+
currentPeriodStart: row.currentPeriodStart,
|
|
150
|
+
currentPeriodEnd: row.currentPeriodEnd
|
|
151
|
+
})),
|
|
152
|
+
entitlements
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function getProviderCustomer(customerRow, providerId) {
|
|
156
|
+
return (customerRow.provider ?? {})[providerId] ?? null;
|
|
157
|
+
}
|
|
158
|
+
async function setProviderCustomer(database, input) {
|
|
159
|
+
const providerMap = (await getCustomerByIdOrThrow(database, input.customerId)).provider ?? {};
|
|
160
|
+
providerMap[input.providerId] = input.providerCustomer;
|
|
161
|
+
await database.update(customer).set({
|
|
162
|
+
provider: providerMap,
|
|
163
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
164
|
+
}).where(eq(customer.id, input.customerId));
|
|
165
|
+
}
|
|
166
|
+
function getProviderCustomerId(customerRow, providerId) {
|
|
167
|
+
return getProviderCustomer(customerRow, providerId)?.id ?? null;
|
|
168
|
+
}
|
|
169
|
+
async function getProviderCustomerIdForCustomer(database, input) {
|
|
170
|
+
const row = await database.query.customer.findFirst({ where: eq(customer.id, input.customerId) });
|
|
171
|
+
if (!row) return null;
|
|
172
|
+
return getProviderCustomerId(row, input.providerId);
|
|
173
|
+
}
|
|
174
|
+
async function findCustomerByProviderCustomerId(database, input) {
|
|
175
|
+
return await database.query.customer.findFirst({ where: sql`${customer.provider}->${input.providerId}->>'id' = ${input.providerCustomerId}` }) ?? null;
|
|
176
|
+
}
|
|
177
|
+
async function upsertProviderCustomer(ctx, input) {
|
|
178
|
+
const providerId = ctx.provider.id;
|
|
179
|
+
const existingCustomer = await getCustomerByIdOrThrow(ctx.database, input.customerId);
|
|
180
|
+
const existingProviderCustomer = getProviderCustomer(existingCustomer, providerId);
|
|
181
|
+
const existingProviderCustomerId = existingProviderCustomer?.id ?? null;
|
|
182
|
+
if (existingProviderCustomerId) return {
|
|
183
|
+
customerId: input.customerId,
|
|
184
|
+
providerCustomer: existingProviderCustomer,
|
|
185
|
+
providerCustomerId: existingProviderCustomerId
|
|
186
|
+
};
|
|
187
|
+
const { providerCustomer } = await ctx.stripe.upsertCustomer({
|
|
188
|
+
createTestClock: ctx.options.testing?.enabled === true,
|
|
189
|
+
id: existingCustomer.id,
|
|
190
|
+
email: existingCustomer.email ?? void 0,
|
|
191
|
+
name: existingCustomer.name ?? void 0,
|
|
192
|
+
metadata: existingCustomer.metadata ?? void 0
|
|
193
|
+
});
|
|
194
|
+
const providerCustomerId = providerCustomer.id;
|
|
195
|
+
await setProviderCustomer(ctx.database, {
|
|
196
|
+
customerId: input.customerId,
|
|
197
|
+
providerCustomer,
|
|
198
|
+
providerId
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
customerId: input.customerId,
|
|
202
|
+
providerCustomer,
|
|
203
|
+
providerCustomerId
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function ensureTestingProviderCustomer(ctx, customerId) {
|
|
207
|
+
if (ctx.options.testing?.enabled !== true) return null;
|
|
208
|
+
const { providerCustomer } = await upsertProviderCustomer(ctx, { customerId });
|
|
209
|
+
return providerCustomer;
|
|
210
|
+
}
|
|
211
|
+
async function deleteCustomerFromDatabase(database, customerId) {
|
|
212
|
+
const sIds = (await database.select({ id: subscription.id }).from(subscription).where(eq(subscription.customerId, customerId))).map((row) => row.id);
|
|
213
|
+
if (sIds.length > 0) await database.delete(entitlement).where(inArray(entitlement.subscriptionId, sIds));
|
|
214
|
+
await database.delete(subscription).where(eq(subscription.customerId, customerId));
|
|
215
|
+
await database.delete(invoice).where(eq(invoice.customerId, customerId));
|
|
216
|
+
await database.delete(paymentMethod).where(eq(paymentMethod.customerId, customerId));
|
|
217
|
+
await database.delete(customer).where(eq(customer.id, customerId));
|
|
218
|
+
}
|
|
219
|
+
async function hardDeleteCustomer(ctx, customerId) {
|
|
220
|
+
const providerCustomerId = getProviderCustomerId(await getCustomerByIdOrThrow(ctx.database, customerId), ctx.provider.id);
|
|
221
|
+
if (providerCustomerId) try {
|
|
222
|
+
const activeSubscriptions = await ctx.stripe.listActiveSubscriptions({ providerCustomerId });
|
|
223
|
+
for (const sub of activeSubscriptions) await ctx.stripe.cancelSubscription({ providerSubscriptionId: sub.providerSubscriptionId });
|
|
224
|
+
await ctx.stripe.deleteCustomer({ providerCustomerId });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
ctx.logger.error({
|
|
227
|
+
providerCustomerId,
|
|
228
|
+
err: error
|
|
229
|
+
}, "failed to clean up Stripe customer");
|
|
230
|
+
}
|
|
231
|
+
await deleteCustomerFromDatabase(ctx.database, customerId);
|
|
232
|
+
}
|
|
233
|
+
async function emitCustomerUpdated(ctx, customerId) {
|
|
234
|
+
const payload = {
|
|
235
|
+
customerId,
|
|
236
|
+
subscriptions: await getCurrentSubscriptions(ctx.database, customerId)
|
|
237
|
+
};
|
|
238
|
+
try {
|
|
239
|
+
await ctx.options.on?.["customer.updated"]?.({
|
|
240
|
+
name: "customer.updated",
|
|
241
|
+
payload
|
|
242
|
+
});
|
|
243
|
+
await ctx.options.on?.["*"]?.({ event: {
|
|
244
|
+
name: "customer.updated",
|
|
245
|
+
payload
|
|
246
|
+
} });
|
|
247
|
+
} catch (error) {
|
|
248
|
+
ctx.logger.error({ err: error }, "error in customer.updated event handler");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function listCustomers(ctx, input) {
|
|
252
|
+
const limit = input?.limit ?? 50;
|
|
253
|
+
const offset = input?.offset ?? 0;
|
|
254
|
+
const planIds = input?.planIds;
|
|
255
|
+
let customerRows;
|
|
256
|
+
let total;
|
|
257
|
+
if (planIds && planIds.length > 0) {
|
|
258
|
+
const planFilter = and(inArray(product.id, planIds), inArray(subscription.status, [
|
|
259
|
+
"active",
|
|
260
|
+
"trialing",
|
|
261
|
+
"past_due"
|
|
262
|
+
]), or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`), isNull(customer.deletedAt));
|
|
263
|
+
customerRows = (await ctx.database.selectDistinct({ customer }).from(customer).innerJoin(subscription, eq(subscription.customerId, customer.id)).innerJoin(product, eq(product.internalId, subscription.productInternalId)).where(planFilter).orderBy(desc(customer.createdAt)).limit(limit).offset(offset)).map((r) => r.customer);
|
|
264
|
+
total = (await ctx.database.select({ count: countDistinct(customer.id) }).from(customer).innerJoin(subscription, eq(subscription.customerId, customer.id)).innerJoin(product, eq(product.internalId, subscription.productInternalId)).where(planFilter))[0]?.count ?? 0;
|
|
265
|
+
} else {
|
|
266
|
+
total = (await ctx.database.select({ count: count() }).from(customer).where(isNull(customer.deletedAt)))[0]?.count ?? 0;
|
|
267
|
+
customerRows = await ctx.database.query.customer.findMany({
|
|
268
|
+
where: isNull(customer.deletedAt),
|
|
269
|
+
orderBy: (c, { desc }) => [desc(c.createdAt)],
|
|
270
|
+
limit,
|
|
271
|
+
offset
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const customerIds = customerRows.map((c) => c.id);
|
|
275
|
+
const data = [];
|
|
276
|
+
if (customerIds.length > 0) {
|
|
277
|
+
const subRows = await ctx.database.select({
|
|
278
|
+
customerId: subscription.customerId,
|
|
279
|
+
planId: product.id,
|
|
280
|
+
status: subscription.status,
|
|
281
|
+
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
282
|
+
currentPeriodStart: subscription.currentPeriodStartAt,
|
|
283
|
+
currentPeriodEnd: subscription.currentPeriodEndAt
|
|
284
|
+
}).from(subscription).innerJoin(product, eq(product.internalId, subscription.productInternalId)).where(and(inArray(subscription.customerId, customerIds), inArray(subscription.status, [
|
|
285
|
+
"active",
|
|
286
|
+
"trialing",
|
|
287
|
+
"past_due",
|
|
288
|
+
"scheduled"
|
|
289
|
+
]), or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`))).orderBy(desc(subscription.createdAt));
|
|
290
|
+
const entRows = await ctx.database.select({
|
|
291
|
+
customerId: entitlement.customerId,
|
|
292
|
+
featureId: entitlement.featureId,
|
|
293
|
+
balance: entitlement.balance,
|
|
294
|
+
limit: entitlement.limit,
|
|
295
|
+
nextResetAt: entitlement.nextResetAt
|
|
296
|
+
}).from(entitlement).innerJoin(subscription, eq(subscription.id, entitlement.subscriptionId)).where(and(inArray(entitlement.customerId, customerIds), inArray(subscription.status, [
|
|
297
|
+
"active",
|
|
298
|
+
"trialing",
|
|
299
|
+
"past_due"
|
|
300
|
+
]), or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`)));
|
|
301
|
+
const subscriptionsByCustomer = /* @__PURE__ */ new Map();
|
|
302
|
+
for (const row of subRows) {
|
|
303
|
+
const list = subscriptionsByCustomer.get(row.customerId) ?? [];
|
|
304
|
+
list.push({
|
|
305
|
+
planId: row.planId,
|
|
306
|
+
status: row.status,
|
|
307
|
+
cancelAtPeriodEnd: row.cancelAtPeriodEnd,
|
|
308
|
+
currentPeriodStart: row.currentPeriodStart,
|
|
309
|
+
currentPeriodEnd: row.currentPeriodEnd
|
|
310
|
+
});
|
|
311
|
+
subscriptionsByCustomer.set(row.customerId, list);
|
|
312
|
+
}
|
|
313
|
+
const entitlementsByCustomer = /* @__PURE__ */ new Map();
|
|
314
|
+
for (const row of entRows) {
|
|
315
|
+
const map = entitlementsByCustomer.get(row.customerId) ?? {};
|
|
316
|
+
const isUnlimited = row.limit === null;
|
|
317
|
+
map[row.featureId] = {
|
|
318
|
+
featureId: row.featureId,
|
|
319
|
+
balance: row.balance ?? 0,
|
|
320
|
+
limit: row.limit ?? 0,
|
|
321
|
+
usage: isUnlimited ? 0 : (row.limit ?? 0) - (row.balance ?? 0),
|
|
322
|
+
unlimited: isUnlimited,
|
|
323
|
+
nextResetAt: row.nextResetAt
|
|
324
|
+
};
|
|
325
|
+
entitlementsByCustomer.set(row.customerId, map);
|
|
326
|
+
}
|
|
327
|
+
for (const c of customerRows) data.push({
|
|
328
|
+
...c,
|
|
329
|
+
subscriptions: subscriptionsByCustomer.get(c.id) ?? [],
|
|
330
|
+
entitlements: entitlementsByCustomer.get(c.id) ?? {}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
data,
|
|
335
|
+
total,
|
|
336
|
+
hasMore: offset + limit < total,
|
|
337
|
+
limit,
|
|
338
|
+
offset
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
//#endregion
|
|
342
|
+
export { applyCustomerWebhookAction, emitCustomerUpdated, findCustomerByProviderCustomerId, getCustomerByIdOrThrow, getCustomerWithDetails, getProviderCustomer, getProviderCustomerIdForCustomer, hardDeleteCustomer, listCustomers, setProviderCustomer, syncCustomerWithDefaults, upsertProviderCustomer };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Customer } from "../types/models.js";
|
|
2
|
+
|
|
3
|
+
//#region src/customer/customer.types.d.ts
|
|
4
|
+
interface CustomerSubscription {
|
|
5
|
+
planId: string;
|
|
6
|
+
status: string;
|
|
7
|
+
cancelAtPeriodEnd: boolean;
|
|
8
|
+
currentPeriodStart: Date | null;
|
|
9
|
+
currentPeriodEnd: Date | null;
|
|
10
|
+
}
|
|
11
|
+
interface CustomerEntitlement {
|
|
12
|
+
featureId: string;
|
|
13
|
+
balance: number;
|
|
14
|
+
limit: number;
|
|
15
|
+
usage: number;
|
|
16
|
+
unlimited: boolean;
|
|
17
|
+
nextResetAt: Date | null;
|
|
18
|
+
}
|
|
19
|
+
interface CustomerWithDetails extends Customer {
|
|
20
|
+
subscriptions: CustomerSubscription[];
|
|
21
|
+
entitlements: Record<string, CustomerEntitlement>;
|
|
22
|
+
}
|
|
23
|
+
interface ListCustomersResult {
|
|
24
|
+
data: CustomerWithDetails[];
|
|
25
|
+
total: number;
|
|
26
|
+
hasMore: boolean;
|
|
27
|
+
limit: number;
|
|
28
|
+
offset: number;
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { CustomerEntitlement, CustomerSubscription, CustomerWithDetails, ListCustomersResult };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { schema_d_exports } from "./schema.js";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
+
|
|
5
|
+
//#region src/database/index.d.ts
|
|
6
|
+
type PayKitDatabase = NodePgDatabase<typeof schema_d_exports>;
|
|
7
|
+
//#endregion
|
|
8
|
+
export { PayKitDatabase };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { schema_exports } from "./schema.js";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
6
|
+
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
7
|
+
//#region src/database/index.ts
|
|
8
|
+
const migrationsSchema = "public";
|
|
9
|
+
const migrationsTable = "paykit_migrations";
|
|
10
|
+
const migrationsFolder = path.join(path.dirname(fileURLToPath(import.meta.url)), "migrations");
|
|
11
|
+
async function createDatabase(database) {
|
|
12
|
+
return drizzle(database, { schema: schema_exports });
|
|
13
|
+
}
|
|
14
|
+
async function migrateDatabase(database) {
|
|
15
|
+
await migrate(drizzle(database, { schema: schema_exports }), {
|
|
16
|
+
migrationsFolder,
|
|
17
|
+
migrationsSchema,
|
|
18
|
+
migrationsTable
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async function getPendingMigrationCount(database) {
|
|
22
|
+
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
|
|
23
|
+
const totalMigrations = JSON.parse(fs.readFileSync(journalPath, "utf-8")).entries.length;
|
|
24
|
+
try {
|
|
25
|
+
const appliedCount = (await database.query(`SELECT count(*)::int AS count FROM ${migrationsSchema}.${migrationsTable}`)).rows[0]?.count ?? 0;
|
|
26
|
+
return Math.max(0, totalMigrations - appliedCount);
|
|
27
|
+
} catch {
|
|
28
|
+
return totalMigrations;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { createDatabase, getPendingMigrationCount, migrateDatabase };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
CREATE TABLE "paykit_customer" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"email" text,
|
|
4
|
+
"name" text,
|
|
5
|
+
"metadata" jsonb,
|
|
6
|
+
"provider" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
7
|
+
"deleted_at" timestamp,
|
|
8
|
+
"created_at" timestamp NOT NULL,
|
|
9
|
+
"updated_at" timestamp NOT NULL
|
|
10
|
+
);
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
CREATE TABLE "paykit_entitlement" (
|
|
13
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
14
|
+
"subscription_id" text,
|
|
15
|
+
"customer_id" text NOT NULL,
|
|
16
|
+
"feature_id" text NOT NULL,
|
|
17
|
+
"limit" integer,
|
|
18
|
+
"balance" integer,
|
|
19
|
+
"next_reset_at" timestamp,
|
|
20
|
+
"created_at" timestamp NOT NULL,
|
|
21
|
+
"updated_at" timestamp NOT NULL
|
|
22
|
+
);
|
|
23
|
+
--> statement-breakpoint
|
|
24
|
+
CREATE TABLE "paykit_feature" (
|
|
25
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
26
|
+
"type" text NOT NULL,
|
|
27
|
+
"created_at" timestamp NOT NULL,
|
|
28
|
+
"updated_at" timestamp NOT NULL
|
|
29
|
+
);
|
|
30
|
+
--> statement-breakpoint
|
|
31
|
+
CREATE TABLE "paykit_invoice" (
|
|
32
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
33
|
+
"customer_id" text NOT NULL,
|
|
34
|
+
"subscription_id" text,
|
|
35
|
+
"type" text NOT NULL,
|
|
36
|
+
"status" text NOT NULL,
|
|
37
|
+
"amount" integer NOT NULL,
|
|
38
|
+
"currency" text NOT NULL,
|
|
39
|
+
"description" text,
|
|
40
|
+
"hosted_url" text,
|
|
41
|
+
"provider_id" text NOT NULL,
|
|
42
|
+
"provider_data" jsonb NOT NULL,
|
|
43
|
+
"period_start_at" timestamp,
|
|
44
|
+
"period_end_at" timestamp,
|
|
45
|
+
"created_at" timestamp NOT NULL,
|
|
46
|
+
"updated_at" timestamp NOT NULL
|
|
47
|
+
);
|
|
48
|
+
--> statement-breakpoint
|
|
49
|
+
CREATE TABLE "paykit_metadata" (
|
|
50
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
51
|
+
"provider_id" text NOT NULL,
|
|
52
|
+
"type" text NOT NULL,
|
|
53
|
+
"data" jsonb NOT NULL,
|
|
54
|
+
"provider_checkout_session_id" text,
|
|
55
|
+
"expires_at" timestamp,
|
|
56
|
+
"created_at" timestamp NOT NULL
|
|
57
|
+
);
|
|
58
|
+
--> statement-breakpoint
|
|
59
|
+
CREATE TABLE "paykit_payment_method" (
|
|
60
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
61
|
+
"customer_id" text NOT NULL,
|
|
62
|
+
"provider_id" text NOT NULL,
|
|
63
|
+
"provider_data" jsonb NOT NULL,
|
|
64
|
+
"is_default" boolean DEFAULT false NOT NULL,
|
|
65
|
+
"deleted_at" timestamp,
|
|
66
|
+
"created_at" timestamp NOT NULL,
|
|
67
|
+
"updated_at" timestamp NOT NULL
|
|
68
|
+
);
|
|
69
|
+
--> statement-breakpoint
|
|
70
|
+
CREATE TABLE "paykit_product" (
|
|
71
|
+
"internal_id" text PRIMARY KEY NOT NULL,
|
|
72
|
+
"id" text NOT NULL,
|
|
73
|
+
"version" integer DEFAULT 1 NOT NULL,
|
|
74
|
+
"name" text NOT NULL,
|
|
75
|
+
"group" text DEFAULT '' NOT NULL,
|
|
76
|
+
"is_default" boolean DEFAULT false NOT NULL,
|
|
77
|
+
"price_amount" integer,
|
|
78
|
+
"price_interval" text,
|
|
79
|
+
"hash" text,
|
|
80
|
+
"provider" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
81
|
+
"created_at" timestamp NOT NULL,
|
|
82
|
+
"updated_at" timestamp NOT NULL
|
|
83
|
+
);
|
|
84
|
+
--> statement-breakpoint
|
|
85
|
+
CREATE TABLE "paykit_product_feature" (
|
|
86
|
+
"product_internal_id" text NOT NULL,
|
|
87
|
+
"feature_id" text NOT NULL,
|
|
88
|
+
"limit" integer,
|
|
89
|
+
"reset_interval" text,
|
|
90
|
+
"config" jsonb,
|
|
91
|
+
"created_at" timestamp NOT NULL,
|
|
92
|
+
"updated_at" timestamp NOT NULL,
|
|
93
|
+
CONSTRAINT "paykit_product_feature_product_internal_id_feature_id_pk" PRIMARY KEY("product_internal_id","feature_id")
|
|
94
|
+
);
|
|
95
|
+
--> statement-breakpoint
|
|
96
|
+
CREATE TABLE "paykit_subscription" (
|
|
97
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
98
|
+
"customer_id" text NOT NULL,
|
|
99
|
+
"product_internal_id" text NOT NULL,
|
|
100
|
+
"provider_id" text,
|
|
101
|
+
"provider_data" jsonb,
|
|
102
|
+
"status" text NOT NULL,
|
|
103
|
+
"canceled" boolean DEFAULT false NOT NULL,
|
|
104
|
+
"cancel_at_period_end" boolean DEFAULT false NOT NULL,
|
|
105
|
+
"started_at" timestamp,
|
|
106
|
+
"trial_ends_at" timestamp,
|
|
107
|
+
"current_period_start_at" timestamp,
|
|
108
|
+
"current_period_end_at" timestamp,
|
|
109
|
+
"canceled_at" timestamp,
|
|
110
|
+
"ended_at" timestamp,
|
|
111
|
+
"scheduled_product_id" text,
|
|
112
|
+
"quantity" integer DEFAULT 1 NOT NULL,
|
|
113
|
+
"created_at" timestamp NOT NULL,
|
|
114
|
+
"updated_at" timestamp NOT NULL
|
|
115
|
+
);
|
|
116
|
+
--> statement-breakpoint
|
|
117
|
+
CREATE TABLE "paykit_webhook_event" (
|
|
118
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
119
|
+
"provider_id" text NOT NULL,
|
|
120
|
+
"provider_event_id" text NOT NULL,
|
|
121
|
+
"type" text NOT NULL,
|
|
122
|
+
"payload" jsonb NOT NULL,
|
|
123
|
+
"status" text NOT NULL,
|
|
124
|
+
"error" text,
|
|
125
|
+
"trace_id" text,
|
|
126
|
+
"received_at" timestamp NOT NULL,
|
|
127
|
+
"processed_at" timestamp
|
|
128
|
+
);
|
|
129
|
+
--> statement-breakpoint
|
|
130
|
+
ALTER TABLE "paykit_entitlement" ADD CONSTRAINT "paykit_entitlement_subscription_id_paykit_subscription_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."paykit_subscription"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
131
|
+
ALTER TABLE "paykit_entitlement" ADD CONSTRAINT "paykit_entitlement_customer_id_paykit_customer_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."paykit_customer"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
132
|
+
ALTER TABLE "paykit_entitlement" ADD CONSTRAINT "paykit_entitlement_feature_id_paykit_feature_id_fk" FOREIGN KEY ("feature_id") REFERENCES "public"."paykit_feature"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
133
|
+
ALTER TABLE "paykit_invoice" ADD CONSTRAINT "paykit_invoice_customer_id_paykit_customer_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."paykit_customer"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
134
|
+
ALTER TABLE "paykit_invoice" ADD CONSTRAINT "paykit_invoice_subscription_id_paykit_subscription_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."paykit_subscription"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
135
|
+
ALTER TABLE "paykit_payment_method" ADD CONSTRAINT "paykit_payment_method_customer_id_paykit_customer_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."paykit_customer"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
136
|
+
ALTER TABLE "paykit_product_feature" ADD CONSTRAINT "paykit_product_feature_product_internal_id_paykit_product_internal_id_fk" FOREIGN KEY ("product_internal_id") REFERENCES "public"."paykit_product"("internal_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
137
|
+
ALTER TABLE "paykit_product_feature" ADD CONSTRAINT "paykit_product_feature_feature_id_paykit_feature_id_fk" FOREIGN KEY ("feature_id") REFERENCES "public"."paykit_feature"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
138
|
+
ALTER TABLE "paykit_subscription" ADD CONSTRAINT "paykit_subscription_customer_id_paykit_customer_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."paykit_customer"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
139
|
+
ALTER TABLE "paykit_subscription" ADD CONSTRAINT "paykit_subscription_product_internal_id_paykit_product_internal_id_fk" FOREIGN KEY ("product_internal_id") REFERENCES "public"."paykit_product"("internal_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
140
|
+
CREATE INDEX "paykit_customer_deleted_at_idx" ON "paykit_customer" USING btree ("deleted_at");--> statement-breakpoint
|
|
141
|
+
CREATE INDEX "paykit_entitlement_subscription_idx" ON "paykit_entitlement" USING btree ("subscription_id");--> statement-breakpoint
|
|
142
|
+
CREATE INDEX "paykit_entitlement_customer_feature_idx" ON "paykit_entitlement" USING btree ("customer_id","feature_id");--> statement-breakpoint
|
|
143
|
+
CREATE INDEX "paykit_entitlement_next_reset_idx" ON "paykit_entitlement" USING btree ("next_reset_at");--> statement-breakpoint
|
|
144
|
+
CREATE INDEX "paykit_invoice_customer_idx" ON "paykit_invoice" USING btree ("customer_id","created_at");--> statement-breakpoint
|
|
145
|
+
CREATE INDEX "paykit_invoice_subscription_idx" ON "paykit_invoice" USING btree ("subscription_id");--> statement-breakpoint
|
|
146
|
+
CREATE INDEX "paykit_invoice_provider_idx" ON "paykit_invoice" USING btree ("provider_id");--> statement-breakpoint
|
|
147
|
+
CREATE UNIQUE INDEX "paykit_metadata_checkout_session_unique" ON "paykit_metadata" USING btree ("provider_id","provider_checkout_session_id");--> statement-breakpoint
|
|
148
|
+
CREATE INDEX "paykit_payment_method_customer_idx" ON "paykit_payment_method" USING btree ("customer_id","deleted_at");--> statement-breakpoint
|
|
149
|
+
CREATE INDEX "paykit_payment_method_provider_idx" ON "paykit_payment_method" USING btree ("provider_id");--> statement-breakpoint
|
|
150
|
+
CREATE UNIQUE INDEX "paykit_product_id_version_unique" ON "paykit_product" USING btree ("id","version");--> statement-breakpoint
|
|
151
|
+
CREATE INDEX "paykit_product_default_idx" ON "paykit_product" USING btree ("is_default");--> statement-breakpoint
|
|
152
|
+
CREATE INDEX "paykit_product_feature_feature_idx" ON "paykit_product_feature" USING btree ("feature_id");--> statement-breakpoint
|
|
153
|
+
CREATE INDEX "paykit_subscription_customer_status_idx" ON "paykit_subscription" USING btree ("customer_id","status","ended_at");--> statement-breakpoint
|
|
154
|
+
CREATE INDEX "paykit_subscription_product_idx" ON "paykit_subscription" USING btree ("product_internal_id");--> statement-breakpoint
|
|
155
|
+
CREATE INDEX "paykit_subscription_provider_idx" ON "paykit_subscription" USING btree ("provider_id");--> statement-breakpoint
|
|
156
|
+
CREATE UNIQUE INDEX "paykit_webhook_event_provider_unique" ON "paykit_webhook_event" USING btree ("provider_id","provider_event_id");--> statement-breakpoint
|
|
157
|
+
CREATE INDEX "paykit_webhook_event_status_idx" ON "paykit_webhook_event" USING btree ("provider_id","status");
|