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,896 @@
|
|
|
1
|
+
import { PAYKIT_ERROR_CODES, PayKitError } from "../core/errors.js";
|
|
2
|
+
import { entitlement, product, subscription } from "../database/schema.js";
|
|
3
|
+
import { generateId } from "../core/utils.js";
|
|
4
|
+
import { getDefaultProductInGroup, getLatestProduct, getProductByProviderPriceId, withProviderInfo } from "../product/product.service.js";
|
|
5
|
+
import { upsertInvoiceRecord } from "../invoice/invoice.service.js";
|
|
6
|
+
import { getDefaultPaymentMethod } from "../payment-method/payment-method.service.js";
|
|
7
|
+
import { findCustomerByProviderCustomerId, upsertProviderCustomer } from "../customer/customer.service.js";
|
|
8
|
+
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
|
9
|
+
//#region src/subscription/subscription.service.ts
|
|
10
|
+
/** Applies the requested plan change for a customer and returns the immediate subscribe result. */
|
|
11
|
+
async function subscribeToPlan(ctx, input) {
|
|
12
|
+
return ctx.logger.trace.run("sub", async () => {
|
|
13
|
+
const startTime = Date.now();
|
|
14
|
+
ctx.logger.info({
|
|
15
|
+
planId: input.planId,
|
|
16
|
+
customerId: input.customerId
|
|
17
|
+
}, "subscribe started");
|
|
18
|
+
const subCtx = await loadSubscribeContext(ctx, input);
|
|
19
|
+
let result;
|
|
20
|
+
if (isSamePlan(subCtx)) result = await handleSamePlanSubscribe(ctx, subCtx);
|
|
21
|
+
else if (!subCtx.activeSubscription) result = await handleInitialSubscribe(ctx, subCtx);
|
|
22
|
+
else if (!hasProviderSubscription(subCtx.activeSubscription)) result = await handleLocalPlanSwitch(ctx, subCtx);
|
|
23
|
+
else if (subCtx.isFreeTarget) result = await handleCancelToFree(ctx, subCtx);
|
|
24
|
+
else if (!subCtx.isUpgrade) result = await handleScheduledDowngrade(ctx, subCtx);
|
|
25
|
+
else result = await handleUpgrade(ctx, subCtx);
|
|
26
|
+
const duration = Date.now() - startTime;
|
|
27
|
+
ctx.logger.info({ duration }, "subscribe completed");
|
|
28
|
+
return result;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function loadSubscribeContext(ctx, input) {
|
|
32
|
+
const providerId = ctx.provider.id;
|
|
33
|
+
const normalizedPlan = ctx.plans.planMap.get(input.planId);
|
|
34
|
+
const latestProduct = await getLatestProduct(ctx.database, input.planId);
|
|
35
|
+
const storedPlan = latestProduct ? withProviderInfo(latestProduct, providerId) : null;
|
|
36
|
+
if (!normalizedPlan || !storedPlan) throw PayKitError.from("NOT_FOUND", PAYKIT_ERROR_CODES.PLAN_NOT_FOUND, `Plan "${input.planId}" not found`);
|
|
37
|
+
if (storedPlan.hash !== normalizedPlan.hash) {
|
|
38
|
+
ctx.logger.error({ planId: input.planId }, `Plan "${input.planId}" is out of sync. Run \`paykitjs push\` to update.`);
|
|
39
|
+
throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.PLAN_NOT_SYNCED, `Plan "${input.planId}" schema has changed since last sync. Run \`paykitjs push\` to update.`);
|
|
40
|
+
}
|
|
41
|
+
const isFreeTarget = storedPlan.priceAmount === null;
|
|
42
|
+
const isPaidTarget = !isFreeTarget;
|
|
43
|
+
if (isPaidTarget && !storedPlan.providerPriceId) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.PLAN_NOT_SYNCED, `Plan "${input.planId}" is not synced with provider`);
|
|
44
|
+
await warnOnDuplicateActiveSubscriptionGroups(ctx, input.customerId);
|
|
45
|
+
const { providerCustomerId } = await upsertProviderCustomer(ctx, { customerId: input.customerId });
|
|
46
|
+
const hasDefaultPaymentMethod = await getDefaultPaymentMethod(ctx.database, {
|
|
47
|
+
customerId: input.customerId,
|
|
48
|
+
providerId
|
|
49
|
+
}) != null;
|
|
50
|
+
const activeSubscription = storedPlan.group ? await getActiveSubscriptionInGroup(ctx.database, {
|
|
51
|
+
customerId: input.customerId,
|
|
52
|
+
group: storedPlan.group
|
|
53
|
+
}) : null;
|
|
54
|
+
const scheduledSubscriptions = storedPlan.group ? await getScheduledSubscriptionsInGroup(ctx.database, {
|
|
55
|
+
customerId: input.customerId,
|
|
56
|
+
group: storedPlan.group
|
|
57
|
+
}) : [];
|
|
58
|
+
const activeAmount = activeSubscription?.priceAmount ?? 0;
|
|
59
|
+
const targetAmount = storedPlan.priceAmount ?? 0;
|
|
60
|
+
const isUpgrade = activeSubscription != null && hasProviderSubscription(activeSubscription) && targetAmount > activeAmount;
|
|
61
|
+
return {
|
|
62
|
+
activeSubscription,
|
|
63
|
+
cancelUrl: input.cancelUrl,
|
|
64
|
+
customerId: input.customerId,
|
|
65
|
+
isFreeTarget,
|
|
66
|
+
isPaidTarget,
|
|
67
|
+
isUpgrade,
|
|
68
|
+
normalizedPlan,
|
|
69
|
+
providerCustomerId,
|
|
70
|
+
providerId,
|
|
71
|
+
scheduledSubscriptions,
|
|
72
|
+
shouldUseCheckout: isPaidTarget && (input.forceCheckout === true || !hasDefaultPaymentMethod),
|
|
73
|
+
storedPlan,
|
|
74
|
+
successUrl: input.successUrl
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function buildSubscribeResult(input) {
|
|
78
|
+
return {
|
|
79
|
+
invoice: input.invoice ? {
|
|
80
|
+
currency: input.invoice.currency,
|
|
81
|
+
hostedUrl: input.invoice.hostedUrl ?? null,
|
|
82
|
+
providerInvoiceId: input.invoice.providerInvoiceId,
|
|
83
|
+
status: input.invoice.status,
|
|
84
|
+
totalAmount: input.invoice.totalAmount
|
|
85
|
+
} : void 0,
|
|
86
|
+
paymentUrl: input.paymentUrl,
|
|
87
|
+
requiredAction: input.requiredAction ?? null
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async function cancelExistingProviderSubscriptionForCheckout(ctx, completion) {
|
|
91
|
+
const activeSubscriptionRef = getProviderSubscriptionRef(completion.subCtx.activeSubscription);
|
|
92
|
+
if (isSamePlan(completion.subCtx)) {
|
|
93
|
+
if (activeSubscriptionRef.subscriptionId === completion.subscription.providerSubscriptionId) return;
|
|
94
|
+
throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID, `Checkout completed for plan "${completion.subCtx.storedPlan.id}" after a different active subscription was already present`);
|
|
95
|
+
}
|
|
96
|
+
if (!completion.subCtx.activeSubscription || !activeSubscriptionRef.subscriptionId) return;
|
|
97
|
+
if (!completion.subCtx.isUpgrade) throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID, `Checkout completion is only valid for new paid subscriptions or upgrades to "${completion.subCtx.storedPlan.id}"`);
|
|
98
|
+
await ctx.stripe.cancelSubscription({
|
|
99
|
+
currentPeriodEndAt: completion.subCtx.activeSubscription.currentPeriodEndAt,
|
|
100
|
+
providerSubscriptionId: activeSubscriptionRef.subscriptionId,
|
|
101
|
+
providerSubscriptionScheduleId: activeSubscriptionRef.subscriptionScheduleId
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function applyCheckoutSubscription(ctx, completion) {
|
|
105
|
+
const activeSubscriptionRef = getProviderSubscriptionRef(completion.subCtx.activeSubscription);
|
|
106
|
+
if (isSamePlan(completion.subCtx)) {
|
|
107
|
+
if (activeSubscriptionRef.subscriptionId === completion.subscription.providerSubscriptionId) return;
|
|
108
|
+
throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID, `Checkout completed for plan "${completion.subCtx.storedPlan.id}" after a different active subscription was already present`);
|
|
109
|
+
}
|
|
110
|
+
if (completion.subCtx.activeSubscription && activeSubscriptionRef.subscriptionId) {
|
|
111
|
+
await deleteScheduledSubscriptionsInGroupIfNeeded(ctx.database, completion.subCtx);
|
|
112
|
+
await endSubscriptions(ctx.database, [completion.subCtx.activeSubscription.id], {
|
|
113
|
+
canceled: false,
|
|
114
|
+
endedAt: /* @__PURE__ */ new Date(),
|
|
115
|
+
status: "ended"
|
|
116
|
+
});
|
|
117
|
+
await upsertProviderBackedTargetSubscription(ctx.database, completion.subCtx, completion, { deferred: true });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (completion.subCtx.activeSubscription) await endSubscriptions(ctx.database, [completion.subCtx.activeSubscription.id], {
|
|
121
|
+
canceled: false,
|
|
122
|
+
endedAt: /* @__PURE__ */ new Date(),
|
|
123
|
+
status: "ended"
|
|
124
|
+
});
|
|
125
|
+
await upsertProviderBackedTargetSubscription(ctx.database, completion.subCtx, completion, { deferred: true });
|
|
126
|
+
}
|
|
127
|
+
async function prepareSubscribeCheckoutCompleted(ctx, event) {
|
|
128
|
+
if (event.payload.mode !== "subscription") return null;
|
|
129
|
+
if (event.payload.metadata?.paykit_intent !== "subscribe") return null;
|
|
130
|
+
const customerId = event.payload.metadata?.paykit_customer_id;
|
|
131
|
+
const planId = event.payload.metadata?.paykit_plan_id;
|
|
132
|
+
if (!customerId || !planId) throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID, "Subscribe checkout metadata is missing paykit_customer_id or paykit_plan_id");
|
|
133
|
+
const checkoutSubscription = event.payload.subscription ?? null;
|
|
134
|
+
if (!checkoutSubscription) throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID, "Subscribe checkout completion is missing subscription data");
|
|
135
|
+
const subCtx = await loadSubscribeContext(ctx, {
|
|
136
|
+
customerId,
|
|
137
|
+
planId,
|
|
138
|
+
successUrl: "https://paykit.invalid/checkout"
|
|
139
|
+
});
|
|
140
|
+
if (subCtx.storedPlan.providerPriceId !== checkoutSubscription.providerPriceId) throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID, `Checkout price mismatch for plan "${planId}"`);
|
|
141
|
+
const completion = {
|
|
142
|
+
customerId,
|
|
143
|
+
invoice: event.payload.invoice ?? null,
|
|
144
|
+
subCtx,
|
|
145
|
+
subscription: checkoutSubscription
|
|
146
|
+
};
|
|
147
|
+
await cancelExistingProviderSubscriptionForCheckout(ctx, completion);
|
|
148
|
+
return completion;
|
|
149
|
+
}
|
|
150
|
+
async function handleSubscribeCheckoutCompleted(ctx, completion) {
|
|
151
|
+
await applyCheckoutSubscription(ctx, completion);
|
|
152
|
+
return completion.customerId;
|
|
153
|
+
}
|
|
154
|
+
function isSamePlan(subCtx) {
|
|
155
|
+
return subCtx.activeSubscription?.planId === subCtx.storedPlan.id;
|
|
156
|
+
}
|
|
157
|
+
function getSubscriptionEffectiveDate(input) {
|
|
158
|
+
return input.currentPeriodStartAt ?? input.currentPeriodEndAt ?? /* @__PURE__ */ new Date();
|
|
159
|
+
}
|
|
160
|
+
async function ensureScheduledDefaultPlan(ctx, input) {
|
|
161
|
+
if ((await getScheduledSubscriptionsInGroup(ctx.database, {
|
|
162
|
+
customerId: input.customerId,
|
|
163
|
+
group: input.group
|
|
164
|
+
})).length > 0) return;
|
|
165
|
+
const defaultPlan = await getDefaultProductInGroup(ctx.database, input.group);
|
|
166
|
+
if (!defaultPlan || defaultPlan.priceAmount !== null) return;
|
|
167
|
+
const normalizedPlan = ctx.plans.planMap.get(defaultPlan.id);
|
|
168
|
+
if (!normalizedPlan) return;
|
|
169
|
+
await insertSubscriptionRecord(ctx.database, {
|
|
170
|
+
customerId: input.customerId,
|
|
171
|
+
planFeatures: normalizedPlan.includes,
|
|
172
|
+
productInternalId: defaultPlan.internalId,
|
|
173
|
+
startedAt: input.startsAt,
|
|
174
|
+
status: "scheduled"
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async function activateScheduledSubscriptionForGroup(ctx, input) {
|
|
178
|
+
const activationDate = getSubscriptionEffectiveDate({
|
|
179
|
+
currentPeriodEndAt: input.subscriptionCurrentPeriodEndAt,
|
|
180
|
+
currentPeriodStartAt: input.subscriptionCurrentPeriodStartAt
|
|
181
|
+
});
|
|
182
|
+
const targetSub = (await getScheduledSubscriptionsInGroup(ctx.database, {
|
|
183
|
+
customerId: input.customerId,
|
|
184
|
+
group: input.productGroup
|
|
185
|
+
})).find((scheduled) => {
|
|
186
|
+
if (scheduled.startedAt && scheduled.startedAt > activationDate) return false;
|
|
187
|
+
if (!input.productInternalId) return true;
|
|
188
|
+
return scheduled.productInternalId === input.productInternalId;
|
|
189
|
+
});
|
|
190
|
+
if (!targetSub) return null;
|
|
191
|
+
const activeSub = await getActiveSubscriptionInGroup(ctx.database, {
|
|
192
|
+
customerId: input.customerId,
|
|
193
|
+
group: input.productGroup
|
|
194
|
+
});
|
|
195
|
+
if (activeSub && activeSub.id !== targetSub.id) await endSubscriptions(ctx.database, [activeSub.id], {
|
|
196
|
+
canceled: activeSub.canceled,
|
|
197
|
+
endedAt: activationDate,
|
|
198
|
+
status: "ended"
|
|
199
|
+
});
|
|
200
|
+
await activateScheduledSubscription(ctx.database, {
|
|
201
|
+
currentPeriodEndAt: input.subscriptionCurrentPeriodEndAt,
|
|
202
|
+
currentPeriodStartAt: input.subscriptionCurrentPeriodStartAt,
|
|
203
|
+
subscriptionId: targetSub.id,
|
|
204
|
+
startedAt: targetSub.startedAt ?? activationDate,
|
|
205
|
+
status: input.subscriptionStatus,
|
|
206
|
+
providerData: input.providerData,
|
|
207
|
+
providerId: input.providerId
|
|
208
|
+
});
|
|
209
|
+
return targetSub.id;
|
|
210
|
+
}
|
|
211
|
+
async function applySubscriptionWebhookAction(ctx, action) {
|
|
212
|
+
const customerRow = await findCustomerByProviderCustomerId(ctx.database, {
|
|
213
|
+
providerCustomerId: action.data.providerCustomerId,
|
|
214
|
+
providerId: ctx.provider.id
|
|
215
|
+
});
|
|
216
|
+
if (!customerRow) return null;
|
|
217
|
+
if (action.type === "subscription.delete") {
|
|
218
|
+
const existingSub = await getSubscriptionByProviderSubscriptionId(ctx.database, {
|
|
219
|
+
providerId: ctx.provider.id,
|
|
220
|
+
providerSubscriptionId: action.data.providerSubscriptionId
|
|
221
|
+
});
|
|
222
|
+
if (!existingSub) return customerRow.id;
|
|
223
|
+
const effectiveEndDate = existingSub.currentPeriodEndAt ?? /* @__PURE__ */ new Date();
|
|
224
|
+
await endSubscriptions(ctx.database, [existingSub.id], {
|
|
225
|
+
canceled: true,
|
|
226
|
+
endedAt: effectiveEndDate,
|
|
227
|
+
status: "canceled"
|
|
228
|
+
});
|
|
229
|
+
const productGroup = (await ctx.database.query.product.findFirst({ where: eq(product.internalId, existingSub.productInternalId) }))?.group ?? "";
|
|
230
|
+
if (productGroup) {
|
|
231
|
+
const effectiveDate = existingSub.currentPeriodEndAt ?? /* @__PURE__ */ new Date();
|
|
232
|
+
await ensureScheduledDefaultPlan(ctx, {
|
|
233
|
+
customerId: customerRow.id,
|
|
234
|
+
group: productGroup,
|
|
235
|
+
startsAt: effectiveDate
|
|
236
|
+
});
|
|
237
|
+
await activateScheduledSubscriptionForGroup(ctx, {
|
|
238
|
+
customerId: customerRow.id,
|
|
239
|
+
productGroup,
|
|
240
|
+
subscriptionCurrentPeriodEndAt: null,
|
|
241
|
+
subscriptionCurrentPeriodStartAt: effectiveDate,
|
|
242
|
+
subscriptionStatus: "active"
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return customerRow.id;
|
|
246
|
+
}
|
|
247
|
+
const existingSub = await getSubscriptionByProviderSubscriptionId(ctx.database, {
|
|
248
|
+
providerId: ctx.provider.id,
|
|
249
|
+
providerSubscriptionId: action.data.subscription.providerSubscriptionId
|
|
250
|
+
});
|
|
251
|
+
const storedProduct = action.data.subscription.providerPriceId ? await getProductByProviderPriceId(ctx.database, {
|
|
252
|
+
providerId: ctx.provider.id,
|
|
253
|
+
providerPriceId: action.data.subscription.providerPriceId
|
|
254
|
+
}) : null;
|
|
255
|
+
const normalizedPlan = storedProduct ? ctx.plans.planMap.get(storedProduct.id) ?? null : null;
|
|
256
|
+
const providerData = { subscriptionId: action.data.subscription.providerSubscriptionId };
|
|
257
|
+
const targetSub = existingSub ?? (storedProduct && normalizedPlan ? await insertSubscriptionRecord(ctx.database, {
|
|
258
|
+
currentPeriodEndAt: action.data.subscription.currentPeriodEndAt ?? null,
|
|
259
|
+
currentPeriodStartAt: action.data.subscription.currentPeriodStartAt ?? null,
|
|
260
|
+
customerId: customerRow.id,
|
|
261
|
+
planFeatures: normalizedPlan.includes,
|
|
262
|
+
productInternalId: storedProduct.internalId,
|
|
263
|
+
providerData,
|
|
264
|
+
providerId: ctx.provider.id,
|
|
265
|
+
startedAt: action.data.subscription.currentPeriodStartAt ?? /* @__PURE__ */ new Date(),
|
|
266
|
+
status: action.data.subscription.status
|
|
267
|
+
}) : null);
|
|
268
|
+
if (!targetSub) return customerRow.id;
|
|
269
|
+
await syncSubscriptionFromProvider(ctx.database, {
|
|
270
|
+
providerSubscription: action.data.subscription,
|
|
271
|
+
subscriptionId: targetSub.id
|
|
272
|
+
});
|
|
273
|
+
await syncSubscriptionBillingState(ctx.database, {
|
|
274
|
+
providerData,
|
|
275
|
+
subscriptionId: targetSub.id
|
|
276
|
+
});
|
|
277
|
+
if (storedProduct && existingSub?.cancelAtPeriodEnd && !action.data.subscription.cancelAtPeriodEnd && !action.data.subscription.providerSubscriptionScheduleId) {
|
|
278
|
+
await deleteScheduledSubscriptionsInGroup(ctx.database, {
|
|
279
|
+
customerId: customerRow.id,
|
|
280
|
+
group: storedProduct.group
|
|
281
|
+
});
|
|
282
|
+
const activeSub = await getActiveSubscriptionInGroup(ctx.database, {
|
|
283
|
+
customerId: customerRow.id,
|
|
284
|
+
group: storedProduct.group
|
|
285
|
+
});
|
|
286
|
+
if (activeSub) await replaceSubscriptionSchedule(ctx.database, {
|
|
287
|
+
scheduledProductId: null,
|
|
288
|
+
subscriptionId: activeSub.id
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (storedProduct) {
|
|
292
|
+
const activeSub = await getActiveSubscriptionInGroup(ctx.database, {
|
|
293
|
+
customerId: customerRow.id,
|
|
294
|
+
group: storedProduct.group
|
|
295
|
+
});
|
|
296
|
+
const competingSubscriptions = (await getActiveSubscriptionsInGroup(ctx.database, {
|
|
297
|
+
customerId: customerRow.id,
|
|
298
|
+
group: storedProduct.group
|
|
299
|
+
})).filter((sub) => sub.id !== targetSub.id);
|
|
300
|
+
const subStatus = action.data.subscription.status;
|
|
301
|
+
const isTerminal = subStatus === "canceled" || subStatus === "ended" || subStatus === "unpaid";
|
|
302
|
+
if (action.data.subscription.cancelAtPeriodEnd && activeSub) {
|
|
303
|
+
await scheduleSubscriptionCancellation(ctx.database, {
|
|
304
|
+
canceledAt: action.data.subscription.canceledAt ?? /* @__PURE__ */ new Date(),
|
|
305
|
+
currentPeriodEndAt: action.data.subscription.currentPeriodEndAt ?? activeSub.currentPeriodEndAt,
|
|
306
|
+
subscriptionId: activeSub.id
|
|
307
|
+
});
|
|
308
|
+
await ensureScheduledDefaultPlan(ctx, {
|
|
309
|
+
customerId: customerRow.id,
|
|
310
|
+
group: storedProduct.group,
|
|
311
|
+
startsAt: action.data.subscription.currentPeriodEndAt ?? activeSub.currentPeriodEndAt ?? /* @__PURE__ */ new Date()
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (isTerminal && activeSub) {
|
|
315
|
+
const effectiveDate = action.data.subscription.currentPeriodEndAt ?? /* @__PURE__ */ new Date();
|
|
316
|
+
await endSubscriptions(ctx.database, [activeSub.id], {
|
|
317
|
+
canceled: true,
|
|
318
|
+
endedAt: effectiveDate,
|
|
319
|
+
status: "canceled"
|
|
320
|
+
});
|
|
321
|
+
await ensureScheduledDefaultPlan(ctx, {
|
|
322
|
+
customerId: customerRow.id,
|
|
323
|
+
group: storedProduct.group,
|
|
324
|
+
startsAt: effectiveDate
|
|
325
|
+
});
|
|
326
|
+
await activateScheduledSubscriptionForGroup(ctx, {
|
|
327
|
+
customerId: customerRow.id,
|
|
328
|
+
productGroup: storedProduct.group,
|
|
329
|
+
subscriptionCurrentPeriodStartAt: effectiveDate,
|
|
330
|
+
subscriptionStatus: "active"
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
if (competingSubscriptions.length > 0) {
|
|
334
|
+
const effectiveDate = action.data.subscription.currentPeriodStartAt ?? action.data.subscription.currentPeriodEndAt ?? /* @__PURE__ */ new Date();
|
|
335
|
+
await endSubscriptions(ctx.database, competingSubscriptions.map((sub) => sub.id), {
|
|
336
|
+
canceled: false,
|
|
337
|
+
endedAt: effectiveDate,
|
|
338
|
+
status: "ended"
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const activatedSubId = await activateScheduledSubscriptionForGroup(ctx, {
|
|
342
|
+
customerId: customerRow.id,
|
|
343
|
+
productGroup: storedProduct.group,
|
|
344
|
+
productInternalId: storedProduct.internalId,
|
|
345
|
+
providerData,
|
|
346
|
+
providerId: ctx.provider.id,
|
|
347
|
+
subscriptionCurrentPeriodEndAt: action.data.subscription.currentPeriodEndAt,
|
|
348
|
+
subscriptionCurrentPeriodStartAt: action.data.subscription.currentPeriodStartAt,
|
|
349
|
+
subscriptionStatus: action.data.subscription.status
|
|
350
|
+
});
|
|
351
|
+
if (activatedSubId) await syncSubscriptionFromProvider(ctx.database, {
|
|
352
|
+
providerSubscription: action.data.subscription,
|
|
353
|
+
subscriptionId: activatedSubId
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return customerRow.id;
|
|
358
|
+
}
|
|
359
|
+
function getProviderSubscriptionId(subscription) {
|
|
360
|
+
if (subscription?.providerData == null) return null;
|
|
361
|
+
return typeof subscription.providerData.subscriptionId === "string" ? subscription.providerData.subscriptionId : null;
|
|
362
|
+
}
|
|
363
|
+
function hasProviderSubscription(subscription) {
|
|
364
|
+
return getProviderSubscriptionId(subscription) != null;
|
|
365
|
+
}
|
|
366
|
+
function getProviderSubscriptionRef(subscription) {
|
|
367
|
+
if (subscription?.providerData == null) return {
|
|
368
|
+
subscriptionId: null,
|
|
369
|
+
subscriptionScheduleId: null
|
|
370
|
+
};
|
|
371
|
+
const providerData = subscription.providerData;
|
|
372
|
+
return {
|
|
373
|
+
subscriptionId: typeof providerData.subscriptionId === "string" ? providerData.subscriptionId : null,
|
|
374
|
+
subscriptionScheduleId: typeof providerData.subscriptionScheduleId === "string" ? providerData.subscriptionScheduleId : null
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/** Returns a noop or resumes the current provider subscription. */
|
|
378
|
+
async function handleSamePlanSubscribe(ctx, subCtx) {
|
|
379
|
+
const activeSubscription = subCtx.activeSubscription;
|
|
380
|
+
if (!activeSubscription) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED);
|
|
381
|
+
const hasPendingChange = subCtx.scheduledSubscriptions.length > 0 || activeSubscription.canceled;
|
|
382
|
+
if (!hasProviderSubscription(activeSubscription) || !hasPendingChange) return buildSubscribeResult({ paymentUrl: null });
|
|
383
|
+
const activeSubscriptionRef = getProviderSubscriptionRef(activeSubscription);
|
|
384
|
+
const stripeResult = await ctx.stripe.resumeSubscription({
|
|
385
|
+
providerSubscriptionId: activeSubscriptionRef.subscriptionId,
|
|
386
|
+
providerSubscriptionScheduleId: activeSubscriptionRef.subscriptionScheduleId
|
|
387
|
+
});
|
|
388
|
+
await ctx.database.transaction(async (tx) => {
|
|
389
|
+
await deleteScheduledSubscriptionsInGroupIfNeeded(tx, subCtx);
|
|
390
|
+
await syncSubscriptionFromProvider(tx, {
|
|
391
|
+
subscriptionId: activeSubscription.id,
|
|
392
|
+
providerSubscription: stripeResult.subscription ?? {
|
|
393
|
+
cancelAtPeriodEnd: false,
|
|
394
|
+
providerSubscriptionId: activeSubscriptionRef.subscriptionId,
|
|
395
|
+
providerSubscriptionScheduleId: null,
|
|
396
|
+
status: activeSubscription.status
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
await replaceSubscriptionSchedule(tx, {
|
|
400
|
+
subscriptionId: activeSubscription.id,
|
|
401
|
+
scheduledProductId: null
|
|
402
|
+
});
|
|
403
|
+
if (stripeResult.subscription) await syncSubscriptionBillingState(tx, {
|
|
404
|
+
currentPeriodEndAt: stripeResult.subscription.currentPeriodEndAt,
|
|
405
|
+
currentPeriodStartAt: stripeResult.subscription.currentPeriodStartAt,
|
|
406
|
+
providerData: {
|
|
407
|
+
subscriptionId: stripeResult.subscription.providerSubscriptionId,
|
|
408
|
+
subscriptionScheduleId: stripeResult.subscription.providerSubscriptionScheduleId ?? null
|
|
409
|
+
},
|
|
410
|
+
status: stripeResult.subscription.status,
|
|
411
|
+
subscriptionId: activeSubscription.id
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
return buildSubscribeResult({
|
|
415
|
+
invoice: stripeResult.invoice,
|
|
416
|
+
paymentUrl: stripeResult.paymentUrl,
|
|
417
|
+
requiredAction: stripeResult.requiredAction
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
/** Creates the first subscription in the product group. */
|
|
421
|
+
async function handleInitialSubscribe(ctx, subCtx) {
|
|
422
|
+
if (subCtx.isFreeTarget) {
|
|
423
|
+
await ctx.database.transaction(async (tx) => {
|
|
424
|
+
await insertLocalTargetSubscription(tx, subCtx, {
|
|
425
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
426
|
+
status: "active"
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
return buildSubscribeResult({ paymentUrl: null });
|
|
430
|
+
}
|
|
431
|
+
if (subCtx.shouldUseCheckout) return createCheckoutSubscribe(ctx, subCtx);
|
|
432
|
+
const stripeResult = await ctx.stripe.createSubscription({
|
|
433
|
+
providerCustomerId: subCtx.providerCustomerId,
|
|
434
|
+
providerPriceId: subCtx.storedPlan.providerPriceId
|
|
435
|
+
});
|
|
436
|
+
await ctx.database.transaction(async (tx) => {
|
|
437
|
+
if (!stripeResult.subscription) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED);
|
|
438
|
+
await upsertProviderBackedTargetSubscription(tx, subCtx, {
|
|
439
|
+
invoice: stripeResult.invoice ?? null,
|
|
440
|
+
subscription: stripeResult.subscription
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
return buildSubscribeResult({
|
|
444
|
+
invoice: stripeResult.invoice,
|
|
445
|
+
paymentUrl: stripeResult.paymentUrl,
|
|
446
|
+
requiredAction: stripeResult.requiredAction
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/** Replaces a local-only subscription with the requested target plan. */
|
|
450
|
+
async function handleLocalPlanSwitch(ctx, subCtx) {
|
|
451
|
+
const activeSubscription = subCtx.activeSubscription;
|
|
452
|
+
if (!activeSubscription) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED);
|
|
453
|
+
if (subCtx.shouldUseCheckout) return createCheckoutSubscribe(ctx, subCtx);
|
|
454
|
+
if (subCtx.isFreeTarget) {
|
|
455
|
+
await ctx.database.transaction(async (tx) => {
|
|
456
|
+
const now = /* @__PURE__ */ new Date();
|
|
457
|
+
await endSubscriptions(tx, [activeSubscription.id], {
|
|
458
|
+
canceled: false,
|
|
459
|
+
endedAt: now,
|
|
460
|
+
status: "ended"
|
|
461
|
+
});
|
|
462
|
+
await insertLocalTargetSubscription(tx, subCtx, {
|
|
463
|
+
startedAt: now,
|
|
464
|
+
status: "active"
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
return buildSubscribeResult({ paymentUrl: null });
|
|
468
|
+
}
|
|
469
|
+
const stripeResult = await ctx.stripe.createSubscription({
|
|
470
|
+
providerCustomerId: subCtx.providerCustomerId,
|
|
471
|
+
providerPriceId: subCtx.storedPlan.providerPriceId
|
|
472
|
+
});
|
|
473
|
+
await ctx.database.transaction(async (tx) => {
|
|
474
|
+
if (!stripeResult.subscription) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED);
|
|
475
|
+
await endSubscriptions(tx, [activeSubscription.id], {
|
|
476
|
+
canceled: false,
|
|
477
|
+
endedAt: /* @__PURE__ */ new Date(),
|
|
478
|
+
status: "ended"
|
|
479
|
+
});
|
|
480
|
+
await upsertProviderBackedTargetSubscription(tx, subCtx, {
|
|
481
|
+
invoice: stripeResult.invoice ?? null,
|
|
482
|
+
subscription: stripeResult.subscription
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
return buildSubscribeResult({
|
|
486
|
+
invoice: stripeResult.invoice,
|
|
487
|
+
paymentUrl: stripeResult.paymentUrl,
|
|
488
|
+
requiredAction: stripeResult.requiredAction
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
/** Cancels the paid subscription and schedules the free plan for period end. */
|
|
492
|
+
async function handleCancelToFree(ctx, subCtx) {
|
|
493
|
+
const activeSubscription = subCtx.activeSubscription;
|
|
494
|
+
const activeSubscriptionRef = getProviderSubscriptionRef(activeSubscription);
|
|
495
|
+
if (!activeSubscription || !activeSubscriptionRef.subscriptionId) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED);
|
|
496
|
+
const stripeResult = await ctx.stripe.cancelSubscription({
|
|
497
|
+
currentPeriodEndAt: activeSubscription.currentPeriodEndAt,
|
|
498
|
+
providerSubscriptionId: activeSubscriptionRef.subscriptionId,
|
|
499
|
+
providerSubscriptionScheduleId: activeSubscriptionRef.subscriptionScheduleId
|
|
500
|
+
});
|
|
501
|
+
await ctx.database.transaction(async (tx) => {
|
|
502
|
+
await clearScheduledSubscriptionsInGroupIfNeeded(tx, subCtx);
|
|
503
|
+
await insertLocalTargetSubscription(tx, subCtx, {
|
|
504
|
+
startedAt: activeSubscription.currentPeriodEndAt ?? null,
|
|
505
|
+
status: "scheduled"
|
|
506
|
+
});
|
|
507
|
+
await scheduleSubscriptionCancellation(tx, {
|
|
508
|
+
canceledAt: /* @__PURE__ */ new Date(),
|
|
509
|
+
currentPeriodEndAt: activeSubscription.currentPeriodEndAt ?? null,
|
|
510
|
+
subscriptionId: activeSubscription.id
|
|
511
|
+
});
|
|
512
|
+
await replaceSubscriptionSchedule(tx, {
|
|
513
|
+
scheduledProductId: subCtx.storedPlan.internalId,
|
|
514
|
+
subscriptionId: activeSubscription.id
|
|
515
|
+
});
|
|
516
|
+
if (stripeResult.subscription) await syncSubscriptionBillingState(tx, {
|
|
517
|
+
currentPeriodEndAt: stripeResult.subscription.currentPeriodEndAt,
|
|
518
|
+
currentPeriodStartAt: stripeResult.subscription.currentPeriodStartAt,
|
|
519
|
+
providerData: {
|
|
520
|
+
subscriptionId: stripeResult.subscription.providerSubscriptionId,
|
|
521
|
+
subscriptionScheduleId: stripeResult.subscription.providerSubscriptionScheduleId ?? null
|
|
522
|
+
},
|
|
523
|
+
status: stripeResult.subscription.status,
|
|
524
|
+
subscriptionId: activeSubscription.id
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
return buildSubscribeResult({
|
|
528
|
+
invoice: stripeResult.invoice,
|
|
529
|
+
paymentUrl: stripeResult.paymentUrl,
|
|
530
|
+
requiredAction: stripeResult.requiredAction
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/** Schedules a lower paid tier to start when the current billing period ends. */
|
|
534
|
+
async function handleScheduledDowngrade(ctx, subCtx) {
|
|
535
|
+
const activeSubscription = subCtx.activeSubscription;
|
|
536
|
+
const activeSubscriptionRef = getProviderSubscriptionRef(activeSubscription);
|
|
537
|
+
if (!activeSubscription || !activeSubscriptionRef.subscriptionId) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED);
|
|
538
|
+
const stripeResult = await ctx.stripe.scheduleSubscriptionChange({
|
|
539
|
+
providerPriceId: subCtx.storedPlan.providerPriceId,
|
|
540
|
+
providerSubscriptionId: activeSubscriptionRef.subscriptionId,
|
|
541
|
+
providerSubscriptionScheduleId: activeSubscriptionRef.subscriptionScheduleId
|
|
542
|
+
});
|
|
543
|
+
await ctx.database.transaction(async (tx) => {
|
|
544
|
+
await clearScheduledSubscriptionsInGroupIfNeeded(tx, subCtx);
|
|
545
|
+
await insertLocalTargetSubscription(tx, subCtx, {
|
|
546
|
+
startedAt: activeSubscription.currentPeriodEndAt ?? null,
|
|
547
|
+
status: "scheduled"
|
|
548
|
+
});
|
|
549
|
+
await scheduleSubscriptionCancellation(tx, {
|
|
550
|
+
canceledAt: /* @__PURE__ */ new Date(),
|
|
551
|
+
currentPeriodEndAt: activeSubscription.currentPeriodEndAt ?? null,
|
|
552
|
+
subscriptionId: activeSubscription.id
|
|
553
|
+
});
|
|
554
|
+
await replaceSubscriptionSchedule(tx, {
|
|
555
|
+
scheduledProductId: subCtx.storedPlan.internalId,
|
|
556
|
+
subscriptionId: activeSubscription.id
|
|
557
|
+
});
|
|
558
|
+
if (stripeResult.subscription) await syncSubscriptionBillingState(tx, {
|
|
559
|
+
currentPeriodEndAt: stripeResult.subscription.currentPeriodEndAt,
|
|
560
|
+
currentPeriodStartAt: stripeResult.subscription.currentPeriodStartAt,
|
|
561
|
+
providerData: {
|
|
562
|
+
subscriptionId: stripeResult.subscription.providerSubscriptionId,
|
|
563
|
+
subscriptionScheduleId: stripeResult.subscription.providerSubscriptionScheduleId ?? null
|
|
564
|
+
},
|
|
565
|
+
status: stripeResult.subscription.status,
|
|
566
|
+
subscriptionId: activeSubscription.id
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
return buildSubscribeResult({
|
|
570
|
+
invoice: stripeResult.invoice,
|
|
571
|
+
paymentUrl: stripeResult.paymentUrl,
|
|
572
|
+
requiredAction: stripeResult.requiredAction
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
/** Upgrades the customer immediately or redirects them to checkout. */
|
|
576
|
+
async function handleUpgrade(ctx, subCtx) {
|
|
577
|
+
const activeSubscription = subCtx.activeSubscription;
|
|
578
|
+
const activeSubscriptionRef = getProviderSubscriptionRef(activeSubscription);
|
|
579
|
+
if (!activeSubscription || !activeSubscriptionRef.subscriptionId) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED);
|
|
580
|
+
if (subCtx.shouldUseCheckout) return createCheckoutSubscribe(ctx, subCtx);
|
|
581
|
+
const stripeResult = await ctx.stripe.updateSubscription({
|
|
582
|
+
providerPriceId: subCtx.storedPlan.providerPriceId,
|
|
583
|
+
providerSubscriptionId: activeSubscriptionRef.subscriptionId
|
|
584
|
+
});
|
|
585
|
+
await ctx.database.transaction(async (tx) => {
|
|
586
|
+
if (!stripeResult.subscription) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED);
|
|
587
|
+
await deleteScheduledSubscriptionsInGroupIfNeeded(tx, subCtx);
|
|
588
|
+
await endSubscriptions(tx, [activeSubscription.id], {
|
|
589
|
+
canceled: false,
|
|
590
|
+
endedAt: /* @__PURE__ */ new Date(),
|
|
591
|
+
status: "ended"
|
|
592
|
+
});
|
|
593
|
+
await upsertProviderBackedTargetSubscription(tx, subCtx, {
|
|
594
|
+
invoice: stripeResult.invoice ?? null,
|
|
595
|
+
subscription: stripeResult.subscription
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
return buildSubscribeResult({
|
|
599
|
+
invoice: stripeResult.invoice,
|
|
600
|
+
paymentUrl: stripeResult.paymentUrl,
|
|
601
|
+
requiredAction: stripeResult.requiredAction
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
/** Starts checkout and lets the webhook finalize the subscription later. */
|
|
605
|
+
async function createCheckoutSubscribe(ctx, subCtx) {
|
|
606
|
+
return buildSubscribeResult({ paymentUrl: (await ctx.stripe.createSubscriptionCheckout({
|
|
607
|
+
cancelUrl: subCtx.cancelUrl,
|
|
608
|
+
metadata: {
|
|
609
|
+
paykit_customer_id: subCtx.customerId,
|
|
610
|
+
paykit_intent: "subscribe",
|
|
611
|
+
paykit_plan_id: subCtx.storedPlan.id
|
|
612
|
+
},
|
|
613
|
+
providerCustomerId: subCtx.providerCustomerId,
|
|
614
|
+
providerPriceId: subCtx.storedPlan.providerPriceId,
|
|
615
|
+
successUrl: subCtx.successUrl
|
|
616
|
+
})).paymentUrl });
|
|
617
|
+
}
|
|
618
|
+
async function insertLocalTargetSubscription(database, subCtx, input) {
|
|
619
|
+
await insertSubscriptionRecord(database, {
|
|
620
|
+
customerId: subCtx.customerId,
|
|
621
|
+
planFeatures: subCtx.normalizedPlan.includes,
|
|
622
|
+
productInternalId: subCtx.storedPlan.internalId,
|
|
623
|
+
providerId: subCtx.providerId,
|
|
624
|
+
startedAt: input.startedAt,
|
|
625
|
+
status: input.status
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
async function upsertProviderBackedTargetSubscription(database, subCtx, input, options) {
|
|
629
|
+
const providerData = {
|
|
630
|
+
subscriptionId: input.subscription.providerSubscriptionId,
|
|
631
|
+
subscriptionScheduleId: input.subscription.providerSubscriptionScheduleId ?? null
|
|
632
|
+
};
|
|
633
|
+
let subscriptionId = null;
|
|
634
|
+
if (options?.deferred) {
|
|
635
|
+
const existingSub = await getSubscriptionByProviderSubscriptionId(database, {
|
|
636
|
+
providerId: subCtx.providerId,
|
|
637
|
+
providerSubscriptionId: input.subscription.providerSubscriptionId
|
|
638
|
+
});
|
|
639
|
+
if (existingSub) {
|
|
640
|
+
subscriptionId = existingSub.id;
|
|
641
|
+
await syncSubscriptionBillingState(database, {
|
|
642
|
+
currentPeriodEndAt: input.subscription.currentPeriodEndAt ?? null,
|
|
643
|
+
currentPeriodStartAt: input.subscription.currentPeriodStartAt ?? null,
|
|
644
|
+
providerData,
|
|
645
|
+
status: input.subscription.status,
|
|
646
|
+
subscriptionId: existingSub.id
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (!subscriptionId) subscriptionId = (await insertSubscriptionRecord(database, {
|
|
651
|
+
currentPeriodEndAt: input.subscription.currentPeriodEndAt ?? null,
|
|
652
|
+
currentPeriodStartAt: input.subscription.currentPeriodStartAt ?? null,
|
|
653
|
+
customerId: subCtx.customerId,
|
|
654
|
+
planFeatures: subCtx.normalizedPlan.includes,
|
|
655
|
+
productInternalId: subCtx.storedPlan.internalId,
|
|
656
|
+
providerId: subCtx.providerId,
|
|
657
|
+
providerData,
|
|
658
|
+
startedAt: input.subscription.currentPeriodStartAt ?? /* @__PURE__ */ new Date(),
|
|
659
|
+
status: input.subscription.status
|
|
660
|
+
})).id;
|
|
661
|
+
if (input.invoice) await upsertInvoiceRecord(database, {
|
|
662
|
+
customerId: subCtx.customerId,
|
|
663
|
+
invoice: input.invoice,
|
|
664
|
+
providerId: subCtx.providerId,
|
|
665
|
+
subscriptionId
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
async function clearScheduledSubscriptionsInGroupIfNeeded(database, subCtx) {
|
|
669
|
+
if (!subCtx.storedPlan.group) return;
|
|
670
|
+
await clearScheduledSubscriptionsInGroup(database, {
|
|
671
|
+
customerId: subCtx.customerId,
|
|
672
|
+
group: subCtx.storedPlan.group
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
async function deleteScheduledSubscriptionsInGroupIfNeeded(database, subCtx) {
|
|
676
|
+
if (!subCtx.storedPlan.group) return;
|
|
677
|
+
await deleteScheduledSubscriptionsInGroup(database, {
|
|
678
|
+
customerId: subCtx.customerId,
|
|
679
|
+
group: subCtx.storedPlan.group
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
function addResetInterval(date, resetInterval) {
|
|
683
|
+
const next = new Date(date);
|
|
684
|
+
if (resetInterval === "day") next.setUTCDate(next.getUTCDate() + 1);
|
|
685
|
+
if (resetInterval === "week") next.setUTCDate(next.getUTCDate() + 7);
|
|
686
|
+
if (resetInterval === "month") {
|
|
687
|
+
const day = next.getUTCDate();
|
|
688
|
+
next.setUTCMonth(next.getUTCMonth() + 1);
|
|
689
|
+
if (next.getUTCDate() !== day) next.setUTCDate(0);
|
|
690
|
+
}
|
|
691
|
+
if (resetInterval === "year") {
|
|
692
|
+
const day = next.getUTCDate();
|
|
693
|
+
next.setUTCFullYear(next.getUTCFullYear() + 1);
|
|
694
|
+
if (next.getUTCDate() !== day) next.setUTCDate(0);
|
|
695
|
+
}
|
|
696
|
+
return next;
|
|
697
|
+
}
|
|
698
|
+
async function warnOnDuplicateActiveSubscriptionGroups(ctx, customerId) {
|
|
699
|
+
const activeSubscriptions = await ctx.database.select({
|
|
700
|
+
group: product.group,
|
|
701
|
+
planId: product.id,
|
|
702
|
+
subscriptionId: subscription.id
|
|
703
|
+
}).from(subscription).innerJoin(product, eq(subscription.productInternalId, product.internalId)).where(and(eq(subscription.customerId, customerId), inArray(subscription.status, [
|
|
704
|
+
"active",
|
|
705
|
+
"trialing",
|
|
706
|
+
"past_due"
|
|
707
|
+
]), or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`)));
|
|
708
|
+
const subscriptionsByGroup = /* @__PURE__ */ new Map();
|
|
709
|
+
for (const activeSubscription of activeSubscriptions) {
|
|
710
|
+
const currentGroup = subscriptionsByGroup.get(activeSubscription.group) ?? [];
|
|
711
|
+
currentGroup.push({
|
|
712
|
+
planId: activeSubscription.planId,
|
|
713
|
+
subscriptionId: activeSubscription.subscriptionId
|
|
714
|
+
});
|
|
715
|
+
subscriptionsByGroup.set(activeSubscription.group, currentGroup);
|
|
716
|
+
}
|
|
717
|
+
for (const [group, subscriptionsInGroup] of subscriptionsByGroup) {
|
|
718
|
+
if (subscriptionsInGroup.length < 2) continue;
|
|
719
|
+
ctx.logger.warn({
|
|
720
|
+
customerId,
|
|
721
|
+
group,
|
|
722
|
+
subscriptions: subscriptionsInGroup
|
|
723
|
+
}, "multiple active subscriptions detected in the same group");
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function mapJoinRowToSubscriptionWithCatalog(row) {
|
|
727
|
+
const providerMap = row.product.provider;
|
|
728
|
+
return {
|
|
729
|
+
...row.subscription,
|
|
730
|
+
planGroup: row.product.group,
|
|
731
|
+
planId: row.product.id,
|
|
732
|
+
planIsDefault: row.product.isDefault,
|
|
733
|
+
planName: row.product.name,
|
|
734
|
+
priceAmount: row.product.priceAmount,
|
|
735
|
+
priceInterval: row.product.priceInterval,
|
|
736
|
+
providerPriceId: Object.values(providerMap ?? {})[0]?.priceId ?? null
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
async function getActiveSubscriptionInGroup(database, input) {
|
|
740
|
+
const row = (await database.select().from(subscription).innerJoin(product, eq(subscription.productInternalId, product.internalId)).where(and(eq(subscription.customerId, input.customerId), eq(product.group, input.group), inArray(subscription.status, [
|
|
741
|
+
"active",
|
|
742
|
+
"trialing",
|
|
743
|
+
"past_due"
|
|
744
|
+
]), or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`))).orderBy(desc(subscription.createdAt)).limit(1))[0];
|
|
745
|
+
if (!row) return null;
|
|
746
|
+
return mapJoinRowToSubscriptionWithCatalog(row);
|
|
747
|
+
}
|
|
748
|
+
async function getActiveSubscriptionsInGroup(database, input) {
|
|
749
|
+
return (await database.select().from(subscription).innerJoin(product, eq(subscription.productInternalId, product.internalId)).where(and(eq(subscription.customerId, input.customerId), eq(product.group, input.group), inArray(subscription.status, [
|
|
750
|
+
"active",
|
|
751
|
+
"trialing",
|
|
752
|
+
"past_due"
|
|
753
|
+
]), or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`))).orderBy(desc(subscription.createdAt))).map(mapJoinRowToSubscriptionWithCatalog);
|
|
754
|
+
}
|
|
755
|
+
async function getScheduledSubscriptionsInGroup(database, input) {
|
|
756
|
+
return (await database.select().from(subscription).innerJoin(product, eq(subscription.productInternalId, product.internalId)).where(and(eq(subscription.customerId, input.customerId), eq(product.group, input.group), eq(subscription.status, "scheduled"), isNull(subscription.endedAt))).orderBy(desc(subscription.createdAt))).map(mapJoinRowToSubscriptionWithCatalog);
|
|
757
|
+
}
|
|
758
|
+
async function getSubscriptionByProviderSubscriptionId(database, input) {
|
|
759
|
+
return await database.query.subscription.findFirst({
|
|
760
|
+
orderBy: (s, { desc: d }) => [d(s.createdAt)],
|
|
761
|
+
where: and(eq(subscription.providerId, input.providerId), sql`${subscription.providerData}->>'subscriptionId' = ${input.providerSubscriptionId}`)
|
|
762
|
+
}) ?? null;
|
|
763
|
+
}
|
|
764
|
+
async function getSubscriptionById(database, subscriptionId) {
|
|
765
|
+
return await database.query.subscription.findFirst({ where: eq(subscription.id, subscriptionId) }) ?? null;
|
|
766
|
+
}
|
|
767
|
+
async function insertSubscriptionRecord(database, input) {
|
|
768
|
+
const now = /* @__PURE__ */ new Date();
|
|
769
|
+
const row = (await database.insert(subscription).values({
|
|
770
|
+
canceled: false,
|
|
771
|
+
cancelAtPeriodEnd: false,
|
|
772
|
+
canceledAt: null,
|
|
773
|
+
currentPeriodEndAt: input.currentPeriodEndAt ?? null,
|
|
774
|
+
currentPeriodStartAt: input.currentPeriodStartAt ?? null,
|
|
775
|
+
customerId: input.customerId,
|
|
776
|
+
endedAt: null,
|
|
777
|
+
id: generateId("sub"),
|
|
778
|
+
productInternalId: input.productInternalId,
|
|
779
|
+
providerData: input.providerData ?? null,
|
|
780
|
+
providerId: input.providerId ?? null,
|
|
781
|
+
quantity: 1,
|
|
782
|
+
scheduledProductId: input.scheduledProductId ?? null,
|
|
783
|
+
startedAt: input.startedAt ?? now,
|
|
784
|
+
status: input.status,
|
|
785
|
+
trialEndsAt: input.trialEndsAt ?? null
|
|
786
|
+
}).returning())[0];
|
|
787
|
+
if (!row) throw PayKitError.from("INTERNAL_SERVER_ERROR", PAYKIT_ERROR_CODES.SUBSCRIPTION_CREATE_FAILED);
|
|
788
|
+
if (input.planFeatures.length > 0) for (const planFeature of input.planFeatures) {
|
|
789
|
+
const isBoolean = planFeature.type === "boolean";
|
|
790
|
+
await database.insert(entitlement).values({
|
|
791
|
+
balance: isBoolean ? null : planFeature.limit ?? 0,
|
|
792
|
+
customerId: input.customerId,
|
|
793
|
+
featureId: planFeature.id,
|
|
794
|
+
id: generateId("ent"),
|
|
795
|
+
limit: isBoolean ? null : planFeature.limit ?? null,
|
|
796
|
+
nextResetAt: planFeature.resetInterval ? addResetInterval(now, planFeature.resetInterval) : null,
|
|
797
|
+
subscriptionId: row.id
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
return row;
|
|
801
|
+
}
|
|
802
|
+
async function endSubscriptions(database, subscriptionIds, input) {
|
|
803
|
+
if (subscriptionIds.length === 0) return;
|
|
804
|
+
await database.update(subscription).set({
|
|
805
|
+
canceled: input.canceled ?? false,
|
|
806
|
+
canceledAt: input.canceledAt ?? (input.canceled ? /* @__PURE__ */ new Date() : null),
|
|
807
|
+
endedAt: input.endedAt ?? /* @__PURE__ */ new Date(),
|
|
808
|
+
status: input.status,
|
|
809
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
810
|
+
}).where(inArray(subscription.id, [...subscriptionIds]));
|
|
811
|
+
}
|
|
812
|
+
async function clearScheduledSubscriptionsInGroup(database, input) {
|
|
813
|
+
const scheduled = await getScheduledSubscriptionsInGroup(database, input);
|
|
814
|
+
if (scheduled.length === 0) return;
|
|
815
|
+
await endSubscriptions(database, scheduled.map((item) => item.id), {
|
|
816
|
+
endedAt: /* @__PURE__ */ new Date(),
|
|
817
|
+
status: "canceled"
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
async function deleteSubscriptions(database, subscriptionIds) {
|
|
821
|
+
if (subscriptionIds.length === 0) return;
|
|
822
|
+
await database.delete(entitlement).where(inArray(entitlement.subscriptionId, [...subscriptionIds]));
|
|
823
|
+
await database.delete(subscription).where(inArray(subscription.id, [...subscriptionIds]));
|
|
824
|
+
}
|
|
825
|
+
async function deleteScheduledSubscriptionsInGroup(database, input) {
|
|
826
|
+
const scheduled = await getScheduledSubscriptionsInGroup(database, input);
|
|
827
|
+
if (scheduled.length === 0) return;
|
|
828
|
+
await deleteSubscriptions(database, scheduled.map((item) => item.id));
|
|
829
|
+
}
|
|
830
|
+
async function scheduleSubscriptionCancellation(database, input) {
|
|
831
|
+
const existing = await getSubscriptionById(database, input.subscriptionId);
|
|
832
|
+
if (!existing) return;
|
|
833
|
+
await database.update(subscription).set({
|
|
834
|
+
canceled: true,
|
|
835
|
+
canceledAt: input.canceledAt ?? /* @__PURE__ */ new Date(),
|
|
836
|
+
endedAt: input.currentPeriodEndAt ?? existing.endedAt,
|
|
837
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
838
|
+
}).where(eq(subscription.id, input.subscriptionId));
|
|
839
|
+
}
|
|
840
|
+
async function replaceSubscriptionSchedule(database, input) {
|
|
841
|
+
await database.update(subscription).set({
|
|
842
|
+
scheduledProductId: input.scheduledProductId ?? null,
|
|
843
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
844
|
+
}).where(eq(subscription.id, input.subscriptionId));
|
|
845
|
+
}
|
|
846
|
+
async function activateScheduledSubscription(database, input) {
|
|
847
|
+
await database.update(subscription).set({
|
|
848
|
+
canceled: false,
|
|
849
|
+
canceledAt: null,
|
|
850
|
+
currentPeriodEndAt: input.currentPeriodEndAt ?? null,
|
|
851
|
+
currentPeriodStartAt: input.currentPeriodStartAt ?? null,
|
|
852
|
+
endedAt: null,
|
|
853
|
+
providerData: input.providerData ?? null,
|
|
854
|
+
providerId: input.providerId,
|
|
855
|
+
startedAt: input.startedAt ?? /* @__PURE__ */ new Date(),
|
|
856
|
+
status: input.status,
|
|
857
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
858
|
+
}).where(eq(subscription.id, input.subscriptionId));
|
|
859
|
+
}
|
|
860
|
+
async function syncSubscriptionFromProvider(database, input) {
|
|
861
|
+
const endedAt = input.providerSubscription.cancelAtPeriodEnd === false ? null : input.providerSubscription.endedAt ?? null;
|
|
862
|
+
const canceledAt = input.providerSubscription.cancelAtPeriodEnd === false ? null : input.providerSubscription.canceledAt ?? null;
|
|
863
|
+
await database.update(subscription).set({
|
|
864
|
+
canceled: input.providerSubscription.cancelAtPeriodEnd,
|
|
865
|
+
cancelAtPeriodEnd: input.providerSubscription.cancelAtPeriodEnd,
|
|
866
|
+
canceledAt,
|
|
867
|
+
currentPeriodEndAt: input.providerSubscription.currentPeriodEndAt ?? null,
|
|
868
|
+
currentPeriodStartAt: input.providerSubscription.currentPeriodStartAt ?? null,
|
|
869
|
+
endedAt,
|
|
870
|
+
status: input.providerSubscription.status,
|
|
871
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
872
|
+
}).where(eq(subscription.id, input.subscriptionId));
|
|
873
|
+
}
|
|
874
|
+
async function syncSubscriptionBillingState(database, input) {
|
|
875
|
+
const existing = await database.query.subscription.findFirst({ where: eq(subscription.id, input.subscriptionId) });
|
|
876
|
+
if (!existing) return;
|
|
877
|
+
await database.update(subscription).set({
|
|
878
|
+
currentPeriodEndAt: input.currentPeriodEndAt !== void 0 ? input.currentPeriodEndAt : existing.currentPeriodEndAt,
|
|
879
|
+
currentPeriodStartAt: input.currentPeriodStartAt !== void 0 ? input.currentPeriodStartAt : existing.currentPeriodStartAt,
|
|
880
|
+
providerData: input.providerData !== void 0 ? input.providerData : existing.providerData,
|
|
881
|
+
startedAt: input.startedAt !== void 0 ? input.startedAt : existing.startedAt,
|
|
882
|
+
status: input.status ?? existing.status,
|
|
883
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
884
|
+
}).where(eq(subscription.id, input.subscriptionId));
|
|
885
|
+
}
|
|
886
|
+
async function getCurrentSubscriptions(database, customerId) {
|
|
887
|
+
return database.select({
|
|
888
|
+
currentPeriodEndAt: subscription.currentPeriodEndAt,
|
|
889
|
+
endedAt: subscription.endedAt,
|
|
890
|
+
id: product.id,
|
|
891
|
+
startedAt: subscription.startedAt,
|
|
892
|
+
status: subscription.status
|
|
893
|
+
}).from(subscription).innerJoin(product, eq(subscription.productInternalId, product.internalId)).where(and(eq(subscription.customerId, customerId), or(isNull(subscription.endedAt), sql`${subscription.endedAt} > now()`, eq(subscription.status, "scheduled")))).orderBy(desc(subscription.createdAt));
|
|
894
|
+
}
|
|
895
|
+
//#endregion
|
|
896
|
+
export { applySubscriptionWebhookAction, getActiveSubscriptionInGroup, getCurrentSubscriptions, getScheduledSubscriptionsInGroup, handleSubscribeCheckoutCompleted, insertSubscriptionRecord, prepareSubscribeCheckoutCompleted, subscribeToPlan };
|