@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.
@@ -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
- status: subscription.status
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, db) {
67
- const services = {
78
+ function createStripeServices(deps, defineService) {
79
+ return defineService({
68
80
  getStripeClient() {
69
81
  return deps.stripe;
70
82
  },
71
- createSubscription: async (data) => {
72
- return (await db.create("subscription", data)).externalId;
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: async (id, data) => {
75
- await db.update("subscription", id, (b) => b.set({
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: async (stripeSubscriptionId) => {
81
- const result = await db.findFirst("subscription", (b) => b.whereIndex("idx_stripe_subscription_id", (eb) => eb("stripeSubscriptionId", "=", stripeSubscriptionId)));
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: async (stripeCustomerId) => {
86
- return (await db.find("subscription", (b) => b.whereIndex("idx_stripe_customer_id", (eb) => eb("stripeCustomerId", "=", stripeCustomerId)))).map(asExternalSubscription);
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: async (id) => {
89
- const result = await db.findFirst("subscription", (b) => b.whereIndex("primary", (eb) => eb("id", "=", id)));
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: async (referenceId) => {
94
- const result = await db.find("subscription", (b) => b.whereIndex("idx_reference_id", (eb) => eb("referenceId", "=", referenceId)));
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: async (id) => {
99
- await db.delete("subscription", id);
107
+ deleteSubscription(id) {
108
+ return this.serviceTx(stripeSchema).mutate(({ uow }) => uow.delete("subscription", id)).build();
100
109
  },
101
- deleteSubscriptionsByReferenceId: async (referenceId) => {
102
- const uow = db.createUnitOfWork().find("subscription", (b) => b.whereIndex("idx_reference_id", (eb) => eb("referenceId", "=", referenceId)));
103
- const [subscriptions] = await uow.executeRetrieve();
104
- subscriptions.forEach((sub) => sub && uow.delete("subscription", sub.id));
105
- return await uow.executeMutations();
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: async () => {
108
- return (await db.find("subscription", (b) => b.whereIndex("primary"))).map(asExternalSubscription);
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: async (referenceId, stripeCustomerId) => {
111
- const stripeSubscriptions = await deps.stripe.subscriptions.list({
112
- customer: stripeCustomerId,
113
- status: "all"
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
- return uow.executeMutations();
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/subscriptions.ts
489
- const SubscriptionReponseSchema = z.object({
270
+ //#region src/models/prices.ts
271
+ const PriceResponseSchema = z.object({
490
272
  id: z.string(),
491
- stripeSubscriptionId: z.string(),
492
- stripeCustomerId: z.string(),
493
- stripePriceId: z.string(),
494
- referenceId: z.string().nullable(),
495
- status: z.enum([
496
- "active",
497
- "canceled",
498
- "incomplete",
499
- "incomplete_expired",
500
- "past_due",
501
- "paused",
502
- "trialing",
503
- "unpaid"
504
- ]),
505
- periodStart: z.date().nullable(),
506
- periodEnd: z.date().nullable(),
507
- trialStart: z.date().nullable(),
508
- trialEnd: z.date().nullable(),
509
- cancelAtPeriodEnd: z.boolean().nullable(),
510
- cancelAt: z.date().nullable(),
511
- seats: z.number().nullable(),
512
- createdAt: z.date(),
513
- updatedAt: z.date()
514
- });
515
- const SubscriptionUpgradeRequestSchema = z.object({
516
- priceId: z.string().describe("Stripe price ID to subscribe/upgrade to"),
517
- quantity: z.number().positive().describe("Number of seats"),
518
- successUrl: z.url().describe("Redirect URL after successful checkout"),
519
- cancelUrl: z.url().describe("Redirect URL if checkout is cancelled"),
520
- returnUrl: z.string().optional().describe("Return URL for billing portal"),
521
- promotionCode: z.string().optional().describe("Promotion code to apply"),
522
- subscriptionId: z.string().optional().describe("Subscription ID to upgrade, if none provided assume the active subscription of the user.")
523
- });
524
-
525
- //#endregion
526
- //#region src/routes/subscriptions.ts
527
- const subscriptionsRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ deps, services, config, defineRoute }) => {
528
- return [
529
- defineRoute({
530
- method: "GET",
531
- path: "/admin/subscriptions",
532
- outputSchema: z.object({ subscriptions: z.array(SubscriptionReponseSchema) }),
533
- handler: async (_, { json, error }) => {
534
- if (!config.enableAdminRoutes) return error({
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
- if (entity.stripeCustomerId) {
567
- const existingSubscriptions = await services.getSubscriptionsByStripeCustomerId(entity.stripeCustomerId);
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
- existingSubscription = activeSubscriptions[0];
579
- if (customerId && existingSubscription.stripeCustomerId && existingSubscription.stripeCustomerId !== customerId) return error({
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 = existingSubscription.stripeCustomerId;
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?.status === "active" || existingSubscription?.status === "trialing") {
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/models/products.ts
750
- const ProductResponseSchema = z.object({
751
- id: z.string(),
752
- object: z.literal("product"),
753
- active: z.boolean(),
754
- created: z.number(),
755
- default_price: z.union([z.string(), z.any()]).nullable().optional(),
756
- deleted: z.void().optional(),
757
- description: z.string().nullable(),
758
- images: z.array(z.string()),
759
- livemode: z.boolean(),
760
- marketing_features: z.array(z.any()),
761
- metadata: z.any(),
762
- name: z.string(),
763
- package_dimensions: z.any().nullable(),
764
- shippable: z.boolean().nullable(),
765
- statement_descriptor: z.string().nullable().optional(),
766
- tax_code: z.union([z.string(), z.any()]).nullable(),
767
- type: z.string(),
768
- unit_label: z.string().nullable().optional(),
769
- updated: z.number(),
770
- url: z.string().nullable()
771
- });
772
-
773
- //#endregion
774
- //#region src/routes/products.ts
775
- const productsRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ deps, config, defineRoute }) => {
776
- return [defineRoute({
777
- method: "GET",
778
- path: "/admin/products",
779
- inputSchema: z.object({
780
- limit: z.number().int().positive().max(100).optional().default(50).describe("Number of products to return (max 100)"),
781
- startingAfter: z.string().optional().describe("Product ID to start after for pagination")
782
- }),
783
- outputSchema: z.object({
784
- products: z.array(ProductResponseSchema),
785
- hasMore: z.boolean().describe("Whether there are more items to fetch")
786
- }),
787
- handler: async ({ query }, { json, error }) => {
788
- if (!config.enableAdminRoutes) return error({
789
- message: "Unauthorized",
790
- code: "UNAUTHORIZED"
791
- }, 401);
792
- const limit = Number(query.get("limit")) || void 0;
793
- const startingAfter = query.get("startingAfter") || void 0;
794
- const products = await deps.stripe.products.list({
795
- limit,
796
- starting_after: startingAfter
797
- });
798
- return json({
799
- products: products.data,
800
- hasMore: products.has_more
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
- //#endregion
807
- //#region src/models/prices.ts
808
- const PriceResponseSchema = z.object({
809
- id: z.string(),
810
- object: z.literal("price"),
811
- active: z.boolean(),
812
- billing_scheme: z.string(),
813
- created: z.number(),
814
- currency: z.string(),
815
- custom_unit_amount: z.any().nullable().optional(),
816
- deleted: z.void().optional(),
817
- livemode: z.boolean(),
818
- lookup_key: z.string().nullable().optional(),
819
- metadata: z.any(),
820
- nickname: z.string().nullable().optional(),
821
- product: z.union([z.string(), z.any()]),
822
- recurring: z.object({
823
- aggregate_usage: z.string().nullable().optional(),
824
- interval: z.enum([
825
- "day",
826
- "week",
827
- "month",
828
- "year"
829
- ]),
830
- interval_count: z.number(),
831
- meter: z.string().nullable().optional(),
832
- trial_period_days: z.number().nullable().optional(),
833
- usage_type: z.string()
834
- }).nullable().optional(),
835
- tax_behavior: z.string().nullable().optional(),
836
- tiers_mode: z.string().nullable().optional(),
837
- transform_quantity: z.any().nullable().optional(),
838
- type: z.enum(["one_time", "recurring"]),
839
- unit_amount: z.number().nullable().optional(),
840
- unit_amount_decimal: z.string().nullable().optional()
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/prices.ts
845
- const pricesRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ deps, config, defineRoute }) => {
867
+ //#region src/routes/webhooks.ts
868
+ const webhookRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ config, deps, services, defineRoute }) => {
846
869
  return [defineRoute({
847
- method: "GET",
848
- path: "/admin/products/:productId/prices",
849
- inputSchema: z.object({
850
- limit: z.number().int().positive().max(100).optional().default(50).describe("Number of prices to return (max 100)"),
851
- startingAfter: z.string().optional().describe("Price ID to start after for pagination")
852
- }),
853
- outputSchema: z.object({
854
- prices: z.array(PriceResponseSchema),
855
- hasMore: z.boolean().describe("Whether there are more items to fetch")
856
- }),
857
- handler: async ({ pathParams, query }, { json, error }) => {
858
- if (!config.enableAdminRoutes) return error({
859
- message: "Unauthorized",
860
- code: "UNAUTHORIZED"
861
- }, 401);
862
- const { productId } = pathParams;
863
- const limit = Number(query.get("limit")) || void 0;
864
- const startingAfter = query.get("startingAfter") || void 0;
865
- const prices = await deps.stripe.prices.list({
866
- product: productId,
867
- limit,
868
- starting_after: startingAfter
869
- });
870
- return json({
871
- prices: prices.data,
872
- hasMore: prices.has_more
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
  });