@fragno-dev/stripe 2.0.0 → 2.0.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/dist/browser/client/react.d.ts +5 -5
- package/dist/browser/client/react.d.ts.map +1 -1
- package/dist/browser/client/react.js +105 -13
- package/dist/browser/client/react.js.map +1 -1
- package/dist/browser/client/solid.d.ts +5 -5
- package/dist/browser/client/solid.d.ts.map +1 -1
- package/dist/browser/client/solid.js +25 -11
- package/dist/browser/client/solid.js.map +1 -1
- package/dist/browser/client/svelte.d.ts +5 -5
- package/dist/browser/client/svelte.d.ts.map +1 -1
- package/dist/browser/client/svelte.js +11 -3
- package/dist/browser/client/svelte.js.map +1 -1
- package/dist/browser/client/vanilla.d.ts +5 -5
- package/dist/browser/client/vanilla.d.ts.map +1 -1
- package/dist/browser/client/vanilla.js +21 -1
- package/dist/browser/client/vanilla.js.map +1 -1
- package/dist/browser/client/vue.d.ts +1 -1
- package/dist/browser/client/vue.d.ts.map +1 -1
- package/dist/browser/client/vue.js +19 -11
- package/dist/browser/client/vue.js.map +1 -1
- package/dist/browser/index.d.ts +31 -67
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/{src-D1l5aeYY.js → src-D6VWnvEs.js} +496 -450
- package/dist/browser/src-D6VWnvEs.js.map +1 -0
- package/dist/node/index.d.ts +32 -68
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +511 -461
- package/dist/node/index.js.map +1 -1
- package/package.json +28 -49
- package/dist/browser/src-D1l5aeYY.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
package/dist/node/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createClientBuilder } from "@fragno-dev/core/client";
|
|
2
|
-
import Stripe from "stripe";
|
|
3
|
-
import { column, idColumn, schema } from "@fragno-dev/db/schema";
|
|
4
2
|
import { defineFragment, defineRoutes, instantiate } from "@fragno-dev/core";
|
|
3
|
+
import Stripe from "stripe";
|
|
5
4
|
import { withDatabase } from "@fragno-dev/db";
|
|
5
|
+
import { column, idColumn, schema } from "@fragno-dev/db/schema";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { FragnoApiError } from "@fragno-dev/core/api";
|
|
8
8
|
|
|
@@ -59,81 +59,89 @@ const defaultLogger = {
|
|
|
59
59
|
log: (...data) => console.log(LOG_PREFIX, ...data)
|
|
60
60
|
};
|
|
61
61
|
const asExternalSubscription = (subscription) => ({
|
|
62
|
-
...subscription,
|
|
63
62
|
id: subscription.id.externalId,
|
|
64
|
-
|
|
63
|
+
referenceId: subscription.referenceId,
|
|
64
|
+
stripePriceId: subscription.stripePriceId,
|
|
65
|
+
stripeCustomerId: subscription.stripeCustomerId,
|
|
66
|
+
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
|
67
|
+
status: subscription.status,
|
|
68
|
+
periodStart: subscription.periodStart,
|
|
69
|
+
periodEnd: subscription.periodEnd,
|
|
70
|
+
trialStart: subscription.trialStart,
|
|
71
|
+
trialEnd: subscription.trialEnd,
|
|
72
|
+
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
73
|
+
cancelAt: subscription.cancelAt,
|
|
74
|
+
seats: subscription.seats,
|
|
75
|
+
createdAt: subscription.createdAt,
|
|
76
|
+
updatedAt: subscription.updatedAt
|
|
65
77
|
});
|
|
66
|
-
function createStripeServices(deps,
|
|
67
|
-
|
|
78
|
+
function createStripeServices(deps, defineService) {
|
|
79
|
+
return defineService({
|
|
68
80
|
getStripeClient() {
|
|
69
81
|
return deps.stripe;
|
|
70
82
|
},
|
|
71
|
-
createSubscription
|
|
72
|
-
return (
|
|
83
|
+
createSubscription(data) {
|
|
84
|
+
return this.serviceTx(stripeSchema).mutate(({ uow }) => {
|
|
85
|
+
const created = uow.create("subscription", data);
|
|
86
|
+
return created.externalId;
|
|
87
|
+
}).build();
|
|
73
88
|
},
|
|
74
|
-
updateSubscription
|
|
75
|
-
|
|
89
|
+
updateSubscription(id, data) {
|
|
90
|
+
return this.serviceTx(stripeSchema).mutate(({ uow }) => uow.update("subscription", id, (b) => b.set({
|
|
76
91
|
...data,
|
|
77
92
|
updatedAt: new Date()
|
|
78
|
-
}));
|
|
93
|
+
}))).build();
|
|
79
94
|
},
|
|
80
|
-
getSubscriptionByStripeId
|
|
81
|
-
|
|
82
|
-
if (!result) return null;
|
|
83
|
-
return asExternalSubscription(result);
|
|
95
|
+
getSubscriptionByStripeId(stripeSubscriptionId) {
|
|
96
|
+
return this.serviceTx(stripeSchema).retrieve((uow) => uow.findFirst("subscription", (b) => b.whereIndex("idx_stripe_subscription_id", (eb) => eb("stripeSubscriptionId", "=", stripeSubscriptionId)))).transformRetrieve(([result]) => result ? asExternalSubscription(result) : null).build();
|
|
84
97
|
},
|
|
85
|
-
getSubscriptionsByStripeCustomerId
|
|
86
|
-
return (
|
|
98
|
+
getSubscriptionsByStripeCustomerId(stripeCustomerId) {
|
|
99
|
+
return this.serviceTx(stripeSchema).retrieve((uow) => uow.find("subscription", (b) => b.whereIndex("idx_stripe_customer_id", (eb) => eb("stripeCustomerId", "=", stripeCustomerId)))).transformRetrieve(([result]) => result.map(asExternalSubscription)).build();
|
|
87
100
|
},
|
|
88
|
-
getSubscriptionById
|
|
89
|
-
|
|
90
|
-
if (!result) return null;
|
|
91
|
-
return asExternalSubscription(result);
|
|
101
|
+
getSubscriptionById(id) {
|
|
102
|
+
return this.serviceTx(stripeSchema).retrieve((uow) => uow.findFirst("subscription", (b) => b.whereIndex("primary", (eb) => eb("id", "=", id)))).transformRetrieve(([result]) => result ? asExternalSubscription(result) : null).build();
|
|
92
103
|
},
|
|
93
|
-
getSubscriptionsByReferenceId
|
|
94
|
-
|
|
95
|
-
if (result.length == 0) return [];
|
|
96
|
-
return result.map(asExternalSubscription);
|
|
104
|
+
getSubscriptionsByReferenceId(referenceId) {
|
|
105
|
+
return this.serviceTx(stripeSchema).retrieve((uow) => uow.find("subscription", (b) => b.whereIndex("idx_reference_id", (eb) => eb("referenceId", "=", referenceId)))).transformRetrieve(([result]) => result.map(asExternalSubscription)).build();
|
|
97
106
|
},
|
|
98
|
-
deleteSubscription
|
|
99
|
-
|
|
107
|
+
deleteSubscription(id) {
|
|
108
|
+
return this.serviceTx(stripeSchema).mutate(({ uow }) => uow.delete("subscription", id)).build();
|
|
100
109
|
},
|
|
101
|
-
deleteSubscriptionsByReferenceId
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
deleteSubscriptionsByReferenceId(referenceId) {
|
|
111
|
+
return this.serviceTx(stripeSchema).retrieve((uow) => uow.find("subscription", (b) => b.whereIndex("idx_reference_id", (eb) => eb("referenceId", "=", referenceId)))).mutate(({ uow, retrieveResult: [subscriptions] }) => {
|
|
112
|
+
subscriptions.forEach((sub) => {
|
|
113
|
+
uow.delete("subscription", sub.id);
|
|
114
|
+
});
|
|
115
|
+
return { success: true };
|
|
116
|
+
}).build();
|
|
106
117
|
},
|
|
107
|
-
getAllSubscriptions
|
|
108
|
-
return (
|
|
118
|
+
getAllSubscriptions() {
|
|
119
|
+
return this.serviceTx(stripeSchema).retrieve((uow) => uow.find("subscription", (b) => b.whereIndex("primary"))).transformRetrieve(([result]) => result.map(asExternalSubscription)).build();
|
|
109
120
|
},
|
|
110
|
-
syncStripeSubscriptions
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
});
|
|
115
|
-
if (stripeSubscriptions.data.length === 0) {
|
|
116
|
-
await services.deleteSubscriptionsByReferenceId(referenceId);
|
|
117
|
-
return { success: true };
|
|
118
|
-
}
|
|
119
|
-
const uow = db.createUnitOfWork().find("subscription", (b) => b.whereIndex("idx_reference_id", (eb) => eb("referenceId", "=", referenceId)));
|
|
120
|
-
const [existingSubscriptions] = await uow.executeRetrieve();
|
|
121
|
-
for (const stripeSubscription of stripeSubscriptions.data) {
|
|
122
|
-
const existingSubscription = existingSubscriptions.find((sub) => sub.stripeSubscriptionId === stripeSubscription.id);
|
|
123
|
-
if (existingSubscription) uow.update("subscription", existingSubscription.id, (b) => b.set({
|
|
124
|
-
...stripeSubscriptionToInternalSubscription(stripeSubscription),
|
|
125
|
-
updatedAt: new Date()
|
|
126
|
-
}).check());
|
|
127
|
-
else uow.create("subscription", {
|
|
128
|
-
...stripeSubscriptionToInternalSubscription(stripeSubscription),
|
|
129
|
-
referenceId: referenceId ?? null,
|
|
130
|
-
updatedAt: new Date()
|
|
121
|
+
syncStripeSubscriptions(referenceId, _stripeCustomerId, stripeSubscriptions) {
|
|
122
|
+
if (stripeSubscriptions.length === 0) return this.serviceTx(stripeSchema).retrieve((uow) => uow.find("subscription", (b) => b.whereIndex("idx_reference_id", (eb) => eb("referenceId", "=", referenceId)))).mutate(({ uow, retrieveResult: [subscriptions] }) => {
|
|
123
|
+
subscriptions.forEach((sub) => {
|
|
124
|
+
uow.delete("subscription", sub.id);
|
|
131
125
|
});
|
|
132
|
-
|
|
133
|
-
|
|
126
|
+
return { success: true };
|
|
127
|
+
}).build();
|
|
128
|
+
return this.serviceTx(stripeSchema).retrieve((uow) => uow.find("subscription", (b) => b.whereIndex("idx_reference_id", (eb) => eb("referenceId", "=", referenceId)))).mutate(({ uow, retrieveResult: [existingSubscriptions] }) => {
|
|
129
|
+
for (const stripeSubscription of stripeSubscriptions) {
|
|
130
|
+
const existingSubscription = existingSubscriptions.find((sub) => sub.stripeSubscriptionId === stripeSubscription.id);
|
|
131
|
+
if (existingSubscription) uow.update("subscription", existingSubscription.id, (b) => b.set({
|
|
132
|
+
...stripeSubscriptionToInternalSubscription(stripeSubscription),
|
|
133
|
+
updatedAt: new Date()
|
|
134
|
+
}).check());
|
|
135
|
+
else uow.create("subscription", {
|
|
136
|
+
...stripeSubscriptionToInternalSubscription(stripeSubscription),
|
|
137
|
+
referenceId: referenceId ?? null,
|
|
138
|
+
updatedAt: new Date()
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return { success: true };
|
|
142
|
+
}).build();
|
|
134
143
|
}
|
|
135
|
-
};
|
|
136
|
-
return services;
|
|
144
|
+
});
|
|
137
145
|
}
|
|
138
146
|
const stripeFragmentDefinition = defineFragment("stripe").extend(withDatabase(stripeSchema)).withDependencies(({ config }) => {
|
|
139
147
|
const stripeClient = new Stripe(config.stripeSecretKey, config.stripeClientOptions ?? {});
|
|
@@ -141,233 +149,7 @@ const stripeFragmentDefinition = defineFragment("stripe").extend(withDatabase(st
|
|
|
141
149
|
stripe: stripeClient,
|
|
142
150
|
log: config.logger ? config.logger : defaultLogger
|
|
143
151
|
};
|
|
144
|
-
}).providesBaseService(({ deps }) =>
|
|
145
|
-
return { ...createStripeServices(deps, deps.db) };
|
|
146
|
-
}).build();
|
|
147
|
-
|
|
148
|
-
//#endregion
|
|
149
|
-
//#region src/webhook/handlers.ts
|
|
150
|
-
/**
|
|
151
|
-
* Event Handler for checkout.session.completed
|
|
152
|
-
*
|
|
153
|
-
* This handler is ONLY for subscription checkout sessions.
|
|
154
|
-
* Occurs when a Checkout Session has been successfully completed.
|
|
155
|
-
*/
|
|
156
|
-
async function checkoutSessionCompletedHandler({ event, services, deps }) {
|
|
157
|
-
const checkoutSession = event.data.object;
|
|
158
|
-
if (checkoutSession.mode !== "subscription") {
|
|
159
|
-
deps.log.info(`Not handling checkout session with mode ${checkoutSession.mode}: ${event.id}`);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
const subscriptionId = checkoutSession.subscription;
|
|
163
|
-
if (typeof subscriptionId !== "string") {
|
|
164
|
-
deps.log.error("No subscription ID in checkout session");
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
const stripeSubscription = await deps.stripe.subscriptions.retrieve(subscriptionId);
|
|
168
|
-
const customerId = getId(stripeSubscription.customer);
|
|
169
|
-
const firstItem = stripeSubscription.items.data[0];
|
|
170
|
-
if (!firstItem) {
|
|
171
|
-
deps.log.error("No subscription items found");
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
const referenceId = checkoutSession.metadata?.["referenceId"] || checkoutSession.client_reference_id;
|
|
175
|
-
const existingSubscriptionId = checkoutSession.metadata?.["subscriptionId"];
|
|
176
|
-
const subscriptionData = {
|
|
177
|
-
...stripeSubscriptionToInternalSubscription(stripeSubscription),
|
|
178
|
-
referenceId: referenceId ?? null
|
|
179
|
-
};
|
|
180
|
-
if (existingSubscriptionId) {
|
|
181
|
-
await services.updateSubscription(existingSubscriptionId, subscriptionData);
|
|
182
|
-
deps.log.info(`Updated subscription ${existingSubscriptionId} for customer ${customerId}`);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
const existing = await services.getSubscriptionByStripeId(stripeSubscription.id);
|
|
186
|
-
if (existing) {
|
|
187
|
-
deps.log.info(`Subscription already exists for Stripe ID ${stripeSubscription.id}`);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
const createdSubscriptionId = await services.createSubscription(subscriptionData);
|
|
191
|
-
deps.log.info(`Created subscription ${createdSubscriptionId} for customer ${customerId} (Stripe ID: ${stripeSubscription.id})`);
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Event Handler for customer.subscription.paused
|
|
195
|
-
*
|
|
196
|
-
* Occurs whenever a customer’s subscription is paused. Only applies when
|
|
197
|
-
* subscriptions enter status=paused, not when payment collection is paused.
|
|
198
|
-
*
|
|
199
|
-
* PAUSED status: The subscription has ended its trial period without a default
|
|
200
|
-
* payment method and the trial_settings.end_behavior.missing_payment_method is set
|
|
201
|
-
* to pause. Invoices are no longer created for the subscription. After attaching a
|
|
202
|
-
* default payment method to the customer, you can resume the subscription.
|
|
203
|
-
*/
|
|
204
|
-
async function customerSubscriptionPausedHandler(args) {
|
|
205
|
-
return customerSubscriptionUpdatedHandler(args);
|
|
206
|
-
}
|
|
207
|
-
/**
|
|
208
|
-
* Event Handler for customer.subscription.pending_update_applied
|
|
209
|
-
*
|
|
210
|
-
* Occurs whenever a customer’s subscription’s pending update is applied,
|
|
211
|
-
* and the subscription is updated.
|
|
212
|
-
*/
|
|
213
|
-
async function customerSubscriptionPendingUpdateAppliedHandler(args) {
|
|
214
|
-
return customerSubscriptionUpdatedHandler(args);
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Event Handler for customer.subscription.pending_update_expired
|
|
218
|
-
*
|
|
219
|
-
* Occurs whenever a customer’s subscription’s pending update expires
|
|
220
|
-
* before the related invoice is paid.
|
|
221
|
-
*/
|
|
222
|
-
async function customerSubscriptionPendingUpdateExpiredHandler(args) {
|
|
223
|
-
return customerSubscriptionUpdatedHandler(args);
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* Event Handler for customer.subscription.resumed
|
|
227
|
-
*
|
|
228
|
-
* Occurs whenever a customer’s subscription is no longer paused.
|
|
229
|
-
* Only applies when a status=paused subscription is resumed, not when payment
|
|
230
|
-
* collection is resumed.
|
|
231
|
-
*/
|
|
232
|
-
async function customerSubscriptionResumedHandler(args) {
|
|
233
|
-
return customerSubscriptionUpdatedHandler(args);
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Event Handler for customer.subscription.trial_will_end
|
|
237
|
-
*
|
|
238
|
-
* Occurs three days before a subscription's trial period is scheduled to end,
|
|
239
|
-
* or when a trial is ended immediately (using trial_end=now). This event allows
|
|
240
|
-
* you to send reminders or take action before the trial expires.
|
|
241
|
-
*/
|
|
242
|
-
async function customerSubscriptionTrialWillEndHandler(args) {
|
|
243
|
-
return customerSubscriptionUpdatedHandler(args);
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* Event Handler for customer.subscription.updated
|
|
247
|
-
*
|
|
248
|
-
* Occurs whenever a subscription changes (e.g., switching from one plan to another,
|
|
249
|
-
* or changing the status from trial to active).
|
|
250
|
-
*/
|
|
251
|
-
async function customerSubscriptionUpdatedHandler({ event, services, deps }) {
|
|
252
|
-
deps.log.info(`Processing ${event.type}: ${event.id}`);
|
|
253
|
-
const stripeSubscription = event.data.object;
|
|
254
|
-
const firstItem = stripeSubscription.items?.data?.[0];
|
|
255
|
-
if (!firstItem) throw new FragnoApiError({
|
|
256
|
-
message: "Subscription contains no items",
|
|
257
|
-
code: "EMPTY_SUBSCRIPTION"
|
|
258
|
-
}, 400);
|
|
259
|
-
const customerId = getId(stripeSubscription.customer);
|
|
260
|
-
let subscription = await services.getSubscriptionByStripeId(stripeSubscription.id);
|
|
261
|
-
if (!subscription) {
|
|
262
|
-
const customerSubs = await services.getSubscriptionsByStripeCustomerId(customerId);
|
|
263
|
-
if (customerSubs.length > 1) {
|
|
264
|
-
subscription = customerSubs.find((sub) => sub.status === "active" || sub.status === "trialing") ?? null;
|
|
265
|
-
if (!subscription) {
|
|
266
|
-
deps.log.warn(`Multiple subscriptions found for customer ${customerId} but none active or trialing`);
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
} else subscription = customerSubs[0] ?? null;
|
|
270
|
-
}
|
|
271
|
-
if (!subscription) {
|
|
272
|
-
deps.log.warn(`No subscription found for Stripe ID ${stripeSubscription.id}, creating new record`);
|
|
273
|
-
const createdSubscriptionId = await services.createSubscription({
|
|
274
|
-
...stripeSubscriptionToInternalSubscription(stripeSubscription),
|
|
275
|
-
referenceId: stripeSubscription.metadata?.["referenceId"] ?? null
|
|
276
|
-
});
|
|
277
|
-
deps.log.info(`Created subscription ${createdSubscriptionId} for customer ${customerId} (Stripe ID: ${stripeSubscription.id})`);
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
await services.updateSubscription(subscription.id, stripeSubscriptionToInternalSubscription(stripeSubscription));
|
|
281
|
-
deps.log.info(`Updated subscription ${subscription.id} (Stripe ID: ${stripeSubscription.id})`);
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Event Handler for customer.subscription.deleted
|
|
285
|
-
*
|
|
286
|
-
* Occurs whenever a customer's subscription ends.
|
|
287
|
-
*/
|
|
288
|
-
async function customerSubscriptionDeletedHandler({ event, services, deps }) {
|
|
289
|
-
deps.log.info(`Processing customer.subscription.deleted: ${event.id}`);
|
|
290
|
-
const stripeSubscription = event.data.object;
|
|
291
|
-
const subscription = await services.getSubscriptionByStripeId(stripeSubscription.id);
|
|
292
|
-
if (!subscription) {
|
|
293
|
-
deps.log.warn(`No subscription found for Stripe ID ${stripeSubscription.id}`);
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
await services.updateSubscription(subscription.id, { status: "canceled" });
|
|
297
|
-
deps.log.info(`Marked subscription ${subscription.id} as canceled (Stripe ID: ${stripeSubscription.id})`);
|
|
298
|
-
}
|
|
299
|
-
const eventToHandler = {
|
|
300
|
-
"checkout.session.completed": checkoutSessionCompletedHandler,
|
|
301
|
-
"customer.subscription.deleted": customerSubscriptionDeletedHandler,
|
|
302
|
-
"customer.subscription.created": customerSubscriptionUpdatedHandler,
|
|
303
|
-
"customer.subscription.updated": customerSubscriptionUpdatedHandler,
|
|
304
|
-
"customer.subscription.paused": customerSubscriptionPausedHandler,
|
|
305
|
-
"customer.subscription.pending_update_applied": customerSubscriptionPendingUpdateAppliedHandler,
|
|
306
|
-
"customer.subscription.pending_update_expired": customerSubscriptionPendingUpdateExpiredHandler,
|
|
307
|
-
"customer.subscription.resumed": customerSubscriptionResumedHandler,
|
|
308
|
-
"customer.subscription.trial_will_end": customerSubscriptionTrialWillEndHandler
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
//#endregion
|
|
312
|
-
//#region src/routes/webhooks.ts
|
|
313
|
-
const webhookRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ config, deps, services, defineRoute }) => {
|
|
314
|
-
return [defineRoute({
|
|
315
|
-
method: "POST",
|
|
316
|
-
path: "/webhook",
|
|
317
|
-
outputSchema: z.object({ success: z.boolean() }),
|
|
318
|
-
errorCodes: [
|
|
319
|
-
"MISSING_SIGNATURE",
|
|
320
|
-
"WEBHOOK_SIGNATURE_INVALID",
|
|
321
|
-
"WEBHOOK_ERROR"
|
|
322
|
-
],
|
|
323
|
-
handler: async ({ headers, rawBody }, { json, error }) => {
|
|
324
|
-
const signature = headers.get("stripe-signature");
|
|
325
|
-
if (!signature) return error({
|
|
326
|
-
message: "Missing stripe-signature header",
|
|
327
|
-
code: "MISSING_SIGNATURE"
|
|
328
|
-
}, 400);
|
|
329
|
-
if (!rawBody) return error({
|
|
330
|
-
message: "Missing request body for webhook verification",
|
|
331
|
-
code: "WEBHOOK_ERROR"
|
|
332
|
-
}, 400);
|
|
333
|
-
let event;
|
|
334
|
-
try {
|
|
335
|
-
if (typeof deps.stripe.webhooks.constructEventAsync === "function") event = await deps.stripe.webhooks.constructEventAsync(rawBody, signature, config.webhookSecret);
|
|
336
|
-
else event = deps.stripe.webhooks.constructEvent(rawBody, signature, config.webhookSecret);
|
|
337
|
-
} catch (err) {
|
|
338
|
-
if (err instanceof Stripe.errors.StripeSignatureVerificationError) return error({
|
|
339
|
-
message: `Webhook signature verification failed`,
|
|
340
|
-
code: "WEBHOOK_SIGNATURE_INVALID"
|
|
341
|
-
}, 400);
|
|
342
|
-
throw err;
|
|
343
|
-
}
|
|
344
|
-
if (!event) return error({
|
|
345
|
-
message: "Failed to construct event",
|
|
346
|
-
code: "WEBHOOK_ERROR"
|
|
347
|
-
}, 400);
|
|
348
|
-
if (config.onEvent) {
|
|
349
|
-
deps.log.info("Running user callback event");
|
|
350
|
-
await config.onEvent({
|
|
351
|
-
event,
|
|
352
|
-
stripeClient: deps.stripe
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
const eventHandler = eventToHandler[event.type];
|
|
356
|
-
if (!eventHandler) {
|
|
357
|
-
deps.log.info(`Webhook event ${event.type}: ${event.id} ignored`);
|
|
358
|
-
return json({ success: true });
|
|
359
|
-
}
|
|
360
|
-
deps.log.info(`Executing event handler for ${event.type}: ${event.id}`);
|
|
361
|
-
await eventHandler({
|
|
362
|
-
event,
|
|
363
|
-
services,
|
|
364
|
-
deps,
|
|
365
|
-
config
|
|
366
|
-
});
|
|
367
|
-
return json({ success: true });
|
|
368
|
-
}
|
|
369
|
-
})];
|
|
370
|
-
});
|
|
152
|
+
}).providesBaseService(({ deps, defineService }) => createStripeServices(deps, defineService)).build();
|
|
371
153
|
|
|
372
154
|
//#endregion
|
|
373
155
|
//#region src/models/customers.ts
|
|
@@ -485,57 +267,191 @@ const customersRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({
|
|
|
485
267
|
});
|
|
486
268
|
|
|
487
269
|
//#endregion
|
|
488
|
-
//#region src/models/
|
|
489
|
-
const
|
|
270
|
+
//#region src/models/prices.ts
|
|
271
|
+
const PriceResponseSchema = z.object({
|
|
490
272
|
id: z.string(),
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
//#
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
273
|
+
object: z.literal("price"),
|
|
274
|
+
active: z.boolean(),
|
|
275
|
+
billing_scheme: z.string(),
|
|
276
|
+
created: z.number(),
|
|
277
|
+
currency: z.string(),
|
|
278
|
+
custom_unit_amount: z.any().nullable().optional(),
|
|
279
|
+
deleted: z.void().optional(),
|
|
280
|
+
livemode: z.boolean(),
|
|
281
|
+
lookup_key: z.string().nullable().optional(),
|
|
282
|
+
metadata: z.any(),
|
|
283
|
+
nickname: z.string().nullable().optional(),
|
|
284
|
+
product: z.union([z.string(), z.any()]),
|
|
285
|
+
recurring: z.object({
|
|
286
|
+
aggregate_usage: z.string().nullable().optional(),
|
|
287
|
+
interval: z.enum([
|
|
288
|
+
"day",
|
|
289
|
+
"week",
|
|
290
|
+
"month",
|
|
291
|
+
"year"
|
|
292
|
+
]),
|
|
293
|
+
interval_count: z.number(),
|
|
294
|
+
meter: z.string().nullable().optional(),
|
|
295
|
+
trial_period_days: z.number().nullable().optional(),
|
|
296
|
+
usage_type: z.string()
|
|
297
|
+
}).nullable().optional(),
|
|
298
|
+
tax_behavior: z.string().nullable().optional(),
|
|
299
|
+
tiers_mode: z.string().nullable().optional(),
|
|
300
|
+
transform_quantity: z.any().nullable().optional(),
|
|
301
|
+
type: z.enum(["one_time", "recurring"]),
|
|
302
|
+
unit_amount: z.number().nullable().optional(),
|
|
303
|
+
unit_amount_decimal: z.string().nullable().optional()
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/routes/prices.ts
|
|
308
|
+
const pricesRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ deps, config, defineRoute }) => {
|
|
309
|
+
return [defineRoute({
|
|
310
|
+
method: "GET",
|
|
311
|
+
path: "/admin/products/:productId/prices",
|
|
312
|
+
inputSchema: z.object({
|
|
313
|
+
limit: z.number().int().positive().max(100).optional().default(50).describe("Number of prices to return (max 100)"),
|
|
314
|
+
startingAfter: z.string().optional().describe("Price ID to start after for pagination")
|
|
315
|
+
}),
|
|
316
|
+
outputSchema: z.object({
|
|
317
|
+
prices: z.array(PriceResponseSchema),
|
|
318
|
+
hasMore: z.boolean().describe("Whether there are more items to fetch")
|
|
319
|
+
}),
|
|
320
|
+
handler: async ({ pathParams, query }, { json, error }) => {
|
|
321
|
+
if (!config.enableAdminRoutes) return error({
|
|
322
|
+
message: "Unauthorized",
|
|
323
|
+
code: "UNAUTHORIZED"
|
|
324
|
+
}, 401);
|
|
325
|
+
const { productId } = pathParams;
|
|
326
|
+
const limit = Number(query.get("limit")) || void 0;
|
|
327
|
+
const startingAfter = query.get("startingAfter") || void 0;
|
|
328
|
+
const prices = await deps.stripe.prices.list({
|
|
329
|
+
product: productId,
|
|
330
|
+
limit,
|
|
331
|
+
starting_after: startingAfter
|
|
332
|
+
});
|
|
333
|
+
return json({
|
|
334
|
+
prices: prices.data,
|
|
335
|
+
hasMore: prices.has_more
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
})];
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/models/products.ts
|
|
343
|
+
const ProductResponseSchema = z.object({
|
|
344
|
+
id: z.string(),
|
|
345
|
+
object: z.literal("product"),
|
|
346
|
+
active: z.boolean(),
|
|
347
|
+
created: z.number(),
|
|
348
|
+
default_price: z.union([z.string(), z.any()]).nullable().optional(),
|
|
349
|
+
deleted: z.void().optional(),
|
|
350
|
+
description: z.string().nullable(),
|
|
351
|
+
images: z.array(z.string()),
|
|
352
|
+
livemode: z.boolean(),
|
|
353
|
+
marketing_features: z.array(z.any()),
|
|
354
|
+
metadata: z.any(),
|
|
355
|
+
name: z.string(),
|
|
356
|
+
package_dimensions: z.any().nullable(),
|
|
357
|
+
shippable: z.boolean().nullable(),
|
|
358
|
+
statement_descriptor: z.string().nullable().optional(),
|
|
359
|
+
tax_code: z.union([z.string(), z.any()]).nullable(),
|
|
360
|
+
type: z.string(),
|
|
361
|
+
unit_label: z.string().nullable().optional(),
|
|
362
|
+
updated: z.number(),
|
|
363
|
+
url: z.string().nullable()
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/routes/products.ts
|
|
368
|
+
const productsRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ deps, config, defineRoute }) => {
|
|
369
|
+
return [defineRoute({
|
|
370
|
+
method: "GET",
|
|
371
|
+
path: "/admin/products",
|
|
372
|
+
inputSchema: z.object({
|
|
373
|
+
limit: z.number().int().positive().max(100).optional().default(50).describe("Number of products to return (max 100)"),
|
|
374
|
+
startingAfter: z.string().optional().describe("Product ID to start after for pagination")
|
|
375
|
+
}),
|
|
376
|
+
outputSchema: z.object({
|
|
377
|
+
products: z.array(ProductResponseSchema),
|
|
378
|
+
hasMore: z.boolean().describe("Whether there are more items to fetch")
|
|
379
|
+
}),
|
|
380
|
+
handler: async ({ query }, { json, error }) => {
|
|
381
|
+
if (!config.enableAdminRoutes) return error({
|
|
382
|
+
message: "Unauthorized",
|
|
383
|
+
code: "UNAUTHORIZED"
|
|
384
|
+
}, 401);
|
|
385
|
+
const limit = Number(query.get("limit")) || void 0;
|
|
386
|
+
const startingAfter = query.get("startingAfter") || void 0;
|
|
387
|
+
const products = await deps.stripe.products.list({
|
|
388
|
+
limit,
|
|
389
|
+
starting_after: startingAfter
|
|
390
|
+
});
|
|
391
|
+
return json({
|
|
392
|
+
products: products.data,
|
|
393
|
+
hasMore: products.has_more
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
})];
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/models/subscriptions.ts
|
|
401
|
+
const SubscriptionReponseSchema = z.object({
|
|
402
|
+
id: z.string(),
|
|
403
|
+
stripeSubscriptionId: z.string(),
|
|
404
|
+
stripeCustomerId: z.string(),
|
|
405
|
+
stripePriceId: z.string(),
|
|
406
|
+
referenceId: z.string().nullable(),
|
|
407
|
+
status: z.enum([
|
|
408
|
+
"active",
|
|
409
|
+
"canceled",
|
|
410
|
+
"incomplete",
|
|
411
|
+
"incomplete_expired",
|
|
412
|
+
"past_due",
|
|
413
|
+
"paused",
|
|
414
|
+
"trialing",
|
|
415
|
+
"unpaid"
|
|
416
|
+
]),
|
|
417
|
+
periodStart: z.date().nullable(),
|
|
418
|
+
periodEnd: z.date().nullable(),
|
|
419
|
+
trialStart: z.date().nullable(),
|
|
420
|
+
trialEnd: z.date().nullable(),
|
|
421
|
+
cancelAtPeriodEnd: z.boolean().nullable(),
|
|
422
|
+
cancelAt: z.date().nullable(),
|
|
423
|
+
seats: z.number().nullable(),
|
|
424
|
+
createdAt: z.date(),
|
|
425
|
+
updatedAt: z.date()
|
|
426
|
+
});
|
|
427
|
+
const SubscriptionUpgradeRequestSchema = z.object({
|
|
428
|
+
priceId: z.string().describe("Stripe price ID to subscribe/upgrade to"),
|
|
429
|
+
quantity: z.number().positive().describe("Number of seats"),
|
|
430
|
+
successUrl: z.url().describe("Redirect URL after successful checkout"),
|
|
431
|
+
cancelUrl: z.url().describe("Redirect URL if checkout is cancelled"),
|
|
432
|
+
returnUrl: z.string().optional().describe("Return URL for billing portal"),
|
|
433
|
+
promotionCode: z.string().optional().describe("Promotion code to apply"),
|
|
434
|
+
subscriptionId: z.string().optional().describe("Subscription ID to upgrade, if none provided assume the active subscription of the user.")
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/routes/subscriptions.ts
|
|
439
|
+
const callService = async (handlerTx, call) => {
|
|
440
|
+
return handlerTx().withServiceCalls(() => [call()]).transform(({ serviceResult: [result] }) => result).execute();
|
|
441
|
+
};
|
|
442
|
+
const subscriptionsRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ deps, services, config, defineRoute }) => {
|
|
443
|
+
return [
|
|
444
|
+
defineRoute({
|
|
445
|
+
method: "GET",
|
|
446
|
+
path: "/admin/subscriptions",
|
|
447
|
+
errorCodes: ["UNAUTHORIZED"],
|
|
448
|
+
outputSchema: z.object({ subscriptions: z.array(SubscriptionReponseSchema) }),
|
|
449
|
+
handler: async function(_, { json, error }) {
|
|
450
|
+
if (!config.enableAdminRoutes) return error({
|
|
535
451
|
message: "Unauthorized",
|
|
536
452
|
code: "UNAUTHORIZED"
|
|
537
453
|
}, 401);
|
|
538
|
-
return json({ subscriptions: await services.getAllSubscriptions() });
|
|
454
|
+
return json({ subscriptions: await callService(this.handlerTx, () => services.getAllSubscriptions()) });
|
|
539
455
|
}
|
|
540
456
|
}),
|
|
541
457
|
defineRoute({
|
|
@@ -558,13 +474,14 @@ const subscriptionsRoutesFactory = defineRoutes(stripeFragmentDefinition).create
|
|
|
558
474
|
"MULTIPLE_ACTIVE_SUBSCRIPTIONS",
|
|
559
475
|
"NO_ACTIVE_SUBSCRIPTIONS"
|
|
560
476
|
],
|
|
561
|
-
handler: async (context, { json, error })
|
|
477
|
+
handler: async function(context, { json, error }) {
|
|
562
478
|
const body = await context.input.valid();
|
|
563
479
|
const entity = await config.resolveEntityFromRequest(context);
|
|
564
480
|
let customerId = entity.stripeCustomerId;
|
|
565
481
|
let existingSubscription = null;
|
|
566
|
-
|
|
567
|
-
|
|
482
|
+
const stripeCustomerId = entity.stripeCustomerId;
|
|
483
|
+
if (stripeCustomerId) {
|
|
484
|
+
const existingSubscriptions = await callService(this.handlerTx, () => services.getSubscriptionsByStripeCustomerId(stripeCustomerId));
|
|
568
485
|
let activeSubscriptions = existingSubscriptions.filter((s) => s.status !== "canceled");
|
|
569
486
|
if (body.subscriptionId) activeSubscriptions = activeSubscriptions.filter((s) => s.id === body.subscriptionId);
|
|
570
487
|
if (activeSubscriptions.length > 1) return error({
|
|
@@ -575,12 +492,17 @@ const subscriptionsRoutesFactory = defineRoutes(stripeFragmentDefinition).create
|
|
|
575
492
|
message: "No active subscriptions found for customer",
|
|
576
493
|
code: "NO_ACTIVE_SUBSCRIPTIONS"
|
|
577
494
|
}, 400);
|
|
578
|
-
|
|
579
|
-
if (
|
|
495
|
+
const activeSubscription = activeSubscriptions[0];
|
|
496
|
+
if (!activeSubscription) return error({
|
|
497
|
+
message: "No active subscriptions found for customer",
|
|
498
|
+
code: "NO_ACTIVE_SUBSCRIPTIONS"
|
|
499
|
+
}, 400);
|
|
500
|
+
existingSubscription = activeSubscription;
|
|
501
|
+
if (customerId && existingSubscription?.stripeCustomerId && existingSubscription.stripeCustomerId !== customerId) return error({
|
|
580
502
|
message: "Subsciption being updated does not belong to Stripe Customer that was provided",
|
|
581
503
|
code: "CUSTOMER_SUBSCRIPTION_MISMATCH"
|
|
582
504
|
}, 422);
|
|
583
|
-
customerId =
|
|
505
|
+
customerId = activeSubscription.stripeCustomerId;
|
|
584
506
|
}
|
|
585
507
|
if (!customerId) {
|
|
586
508
|
const existingLinkedCustomer = await deps.stripe.customers.search({
|
|
@@ -617,7 +539,7 @@ const subscriptionsRoutesFactory = defineRoutes(stripeFragmentDefinition).create
|
|
|
617
539
|
});
|
|
618
540
|
if (promotionCodes.data.length > 0) promotionCodeId = promotionCodes.data[0].id;
|
|
619
541
|
}
|
|
620
|
-
if (existingSubscription
|
|
542
|
+
if (existingSubscription && (existingSubscription.status === "active" || existingSubscription.status === "trialing")) {
|
|
621
543
|
const stripeSubscription = await deps.stripe.subscriptions.retrieve(existingSubscription.stripeSubscriptionId);
|
|
622
544
|
try {
|
|
623
545
|
if (existingSubscription.cancelAt != null) {
|
|
@@ -693,14 +615,14 @@ const subscriptionsRoutesFactory = defineRoutes(stripeFragmentDefinition).create
|
|
|
693
615
|
"NO_STRIPE_CUSTOMER_LINKED",
|
|
694
616
|
"MULTIPLE_SUBSCRIPTIONS_FOUND"
|
|
695
617
|
],
|
|
696
|
-
handler: async (context, { json, error })
|
|
618
|
+
handler: async function(context, { json, error }) {
|
|
697
619
|
const body = await context.input.valid();
|
|
698
620
|
const { stripeCustomerId } = await config.resolveEntityFromRequest(context);
|
|
699
621
|
if (!stripeCustomerId) return error({
|
|
700
622
|
message: "No stripe customer linked to entity",
|
|
701
623
|
code: "NO_STRIPE_CUSTOMER_LINKED"
|
|
702
624
|
}, 400);
|
|
703
|
-
let activeSubscriptions = (await services.getSubscriptionsByStripeCustomerId(stripeCustomerId)).filter((s) => s.status === "active");
|
|
625
|
+
let activeSubscriptions = (await callService(this.handlerTx, () => services.getSubscriptionsByStripeCustomerId(stripeCustomerId))).filter((s) => s.status === "active");
|
|
704
626
|
if (body.subscriptionId) activeSubscriptions = activeSubscriptions.filter((s) => s.id === body.subscriptionId);
|
|
705
627
|
if (activeSubscriptions.length === 0) return error({
|
|
706
628
|
message: "No active subscription to cancel",
|
|
@@ -746,131 +668,259 @@ const subscriptionsRoutesFactory = defineRoutes(stripeFragmentDefinition).create
|
|
|
746
668
|
});
|
|
747
669
|
|
|
748
670
|
//#endregion
|
|
749
|
-
//#region src/
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
})
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
671
|
+
//#region src/webhook/handlers.ts
|
|
672
|
+
/**
|
|
673
|
+
* Event Handler for checkout.session.completed
|
|
674
|
+
*
|
|
675
|
+
* This handler is ONLY for subscription checkout sessions.
|
|
676
|
+
* Occurs when a Checkout Session has been successfully completed.
|
|
677
|
+
*/
|
|
678
|
+
async function checkoutSessionCompletedHandler({ event, services, deps, handlerTx }) {
|
|
679
|
+
const checkoutSession = event.data.object;
|
|
680
|
+
if (checkoutSession.mode !== "subscription") {
|
|
681
|
+
deps.log.info(`Not handling checkout session with mode ${checkoutSession.mode}: ${event.id}`);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const subscriptionId = checkoutSession.subscription;
|
|
685
|
+
if (typeof subscriptionId !== "string") {
|
|
686
|
+
deps.log.error("No subscription ID in checkout session");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const stripeSubscription = await deps.stripe.subscriptions.retrieve(subscriptionId);
|
|
690
|
+
const customerId = getId(stripeSubscription.customer);
|
|
691
|
+
const firstItem = stripeSubscription.items.data[0];
|
|
692
|
+
if (!firstItem) {
|
|
693
|
+
deps.log.error("No subscription items found");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const referenceId = checkoutSession.metadata?.["referenceId"] || checkoutSession.client_reference_id;
|
|
697
|
+
const existingSubscriptionId = checkoutSession.metadata?.["subscriptionId"];
|
|
698
|
+
const subscriptionData = {
|
|
699
|
+
...stripeSubscriptionToInternalSubscription(stripeSubscription),
|
|
700
|
+
referenceId: referenceId ?? null
|
|
701
|
+
};
|
|
702
|
+
if (existingSubscriptionId) {
|
|
703
|
+
await handlerTx().withServiceCalls(() => [services.updateSubscription(existingSubscriptionId, subscriptionData)]).execute();
|
|
704
|
+
deps.log.info(`Updated subscription ${existingSubscriptionId} for customer ${customerId}`);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const result = await handlerTx().withServiceCalls(() => [services.getSubscriptionByStripeId(stripeSubscription.id)]).mutate(({ forSchema, serviceIntermediateResult: [existing] }) => {
|
|
708
|
+
if (existing) return {
|
|
709
|
+
action: "exists",
|
|
710
|
+
id: existing.id
|
|
711
|
+
};
|
|
712
|
+
const created = forSchema(stripeSchema).create("subscription", subscriptionData);
|
|
713
|
+
return {
|
|
714
|
+
action: "created",
|
|
715
|
+
id: created.externalId
|
|
716
|
+
};
|
|
717
|
+
}).execute();
|
|
718
|
+
if (result.action === "exists") {
|
|
719
|
+
deps.log.info(`Subscription already exists for Stripe ID ${stripeSubscription.id}`);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
deps.log.info(`Created subscription ${result.id} for customer ${customerId} (Stripe ID: ${stripeSubscription.id})`);
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Event Handler for customer.subscription.paused
|
|
726
|
+
*
|
|
727
|
+
* Occurs whenever a customer’s subscription is paused. Only applies when
|
|
728
|
+
* subscriptions enter status=paused, not when payment collection is paused.
|
|
729
|
+
*
|
|
730
|
+
* PAUSED status: The subscription has ended its trial period without a default
|
|
731
|
+
* payment method and the trial_settings.end_behavior.missing_payment_method is set
|
|
732
|
+
* to pause. Invoices are no longer created for the subscription. After attaching a
|
|
733
|
+
* default payment method to the customer, you can resume the subscription.
|
|
734
|
+
*/
|
|
735
|
+
async function customerSubscriptionPausedHandler(args) {
|
|
736
|
+
return customerSubscriptionUpdatedHandler(args);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Event Handler for customer.subscription.pending_update_applied
|
|
740
|
+
*
|
|
741
|
+
* Occurs whenever a customer’s subscription’s pending update is applied,
|
|
742
|
+
* and the subscription is updated.
|
|
743
|
+
*/
|
|
744
|
+
async function customerSubscriptionPendingUpdateAppliedHandler(args) {
|
|
745
|
+
return customerSubscriptionUpdatedHandler(args);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Event Handler for customer.subscription.pending_update_expired
|
|
749
|
+
*
|
|
750
|
+
* Occurs whenever a customer’s subscription’s pending update expires
|
|
751
|
+
* before the related invoice is paid.
|
|
752
|
+
*/
|
|
753
|
+
async function customerSubscriptionPendingUpdateExpiredHandler(args) {
|
|
754
|
+
return customerSubscriptionUpdatedHandler(args);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Event Handler for customer.subscription.resumed
|
|
758
|
+
*
|
|
759
|
+
* Occurs whenever a customer’s subscription is no longer paused.
|
|
760
|
+
* Only applies when a status=paused subscription is resumed, not when payment
|
|
761
|
+
* collection is resumed.
|
|
762
|
+
*/
|
|
763
|
+
async function customerSubscriptionResumedHandler(args) {
|
|
764
|
+
return customerSubscriptionUpdatedHandler(args);
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Event Handler for customer.subscription.trial_will_end
|
|
768
|
+
*
|
|
769
|
+
* Occurs three days before a subscription's trial period is scheduled to end,
|
|
770
|
+
* or when a trial is ended immediately (using trial_end=now). This event allows
|
|
771
|
+
* you to send reminders or take action before the trial expires.
|
|
772
|
+
*/
|
|
773
|
+
async function customerSubscriptionTrialWillEndHandler(args) {
|
|
774
|
+
return customerSubscriptionUpdatedHandler(args);
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Event Handler for customer.subscription.updated
|
|
778
|
+
*
|
|
779
|
+
* Occurs whenever a subscription changes (e.g., switching from one plan to another,
|
|
780
|
+
* or changing the status from trial to active).
|
|
781
|
+
*/
|
|
782
|
+
async function customerSubscriptionUpdatedHandler({ event, services, deps, handlerTx }) {
|
|
783
|
+
deps.log.info(`Processing ${event.type}: ${event.id}`);
|
|
784
|
+
const stripeSubscription = event.data.object;
|
|
785
|
+
const firstItem = stripeSubscription.items?.data?.[0];
|
|
786
|
+
if (!firstItem) throw new FragnoApiError({
|
|
787
|
+
message: "Subscription contains no items",
|
|
788
|
+
code: "EMPTY_SUBSCRIPTION"
|
|
789
|
+
}, 400);
|
|
790
|
+
const customerId = getId(stripeSubscription.customer);
|
|
791
|
+
const subscriptionPayload = stripeSubscriptionToInternalSubscription(stripeSubscription);
|
|
792
|
+
const referenceId = stripeSubscription.metadata?.["referenceId"] ?? null;
|
|
793
|
+
const result = await handlerTx().withServiceCalls(() => [services.getSubscriptionByStripeId(stripeSubscription.id), services.getSubscriptionsByStripeCustomerId(customerId)]).mutate(({ forSchema, serviceIntermediateResult: [byStripeId, byCustomerId] }) => {
|
|
794
|
+
let subscription = byStripeId;
|
|
795
|
+
if (!subscription) if (byCustomerId.length > 1) {
|
|
796
|
+
subscription = byCustomerId.find((sub) => sub.status === "active" || sub.status === "trialing") ?? null;
|
|
797
|
+
if (!subscription) return { action: "skipped" };
|
|
798
|
+
} else subscription = byCustomerId[0] ?? null;
|
|
799
|
+
if (!subscription) {
|
|
800
|
+
const created = forSchema(stripeSchema).create("subscription", {
|
|
801
|
+
...subscriptionPayload,
|
|
802
|
+
referenceId
|
|
801
803
|
});
|
|
804
|
+
return {
|
|
805
|
+
action: "created",
|
|
806
|
+
id: created.externalId
|
|
807
|
+
};
|
|
802
808
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
});
|
|
809
|
+
forSchema(stripeSchema).update("subscription", subscription.id, (b) => b.set({
|
|
810
|
+
...subscriptionPayload,
|
|
811
|
+
updatedAt: new Date()
|
|
812
|
+
}));
|
|
813
|
+
return {
|
|
814
|
+
action: "updated",
|
|
815
|
+
id: subscription.id
|
|
816
|
+
};
|
|
817
|
+
}).execute();
|
|
818
|
+
if (result.action === "skipped") {
|
|
819
|
+
deps.log.warn(`Multiple subscriptions found for customer ${customerId} but none active or trialing`);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (result.action === "created") {
|
|
823
|
+
deps.log.warn(`No subscription found for Stripe ID ${stripeSubscription.id}, creating new record`);
|
|
824
|
+
deps.log.info(`Created subscription ${result.id} for customer ${customerId} (Stripe ID: ${stripeSubscription.id})`);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
deps.log.info(`Updated subscription ${result.id} (Stripe ID: ${stripeSubscription.id})`);
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Event Handler for customer.subscription.deleted
|
|
831
|
+
*
|
|
832
|
+
* Occurs whenever a customer's subscription ends.
|
|
833
|
+
*/
|
|
834
|
+
async function customerSubscriptionDeletedHandler({ event, services, deps, handlerTx }) {
|
|
835
|
+
deps.log.info(`Processing customer.subscription.deleted: ${event.id}`);
|
|
836
|
+
const stripeSubscription = event.data.object;
|
|
837
|
+
const result = await handlerTx().withServiceCalls(() => [services.getSubscriptionByStripeId(stripeSubscription.id)]).mutate(({ forSchema, serviceIntermediateResult: [subscription] }) => {
|
|
838
|
+
if (!subscription) return { action: "missing" };
|
|
839
|
+
forSchema(stripeSchema).update("subscription", subscription.id, (b) => b.set({
|
|
840
|
+
status: "canceled",
|
|
841
|
+
updatedAt: new Date()
|
|
842
|
+
}));
|
|
843
|
+
return {
|
|
844
|
+
action: "canceled",
|
|
845
|
+
id: subscription.id
|
|
846
|
+
};
|
|
847
|
+
}).execute();
|
|
848
|
+
if (result.action === "missing") {
|
|
849
|
+
deps.log.warn(`No subscription found for Stripe ID ${stripeSubscription.id}`);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
deps.log.info(`Marked subscription ${result.id} as canceled (Stripe ID: ${stripeSubscription.id})`);
|
|
853
|
+
}
|
|
854
|
+
const eventToHandler = {
|
|
855
|
+
"checkout.session.completed": checkoutSessionCompletedHandler,
|
|
856
|
+
"customer.subscription.deleted": customerSubscriptionDeletedHandler,
|
|
857
|
+
"customer.subscription.created": customerSubscriptionUpdatedHandler,
|
|
858
|
+
"customer.subscription.updated": customerSubscriptionUpdatedHandler,
|
|
859
|
+
"customer.subscription.paused": customerSubscriptionPausedHandler,
|
|
860
|
+
"customer.subscription.pending_update_applied": customerSubscriptionPendingUpdateAppliedHandler,
|
|
861
|
+
"customer.subscription.pending_update_expired": customerSubscriptionPendingUpdateExpiredHandler,
|
|
862
|
+
"customer.subscription.resumed": customerSubscriptionResumedHandler,
|
|
863
|
+
"customer.subscription.trial_will_end": customerSubscriptionTrialWillEndHandler
|
|
864
|
+
};
|
|
842
865
|
|
|
843
866
|
//#endregion
|
|
844
|
-
//#region src/routes/
|
|
845
|
-
const
|
|
867
|
+
//#region src/routes/webhooks.ts
|
|
868
|
+
const webhookRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ config, deps, services, defineRoute }) => {
|
|
846
869
|
return [defineRoute({
|
|
847
|
-
method: "
|
|
848
|
-
path: "/
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
})
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
870
|
+
method: "POST",
|
|
871
|
+
path: "/webhook",
|
|
872
|
+
outputSchema: z.object({ success: z.boolean() }),
|
|
873
|
+
errorCodes: [
|
|
874
|
+
"MISSING_SIGNATURE",
|
|
875
|
+
"WEBHOOK_SIGNATURE_INVALID",
|
|
876
|
+
"WEBHOOK_ERROR"
|
|
877
|
+
],
|
|
878
|
+
handler: async function({ headers, rawBody }, { json, error }) {
|
|
879
|
+
const signature = headers.get("stripe-signature");
|
|
880
|
+
if (!signature) return error({
|
|
881
|
+
message: "Missing stripe-signature header",
|
|
882
|
+
code: "MISSING_SIGNATURE"
|
|
883
|
+
}, 400);
|
|
884
|
+
if (!rawBody) return error({
|
|
885
|
+
message: "Missing request body for webhook verification",
|
|
886
|
+
code: "WEBHOOK_ERROR"
|
|
887
|
+
}, 400);
|
|
888
|
+
let event;
|
|
889
|
+
try {
|
|
890
|
+
if (typeof deps.stripe.webhooks.constructEventAsync === "function") event = await deps.stripe.webhooks.constructEventAsync(rawBody, signature, config.webhookSecret);
|
|
891
|
+
else event = deps.stripe.webhooks.constructEvent(rawBody, signature, config.webhookSecret);
|
|
892
|
+
} catch (err) {
|
|
893
|
+
if (err instanceof Stripe.errors.StripeSignatureVerificationError) return error({
|
|
894
|
+
message: `Webhook signature verification failed`,
|
|
895
|
+
code: "WEBHOOK_SIGNATURE_INVALID"
|
|
896
|
+
}, 400);
|
|
897
|
+
throw err;
|
|
898
|
+
}
|
|
899
|
+
if (!event) return error({
|
|
900
|
+
message: "Failed to construct event",
|
|
901
|
+
code: "WEBHOOK_ERROR"
|
|
902
|
+
}, 400);
|
|
903
|
+
if (config.onEvent) {
|
|
904
|
+
deps.log.info("Running user callback event");
|
|
905
|
+
await config.onEvent({
|
|
906
|
+
event,
|
|
907
|
+
stripeClient: deps.stripe
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
const eventHandler = eventToHandler[event.type];
|
|
911
|
+
if (!eventHandler) {
|
|
912
|
+
deps.log.info(`Webhook event ${event.type}: ${event.id} ignored`);
|
|
913
|
+
return json({ success: true });
|
|
914
|
+
}
|
|
915
|
+
deps.log.info(`Executing event handler for ${event.type}: ${event.id}`);
|
|
916
|
+
await eventHandler({
|
|
917
|
+
event,
|
|
918
|
+
services,
|
|
919
|
+
deps,
|
|
920
|
+
config,
|
|
921
|
+
handlerTx: this.handlerTx
|
|
873
922
|
});
|
|
923
|
+
return json({ success: true });
|
|
874
924
|
}
|
|
875
925
|
})];
|
|
876
926
|
});
|