@fragno-dev/stripe 0.0.2 → 0.1.1
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/README.md +7 -0
- package/dist/browser/client/react.d.ts +130 -121
- package/dist/browser/client/react.js +1 -1
- package/dist/browser/client/react.js.map +1 -1
- package/dist/browser/client/solid.d.ts +130 -121
- package/dist/browser/client/solid.js +1 -1
- package/dist/browser/client/solid.js.map +1 -1
- package/dist/browser/client/svelte.d.ts +130 -121
- package/dist/browser/client/svelte.d.ts.map +1 -1
- package/dist/browser/client/svelte.js +1 -1
- package/dist/browser/client/svelte.js.map +1 -1
- package/dist/browser/client/vanilla.d.ts +129 -120
- package/dist/browser/client/vanilla.d.ts.map +1 -1
- package/dist/browser/client/vanilla.js +5 -2
- package/dist/browser/client/vanilla.js.map +1 -1
- package/dist/browser/client/vue.d.ts +130 -121
- package/dist/browser/client/vue.d.ts.map +1 -1
- package/dist/browser/client/vue.js +1 -1
- package/dist/browser/client/vue.js.map +1 -1
- package/dist/browser/index.d.ts +326 -511
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/{src-k2PmVjgJ.js → src-D6S1gN3I.js} +837 -766
- package/dist/browser/src-D6S1gN3I.js.map +1 -0
- package/dist/node/index.d.ts +93 -278
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +247 -196
- package/dist/node/index.js.map +1 -1
- package/package.json +6 -6
- package/dist/browser/src-k2PmVjgJ.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
package/dist/node/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createFragment, defineRoute, defineRoutes } from "@fragno-dev/core";
|
|
2
1
|
import { createClientBuilder } from "@fragno-dev/core/client";
|
|
3
|
-
import { defineFragmentWithDatabase } from "@fragno-dev/db/fragment";
|
|
4
2
|
import Stripe from "stripe";
|
|
5
3
|
import { column, idColumn, schema } from "@fragno-dev/db/schema";
|
|
4
|
+
import { defineFragment, defineRoutes, instantiate } from "@fragno-dev/core";
|
|
5
|
+
import { withDatabase } from "@fragno-dev/db";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { FragnoApiError } from "@fragno-dev/core/api";
|
|
8
8
|
|
|
@@ -43,10 +43,108 @@ function stripeSubscriptionToInternalSubscription(stripeSubscription) {
|
|
|
43
43
|
trialEnd: toDate(stripeSubscription.trial_end),
|
|
44
44
|
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? false,
|
|
45
45
|
cancelAt: toDate(stripeSubscription.cancel_at),
|
|
46
|
+
createdAt: toDate(stripeSubscription.created),
|
|
46
47
|
seats: firstItem.quantity ?? null
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/definition.ts
|
|
53
|
+
const LOG_PREFIX = "[Stripe Fragment]";
|
|
54
|
+
const defaultLogger = {
|
|
55
|
+
info: (...data) => console.info(LOG_PREFIX, ...data),
|
|
56
|
+
error: (...data) => console.error(LOG_PREFIX, ...data),
|
|
57
|
+
warn: (...data) => console.warn(LOG_PREFIX, ...data),
|
|
58
|
+
debug: (...data) => console.debug(LOG_PREFIX, ...data),
|
|
59
|
+
log: (...data) => console.log(LOG_PREFIX, ...data)
|
|
60
|
+
};
|
|
61
|
+
const asExternalSubscription = (subscription) => ({
|
|
62
|
+
...subscription,
|
|
63
|
+
id: subscription.id.externalId,
|
|
64
|
+
status: subscription.status
|
|
65
|
+
});
|
|
66
|
+
function createStripeServices(deps, db) {
|
|
67
|
+
const services = {
|
|
68
|
+
getStripeClient() {
|
|
69
|
+
return deps.stripe;
|
|
70
|
+
},
|
|
71
|
+
createSubscription: async (data) => {
|
|
72
|
+
return (await db.create("subscription", data)).externalId;
|
|
73
|
+
},
|
|
74
|
+
updateSubscription: async (id, data) => {
|
|
75
|
+
await db.update("subscription", id, (b) => b.set({
|
|
76
|
+
...data,
|
|
77
|
+
updatedAt: new Date()
|
|
78
|
+
}));
|
|
79
|
+
},
|
|
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);
|
|
84
|
+
},
|
|
85
|
+
getSubscriptionsByStripeCustomerId: async (stripeCustomerId) => {
|
|
86
|
+
return (await db.find("subscription", (b) => b.whereIndex("idx_stripe_customer_id", (eb) => eb("stripeCustomerId", "=", stripeCustomerId)))).map(asExternalSubscription);
|
|
87
|
+
},
|
|
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);
|
|
92
|
+
},
|
|
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);
|
|
97
|
+
},
|
|
98
|
+
deleteSubscription: async (id) => {
|
|
99
|
+
await db.delete("subscription", id);
|
|
100
|
+
},
|
|
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();
|
|
106
|
+
},
|
|
107
|
+
getAllSubscriptions: async () => {
|
|
108
|
+
return (await db.find("subscription", (b) => b.whereIndex("primary"))).map(asExternalSubscription);
|
|
109
|
+
},
|
|
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()
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return uow.executeMutations();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
return services;
|
|
137
|
+
}
|
|
138
|
+
const stripeFragmentDefinition = defineFragment("stripe").extend(withDatabase(stripeSchema)).withDependencies(({ config }) => {
|
|
139
|
+
const stripeClient = new Stripe(config.stripeSecretKey, config.stripeClientOptions ?? {});
|
|
140
|
+
return {
|
|
141
|
+
stripe: stripeClient,
|
|
142
|
+
log: config.logger ? config.logger : defaultLogger
|
|
143
|
+
};
|
|
144
|
+
}).providesBaseService(({ deps }) => {
|
|
145
|
+
return { ...createStripeServices(deps, deps.db) };
|
|
146
|
+
}).build();
|
|
147
|
+
|
|
50
148
|
//#endregion
|
|
51
149
|
//#region src/webhook/handlers.ts
|
|
52
150
|
/**
|
|
@@ -161,7 +259,7 @@ async function customerSubscriptionUpdatedHandler({ event, services, deps }) {
|
|
|
161
259
|
const customerId = getId(stripeSubscription.customer);
|
|
162
260
|
let subscription = await services.getSubscriptionByStripeId(stripeSubscription.id);
|
|
163
261
|
if (!subscription) {
|
|
164
|
-
const customerSubs = await services.
|
|
262
|
+
const customerSubs = await services.getSubscriptionsByStripeCustomerId(customerId);
|
|
165
263
|
if (customerSubs.length > 1) {
|
|
166
264
|
subscription = customerSubs.find((sub) => sub.status === "active" || sub.status === "trialing") ?? null;
|
|
167
265
|
if (!subscription) {
|
|
@@ -212,7 +310,7 @@ const eventToHandler = {
|
|
|
212
310
|
|
|
213
311
|
//#endregion
|
|
214
312
|
//#region src/routes/webhooks.ts
|
|
215
|
-
const webhookRoutesFactory = defineRoutes().create(({ config, deps, services }) => {
|
|
313
|
+
const webhookRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ config, deps, services, defineRoute }) => {
|
|
216
314
|
return [defineRoute({
|
|
217
315
|
method: "POST",
|
|
218
316
|
path: "/webhook",
|
|
@@ -295,9 +393,38 @@ const CustomerResponseSchema = z.object({
|
|
|
295
393
|
preferred_locales: z.array(z.string()).nullable().optional()
|
|
296
394
|
});
|
|
297
395
|
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region src/routes/errors.ts
|
|
398
|
+
function isStripeError(error) {
|
|
399
|
+
return typeof error === "object" && error !== null && "type" in error && typeof error.type === "string" && "message" in error && typeof error.message === "string";
|
|
400
|
+
}
|
|
401
|
+
function stripeToApiError(error) {
|
|
402
|
+
if (isStripeError(error) && error.type === "StripeInvalidRequestError" && error.message.includes("there are no changes to confirm")) return new FragnoApiError({
|
|
403
|
+
message: "Trying to upgrade to same subscription plan",
|
|
404
|
+
code: "UPGRADE_HAS_NO_EFFECT"
|
|
405
|
+
}, 500);
|
|
406
|
+
if (isStripeError(error) && error.type === "StripeInvalidRequestError" && error.message.includes("is already set to be canceled at period end")) return new FragnoApiError({
|
|
407
|
+
message: "Subscription is already set to be canceled at period end",
|
|
408
|
+
code: "SUBSCRIPTION_ALREADY_CANCELED"
|
|
409
|
+
}, 500);
|
|
410
|
+
if (isStripeError(error) && error.type === "StripeInvalidRequestError" && error.message.includes("the subscription update feature in the portal configuration is disabled")) return new FragnoApiError({
|
|
411
|
+
message: "Subscription cannot be updated to this plan",
|
|
412
|
+
code: "SUBSCRIPTION_UPDATE_NOT_ALLOWED"
|
|
413
|
+
}, 500);
|
|
414
|
+
if (isStripeError(error) && error.type === "StripeInvalidRequestError" && error.message.includes("does not include `promotion_code`")) return new FragnoApiError({
|
|
415
|
+
message: "Cannot apply promotion code when updating subscription",
|
|
416
|
+
code: "SUBSCRIPTION_UPDATE_PROMO_CODE_NOT_ALLOWED"
|
|
417
|
+
}, 500);
|
|
418
|
+
if (isStripeError(error) && error.type === "StripeInvalidRequestError" && error.code == "promotion_code_customer_not_first_time") return new FragnoApiError({
|
|
419
|
+
message: "This promotion code cannot be redeemed because you have already used it before.",
|
|
420
|
+
code: "PROMOTION_CODE_CUSTOMER_NOT_FIRST_TIME"
|
|
421
|
+
}, 500);
|
|
422
|
+
return error;
|
|
423
|
+
}
|
|
424
|
+
|
|
298
425
|
//#endregion
|
|
299
426
|
//#region src/routes/customers.ts
|
|
300
|
-
const customersRoutesFactory = defineRoutes().create(({ deps, config }) => {
|
|
427
|
+
const customersRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ deps, config, defineRoute }) => {
|
|
301
428
|
return [defineRoute({
|
|
302
429
|
method: "GET",
|
|
303
430
|
path: "/admin/customers",
|
|
@@ -325,6 +452,35 @@ const customersRoutesFactory = defineRoutes().create(({ deps, config }) => {
|
|
|
325
452
|
hasMore: customers.has_more
|
|
326
453
|
});
|
|
327
454
|
}
|
|
455
|
+
}), defineRoute({
|
|
456
|
+
method: "POST",
|
|
457
|
+
path: "/portal",
|
|
458
|
+
inputSchema: z.object({ returnUrl: z.url().describe("URL to redirect to after completing billing portal") }),
|
|
459
|
+
outputSchema: z.object({
|
|
460
|
+
url: z.url().describe("URL to redirect to after cancellation"),
|
|
461
|
+
redirect: z.boolean().describe("Whether to redirect to the URL")
|
|
462
|
+
}),
|
|
463
|
+
errorCodes: ["NO_STRIPE_CUSTOMER_FOR_ENTITY"],
|
|
464
|
+
handler: async (context, { json, error }) => {
|
|
465
|
+
const body = await context.input.valid();
|
|
466
|
+
const { stripeCustomerId } = await config.resolveEntityFromRequest(context);
|
|
467
|
+
if (!stripeCustomerId) return error({
|
|
468
|
+
message: "No stripe customer to create billing portal for",
|
|
469
|
+
code: "NO_STRIPE_CUSTOMER_FOR_ENTITY"
|
|
470
|
+
}, 400);
|
|
471
|
+
try {
|
|
472
|
+
const portalSession = await deps.stripe.billingPortal.sessions.create({
|
|
473
|
+
customer: stripeCustomerId,
|
|
474
|
+
return_url: body.returnUrl
|
|
475
|
+
});
|
|
476
|
+
return json({
|
|
477
|
+
url: portalSession.url,
|
|
478
|
+
redirect: true
|
|
479
|
+
});
|
|
480
|
+
} catch (err) {
|
|
481
|
+
throw stripeToApiError(err);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
328
484
|
})];
|
|
329
485
|
});
|
|
330
486
|
|
|
@@ -361,33 +517,14 @@ const SubscriptionUpgradeRequestSchema = z.object({
|
|
|
361
517
|
quantity: z.number().positive().describe("Number of seats"),
|
|
362
518
|
successUrl: z.url().describe("Redirect URL after successful checkout"),
|
|
363
519
|
cancelUrl: z.url().describe("Redirect URL if checkout is cancelled"),
|
|
364
|
-
returnUrl: z.string().optional().describe("Return URL for billing portal")
|
|
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.")
|
|
365
523
|
});
|
|
366
524
|
|
|
367
|
-
//#endregion
|
|
368
|
-
//#region src/routes/errors.ts
|
|
369
|
-
function isStripeError(error) {
|
|
370
|
-
return typeof error === "object" && error !== null && "type" in error && typeof error.type === "string" && "message" in error && typeof error.message === "string";
|
|
371
|
-
}
|
|
372
|
-
function stripeToApiError(error) {
|
|
373
|
-
if (isStripeError(error) && error.type === "StripeInvalidRequestError" && error.message.includes("there are no changes to confirm")) return new FragnoApiError({
|
|
374
|
-
message: "Trying to upgrade to same subscription plan",
|
|
375
|
-
code: "UPGRADE_HAS_NO_EFFECT"
|
|
376
|
-
}, 500);
|
|
377
|
-
if (isStripeError(error) && error.type === "StripeInvalidRequestError" && error.message.includes("is already set to be canceled at period end")) return new FragnoApiError({
|
|
378
|
-
message: "Subscription is already set to be canceled at period end",
|
|
379
|
-
code: "SUBSCRIPTION_ALREADY_CANCELED"
|
|
380
|
-
}, 500);
|
|
381
|
-
if (isStripeError(error) && error.type === "StripeInvalidRequestError" && error.message.includes("the subscription update feature in the portal configuration is disabled")) return new FragnoApiError({
|
|
382
|
-
message: "Subscription cannot be updated to this plan",
|
|
383
|
-
code: "SUBSCRIPTION_UPDATE_NOT_ALLOWED"
|
|
384
|
-
}, 500);
|
|
385
|
-
return error;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
525
|
//#endregion
|
|
389
526
|
//#region src/routes/subscriptions.ts
|
|
390
|
-
const subscriptionsRoutesFactory = defineRoutes().create(({ deps, services, config }) => {
|
|
527
|
+
const subscriptionsRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ deps, services, config, defineRoute }) => {
|
|
391
528
|
return [
|
|
392
529
|
defineRoute({
|
|
393
530
|
method: "GET",
|
|
@@ -415,19 +552,30 @@ const subscriptionsRoutesFactory = defineRoutes().create(({ deps, services, conf
|
|
|
415
552
|
"SUBSCRIPTION_NOT_FOUND",
|
|
416
553
|
"CUSTOMER_SUBSCRIPTION_MISMATCH",
|
|
417
554
|
"UPGRADE_HAS_NO_EFFECT",
|
|
418
|
-
"SUBSCRIPTION_UPDATE_NOT_ALLOWED"
|
|
555
|
+
"SUBSCRIPTION_UPDATE_NOT_ALLOWED",
|
|
556
|
+
"SUBSCRIPTION_UPDATE_PROMO_CODE_NOT_ALLOWED",
|
|
557
|
+
"PROMOTION_CODE_CUSTOMER_NOT_FIRST_TIME",
|
|
558
|
+
"MULTIPLE_ACTIVE_SUBSCRIPTIONS",
|
|
559
|
+
"NO_ACTIVE_SUBSCRIPTIONS"
|
|
419
560
|
],
|
|
420
561
|
handler: async (context, { json, error }) => {
|
|
421
562
|
const body = await context.input.valid();
|
|
422
|
-
const
|
|
423
|
-
let customerId =
|
|
563
|
+
const entity = await config.resolveEntityFromRequest(context);
|
|
564
|
+
let customerId = entity.stripeCustomerId;
|
|
424
565
|
let existingSubscription = null;
|
|
425
|
-
if (
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
566
|
+
if (entity.stripeCustomerId) {
|
|
567
|
+
const existingSubscriptions = await services.getSubscriptionsByStripeCustomerId(entity.stripeCustomerId);
|
|
568
|
+
let activeSubscriptions = existingSubscriptions.filter((s) => s.status !== "canceled");
|
|
569
|
+
if (body.subscriptionId) activeSubscriptions = activeSubscriptions.filter((s) => s.id === body.subscriptionId);
|
|
570
|
+
if (activeSubscriptions.length > 1) return error({
|
|
571
|
+
message: "Multiple active subscriptions found for customer, please specify which subscription to upgrade",
|
|
572
|
+
code: "MULTIPLE_ACTIVE_SUBSCRIPTIONS"
|
|
573
|
+
}, 400);
|
|
574
|
+
if (activeSubscriptions.length === 0) return error({
|
|
575
|
+
message: "No active subscriptions found for customer",
|
|
576
|
+
code: "NO_ACTIVE_SUBSCRIPTIONS"
|
|
577
|
+
}, 400);
|
|
578
|
+
existingSubscription = activeSubscriptions[0];
|
|
431
579
|
if (customerId && existingSubscription.stripeCustomerId && existingSubscription.stripeCustomerId !== customerId) return error({
|
|
432
580
|
message: "Subsciption being updated does not belong to Stripe Customer that was provided",
|
|
433
581
|
code: "CUSTOMER_SUBSCRIPTION_MISMATCH"
|
|
@@ -436,33 +584,50 @@ const subscriptionsRoutesFactory = defineRoutes().create(({ deps, services, conf
|
|
|
436
584
|
}
|
|
437
585
|
if (!customerId) {
|
|
438
586
|
const existingLinkedCustomer = await deps.stripe.customers.search({
|
|
439
|
-
query: `metadata['referenceId']:'${
|
|
587
|
+
query: `metadata['referenceId']:'${entity.referenceId}'`,
|
|
440
588
|
limit: 1
|
|
441
589
|
});
|
|
442
590
|
if (existingLinkedCustomer.data.length === 1) customerId = existingLinkedCustomer.data[0].id;
|
|
443
591
|
else {
|
|
444
|
-
if (!
|
|
445
|
-
message: "New Stripe Customer must be created, but customerEmail or referenceID has not been
|
|
592
|
+
if (!entity.customerEmail || !entity.referenceId) return error({
|
|
593
|
+
message: "New Stripe Customer must be created, but customerEmail or referenceID has not been provided",
|
|
446
594
|
code: "MISSING_CUSTOMER_INFO"
|
|
447
595
|
});
|
|
448
596
|
const newCustomer = await deps.stripe.customers.create({
|
|
449
|
-
email:
|
|
597
|
+
email: entity.customerEmail,
|
|
450
598
|
metadata: {
|
|
451
|
-
referenceId:
|
|
452
|
-
...
|
|
599
|
+
referenceId: entity.referenceId,
|
|
600
|
+
...entity.stripeMetadata
|
|
453
601
|
}
|
|
454
602
|
});
|
|
455
603
|
customerId = newCustomer.id;
|
|
456
604
|
}
|
|
457
605
|
try {
|
|
458
|
-
await config.onStripeCustomerCreated(customerId,
|
|
606
|
+
await config.onStripeCustomerCreated(customerId, entity.referenceId);
|
|
459
607
|
} catch (error$1) {
|
|
460
608
|
deps.log.error("onStripeCustomerCreated failed!", error$1);
|
|
461
609
|
}
|
|
462
610
|
}
|
|
611
|
+
let promotionCodeId = void 0;
|
|
612
|
+
if (body.promotionCode) {
|
|
613
|
+
const promotionCodes = await deps.stripe.promotionCodes.list({
|
|
614
|
+
code: body.promotionCode,
|
|
615
|
+
active: true,
|
|
616
|
+
limit: 1
|
|
617
|
+
});
|
|
618
|
+
if (promotionCodes.data.length > 0) promotionCodeId = promotionCodes.data[0].id;
|
|
619
|
+
}
|
|
463
620
|
if (existingSubscription?.status === "active" || existingSubscription?.status === "trialing") {
|
|
464
621
|
const stripeSubscription = await deps.stripe.subscriptions.retrieve(existingSubscription.stripeSubscriptionId);
|
|
465
622
|
try {
|
|
623
|
+
if (existingSubscription.cancelAt != null) {
|
|
624
|
+
deps.log.info("un-cancelling subscription", stripeSubscription.id);
|
|
625
|
+
await deps.stripe.subscriptions.update(stripeSubscription.id, { cancel_at_period_end: false });
|
|
626
|
+
return json({
|
|
627
|
+
url: body.returnUrl || body.successUrl,
|
|
628
|
+
redirect: true
|
|
629
|
+
});
|
|
630
|
+
}
|
|
466
631
|
const portalSession = await deps.stripe.billingPortal.sessions.create({
|
|
467
632
|
customer: customerId,
|
|
468
633
|
return_url: body.returnUrl || body.successUrl,
|
|
@@ -478,7 +643,8 @@ const subscriptionsRoutesFactory = defineRoutes().create(({ deps, services, conf
|
|
|
478
643
|
id: stripeSubscription.items.data[0]?.id,
|
|
479
644
|
quantity: body.quantity,
|
|
480
645
|
price: body.priceId
|
|
481
|
-
}]
|
|
646
|
+
}],
|
|
647
|
+
...promotionCodeId && { discounts: [{ promotion_code: promotionCodeId }] }
|
|
482
648
|
}
|
|
483
649
|
}
|
|
484
650
|
});
|
|
@@ -499,7 +665,8 @@ const subscriptionsRoutesFactory = defineRoutes().create(({ deps, services, conf
|
|
|
499
665
|
quantity: body.quantity
|
|
500
666
|
}],
|
|
501
667
|
mode: "subscription",
|
|
502
|
-
metadata: { referenceId:
|
|
668
|
+
metadata: { referenceId: entity.referenceId },
|
|
669
|
+
...promotionCodeId && { discounts: [{ promotion_code: promotionCodeId }] }
|
|
503
670
|
});
|
|
504
671
|
return json({
|
|
505
672
|
url: checkoutSession.url,
|
|
@@ -511,7 +678,10 @@ const subscriptionsRoutesFactory = defineRoutes().create(({ deps, services, conf
|
|
|
511
678
|
defineRoute({
|
|
512
679
|
method: "POST",
|
|
513
680
|
path: "/subscription/cancel",
|
|
514
|
-
inputSchema: z.object({
|
|
681
|
+
inputSchema: z.object({
|
|
682
|
+
returnUrl: z.url().describe("URL to redirect to after cancellation is complete"),
|
|
683
|
+
subscriptionId: z.string().optional().describe("Which subscription to cancel, if there are multiple active subscriptions")
|
|
684
|
+
}),
|
|
515
685
|
outputSchema: z.object({
|
|
516
686
|
url: z.url().describe("URL to redirect to after cancellation"),
|
|
517
687
|
redirect: z.boolean().describe("Whether to redirect to the URL")
|
|
@@ -519,30 +689,30 @@ const subscriptionsRoutesFactory = defineRoutes().create(({ deps, services, conf
|
|
|
519
689
|
errorCodes: [
|
|
520
690
|
"SUBSCRIPTION_NOT_FOUND",
|
|
521
691
|
"NO_SUBSCRIPTION_TO_CANCEL",
|
|
522
|
-
"SUBSCRIPTION_ALREADY_CANCELED"
|
|
692
|
+
"SUBSCRIPTION_ALREADY_CANCELED",
|
|
693
|
+
"NO_STRIPE_CUSTOMER_LINKED",
|
|
694
|
+
"MULTIPLE_SUBSCRIPTIONS_FOUND"
|
|
523
695
|
],
|
|
524
696
|
handler: async (context, { json, error }) => {
|
|
525
697
|
const body = await context.input.valid();
|
|
526
|
-
const {
|
|
527
|
-
if (!
|
|
528
|
-
message: "No
|
|
698
|
+
const { stripeCustomerId } = await config.resolveEntityFromRequest(context);
|
|
699
|
+
if (!stripeCustomerId) return error({
|
|
700
|
+
message: "No stripe customer linked to entity",
|
|
701
|
+
code: "NO_STRIPE_CUSTOMER_LINKED"
|
|
702
|
+
}, 400);
|
|
703
|
+
let activeSubscriptions = (await services.getSubscriptionsByStripeCustomerId(stripeCustomerId)).filter((s) => s.status === "active");
|
|
704
|
+
if (body.subscriptionId) activeSubscriptions = activeSubscriptions.filter((s) => s.id === body.subscriptionId);
|
|
705
|
+
if (activeSubscriptions.length === 0) return error({
|
|
706
|
+
message: "No active subscription to cancel",
|
|
529
707
|
code: "NO_SUBSCRIPTION_TO_CANCEL"
|
|
530
708
|
}, 404);
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
code: "SUBSCRIPTION_NOT_FOUND"
|
|
535
|
-
}, 404);
|
|
536
|
-
if (subscription.status === "canceled") return error({
|
|
537
|
-
message: "Subscription is already canceled",
|
|
538
|
-
code: "SUBSCRIPTION_ALREADY_CANCELED"
|
|
709
|
+
if (activeSubscriptions.length > 1) return error({
|
|
710
|
+
message: "Multiple active subscriptions found",
|
|
711
|
+
code: "MULTIPLE_SUBSCRIPTIONS_FOUND"
|
|
539
712
|
}, 400);
|
|
713
|
+
const activeSubscription = activeSubscriptions[0];
|
|
540
714
|
try {
|
|
541
|
-
const
|
|
542
|
-
customer: subscription.stripeCustomerId,
|
|
543
|
-
status: "active"
|
|
544
|
-
});
|
|
545
|
-
const stripeSubscription = activeSubscriptions.data.find((sub) => sub.id === subscription.stripeSubscriptionId);
|
|
715
|
+
const stripeSubscription = await deps.stripe.subscriptions.retrieve(activeSubscription.stripeSubscriptionId);
|
|
546
716
|
if (!stripeSubscription) return error({
|
|
547
717
|
message: "Active subscription not found in Stripe",
|
|
548
718
|
code: "SUBSCRIPTION_NOT_FOUND"
|
|
@@ -552,7 +722,7 @@ const subscriptionsRoutesFactory = defineRoutes().create(({ deps, services, conf
|
|
|
552
722
|
redirect: false
|
|
553
723
|
});
|
|
554
724
|
const portalSession = await deps.stripe.billingPortal.sessions.create({
|
|
555
|
-
customer:
|
|
725
|
+
customer: activeSubscription.stripeCustomerId,
|
|
556
726
|
return_url: body.returnUrl,
|
|
557
727
|
flow_data: {
|
|
558
728
|
type: "subscription_cancel",
|
|
@@ -602,7 +772,7 @@ const ProductResponseSchema = z.object({
|
|
|
602
772
|
|
|
603
773
|
//#endregion
|
|
604
774
|
//#region src/routes/products.ts
|
|
605
|
-
const productsRoutesFactory = defineRoutes().create(({ deps, config }) => {
|
|
775
|
+
const productsRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ deps, config, defineRoute }) => {
|
|
606
776
|
return [defineRoute({
|
|
607
777
|
method: "GET",
|
|
608
778
|
path: "/admin/products",
|
|
@@ -672,7 +842,7 @@ const PriceResponseSchema = z.object({
|
|
|
672
842
|
|
|
673
843
|
//#endregion
|
|
674
844
|
//#region src/routes/prices.ts
|
|
675
|
-
const pricesRoutesFactory = defineRoutes().create(({ deps, config }) => {
|
|
845
|
+
const pricesRoutesFactory = defineRoutes(stripeFragmentDefinition).create(({ deps, config, defineRoute }) => {
|
|
676
846
|
return [defineRoute({
|
|
677
847
|
method: "GET",
|
|
678
848
|
path: "/admin/products/:productId/prices",
|
|
@@ -707,143 +877,24 @@ const pricesRoutesFactory = defineRoutes().create(({ deps, config }) => {
|
|
|
707
877
|
|
|
708
878
|
//#endregion
|
|
709
879
|
//#region src/index.ts
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
};
|
|
718
|
-
const stripeFragmentDefinition = defineFragmentWithDatabase("stripe").withDatabase(stripeSchema, "stripe").withDependencies(({ config }) => {
|
|
719
|
-
const stripeClient = new Stripe(config.stripeSecretKey, config.stripeClientOptions ?? {});
|
|
720
|
-
return {
|
|
721
|
-
stripe: stripeClient,
|
|
722
|
-
log: config.logger ? config.logger : defaultLogger
|
|
723
|
-
};
|
|
724
|
-
}).providesService(({ deps, db, defineService }) => {
|
|
725
|
-
return defineService({ ...createStripeServices(deps, db) });
|
|
726
|
-
});
|
|
727
|
-
function createStripeServices(deps, db) {
|
|
728
|
-
const services = {
|
|
729
|
-
getStripeClient() {
|
|
730
|
-
return deps.stripe;
|
|
731
|
-
},
|
|
732
|
-
createSubscription: async (data) => {
|
|
733
|
-
return (await db.create("subscription", data)).externalId;
|
|
734
|
-
},
|
|
735
|
-
updateSubscription: async (id, data) => {
|
|
736
|
-
await db.update("subscription", id, (b) => b.set({
|
|
737
|
-
...data,
|
|
738
|
-
updatedAt: new Date()
|
|
739
|
-
}));
|
|
740
|
-
},
|
|
741
|
-
getSubscriptionByStripeId: async (stripeSubscriptionId) => {
|
|
742
|
-
const result = await db.findFirst("subscription", (b) => b.whereIndex("idx_stripe_subscription_id", (eb) => eb("stripeSubscriptionId", "=", stripeSubscriptionId)));
|
|
743
|
-
if (!result) return null;
|
|
744
|
-
return {
|
|
745
|
-
...result,
|
|
746
|
-
id: result.id.externalId,
|
|
747
|
-
status: result.status
|
|
748
|
-
};
|
|
749
|
-
},
|
|
750
|
-
getSubscriptionByStripeCustomerId: async (stripeCustomerId) => {
|
|
751
|
-
return (await db.find("subscription", (b) => b.whereIndex("idx_stripe_customer_id", (eb) => eb("stripeCustomerId", "=", stripeCustomerId)))).map((subscription) => ({
|
|
752
|
-
...subscription,
|
|
753
|
-
id: subscription.id.externalId,
|
|
754
|
-
status: subscription.status
|
|
755
|
-
}));
|
|
756
|
-
},
|
|
757
|
-
getSubscriptionById: async (id) => {
|
|
758
|
-
const result = await db.findFirst("subscription", (b) => b.whereIndex("primary", (eb) => eb("id", "=", id)));
|
|
759
|
-
if (!result) return null;
|
|
760
|
-
return {
|
|
761
|
-
...result,
|
|
762
|
-
id: result.id.externalId,
|
|
763
|
-
status: result.status
|
|
764
|
-
};
|
|
765
|
-
},
|
|
766
|
-
getSubscriptionByReferenceId: async (referenceId) => {
|
|
767
|
-
const result = await db.findFirst("subscription", (b) => b.whereIndex("idx_reference_id", (eb) => eb("referenceId", "=", referenceId)));
|
|
768
|
-
if (!result) return null;
|
|
769
|
-
return {
|
|
770
|
-
...result,
|
|
771
|
-
id: result.id.externalId,
|
|
772
|
-
status: result.status
|
|
773
|
-
};
|
|
774
|
-
},
|
|
775
|
-
deleteSubscription: async (id) => {
|
|
776
|
-
await db.delete("subscription", id);
|
|
777
|
-
},
|
|
778
|
-
deleteSubscriptionByReferenceId: async (referenceId) => {
|
|
779
|
-
const uow = db.createUnitOfWork().find("subscription", (b) => b.whereIndex("idx_reference_id", (eb) => eb("referenceId", "=", referenceId)));
|
|
780
|
-
const [subscriptions] = await uow.executeRetrieve();
|
|
781
|
-
const subscription = subscriptions[0];
|
|
782
|
-
if (subscription) {
|
|
783
|
-
uow.delete("subscription", subscription.id);
|
|
784
|
-
const success = await uow.executeMutations();
|
|
785
|
-
if (!success) throw new Error("Failed to deleted subscription, conflict on subscription resource");
|
|
786
|
-
return true;
|
|
787
|
-
}
|
|
788
|
-
return false;
|
|
789
|
-
},
|
|
790
|
-
getAllSubscriptions: async () => {
|
|
791
|
-
return (await db.find("subscription", (b) => b.whereIndex("primary"))).map((subscription) => ({
|
|
792
|
-
...subscription,
|
|
793
|
-
id: subscription.id.externalId,
|
|
794
|
-
status: subscription.status
|
|
795
|
-
}));
|
|
796
|
-
},
|
|
797
|
-
syncStripeSubscription: async (referenceId, stripeCustomerId) => {
|
|
798
|
-
const stripeSubscriptions = await deps.stripe.subscriptions.list({
|
|
799
|
-
customer: stripeCustomerId,
|
|
800
|
-
limit: 1,
|
|
801
|
-
status: "all"
|
|
802
|
-
});
|
|
803
|
-
if (stripeSubscriptions.data.length === 0) {
|
|
804
|
-
await services.deleteSubscriptionByReferenceId(referenceId);
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
const stripeSubscription = stripeSubscriptions.data[0];
|
|
808
|
-
const existingSubscription = await db.findFirst("subscription", (b) => b.whereIndex("idx_stripe_subscription_id", (eb) => eb("stripeSubscriptionId", "=", stripeSubscription.id)));
|
|
809
|
-
if (existingSubscription) await db.update("subscription", existingSubscription.id, (b) => b.set({
|
|
810
|
-
...stripeSubscriptionToInternalSubscription(stripeSubscription),
|
|
811
|
-
updatedAt: new Date()
|
|
812
|
-
}));
|
|
813
|
-
else {
|
|
814
|
-
const subscriptionData = {
|
|
815
|
-
...stripeSubscriptionToInternalSubscription(stripeSubscription),
|
|
816
|
-
referenceId: referenceId ?? null
|
|
817
|
-
};
|
|
818
|
-
await services.createSubscription(subscriptionData);
|
|
819
|
-
}
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
};
|
|
823
|
-
return services;
|
|
824
|
-
}
|
|
880
|
+
const routes = [
|
|
881
|
+
webhookRoutesFactory,
|
|
882
|
+
customersRoutesFactory,
|
|
883
|
+
subscriptionsRoutesFactory,
|
|
884
|
+
productsRoutesFactory,
|
|
885
|
+
pricesRoutesFactory
|
|
886
|
+
];
|
|
825
887
|
function createStripeFragment(config, fragnoConfig) {
|
|
826
|
-
return
|
|
827
|
-
webhookRoutesFactory,
|
|
828
|
-
customersRoutesFactory,
|
|
829
|
-
subscriptionsRoutesFactory,
|
|
830
|
-
productsRoutesFactory,
|
|
831
|
-
pricesRoutesFactory
|
|
832
|
-
], fragnoConfig);
|
|
888
|
+
return instantiate(stripeFragmentDefinition).withConfig(config).withRoutes(routes).withOptions(fragnoConfig).build();
|
|
833
889
|
}
|
|
834
890
|
function createStripeFragmentClients(fragnoConfig = {}) {
|
|
835
|
-
const builder = createClientBuilder(stripeFragmentDefinition, fragnoConfig,
|
|
836
|
-
webhookRoutesFactory,
|
|
837
|
-
customersRoutesFactory,
|
|
838
|
-
subscriptionsRoutesFactory,
|
|
839
|
-
productsRoutesFactory,
|
|
840
|
-
pricesRoutesFactory
|
|
841
|
-
]);
|
|
891
|
+
const builder = createClientBuilder(stripeFragmentDefinition, fragnoConfig, routes);
|
|
842
892
|
return {
|
|
843
893
|
useCustomers: builder.createHook("/admin/customers"),
|
|
844
894
|
useProducts: builder.createHook("/admin/products"),
|
|
845
895
|
usePrices: builder.createHook("/admin/products/:productId/prices"),
|
|
846
896
|
useSubscription: builder.createHook("/admin/subscriptions"),
|
|
897
|
+
useBillingPortal: builder.createMutator("POST", "/portal"),
|
|
847
898
|
upgradeSubscription: builder.createMutator("POST", "/subscription/upgrade"),
|
|
848
899
|
cancelSubscription: builder.createMutator("POST", "/subscription/cancel")
|
|
849
900
|
};
|