@faire-auth/stripe 1.0.0

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/overrides.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type {
2
+ AuthRouteConfig,
3
+ JSONRenderRespond,
4
+ // LiteralStringUnion,
5
+ } from "@faire-auth/core/types";
6
+ import type { AuthContext } from "faire-auth";
7
+
8
+ declare module "hono" {
9
+ export interface ContextRenderer extends JSONRenderRespond {}
10
+ export interface ContextVariableMap {
11
+ /**
12
+ * Gets set early before hitting route handler
13
+ */
14
+ // path: LiteralStringUnion<Configs["path"]>;
15
+ /**
16
+ * Gets set at route handler for intraroute consistency
17
+ */
18
+ config?: AuthRouteConfig;
19
+ /**
20
+ * Gets set by initContext middleware
21
+ */
22
+ context: AuthContext;
23
+ /**
24
+ * Gets set in endpoint Route Apps
25
+ */
26
+ isServer?: true;
27
+ /**
28
+ * Gets set by initContext middleware
29
+ */
30
+ // api: DefaultAPI;
31
+ }
32
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@faire-auth/stripe",
3
+ "author": "Bereket Engida",
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "stripe",
9
+ "auth",
10
+ "stripe"
11
+ ],
12
+ "main": "dist/index.cjs",
13
+ "module": "dist/index.mjs",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/igoforth/faire-auth.git",
17
+ "directory": "packages/stripe"
18
+ },
19
+ "description": "Stripe plugin for Faire Auth",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "dev-source": "./src/index.ts",
26
+ "import": {
27
+ "types": "./dist/index.d.mts",
28
+ "default": "./dist/index.mjs"
29
+ },
30
+ "require": {
31
+ "types": "./dist/index.d.cts",
32
+ "default": "./dist/index.cjs"
33
+ }
34
+ },
35
+ "./client": {
36
+ "dev-source": "./src/client.ts",
37
+ "import": {
38
+ "types": "./dist/client.d.mts",
39
+ "default": "./dist/client.mjs"
40
+ },
41
+ "require": {
42
+ "types": "./dist/client.d.cts",
43
+ "default": "./dist/client.cjs"
44
+ }
45
+ }
46
+ },
47
+ "dependencies": {
48
+ "zod": "4.1.4"
49
+ },
50
+ "peerDependencies": {
51
+ "stripe": "^18 || ^19",
52
+ "hono": ">=4.3.6",
53
+ "@faire-auth/core": "1.0.0",
54
+ "faire-auth": "1.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "stripe": "^18.5.0",
58
+ "tsdown": "^0.16.0",
59
+ "publint": "^0.3.15",
60
+ "@faire-auth/core": "1.0.0",
61
+ "faire-auth": "1.0.0"
62
+ },
63
+ "scripts": {
64
+ "test": "vitest",
65
+ "build": "tsdown",
66
+ "dev": "tsdown --watch",
67
+ "typecheck": "tsc --project tsconfig.json"
68
+ }
69
+ }
package/src/client.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { FaireAuthClientPlugin } from "faire-auth";
2
+ import type { stripe } from "./index";
3
+
4
+ export const stripeClient = <
5
+ O extends {
6
+ subscription: boolean;
7
+ },
8
+ >(
9
+ options?: O,
10
+ ) => {
11
+ return {
12
+ id: "stripe-client",
13
+ $InferServerPlugin: {} as ReturnType<
14
+ typeof stripe<
15
+ O["subscription"] extends true
16
+ ? {
17
+ stripeClient: any;
18
+ stripeWebhookSecret: string;
19
+ subscription: {
20
+ enabled: true;
21
+ plans: [];
22
+ };
23
+ }
24
+ : {
25
+ stripeClient: any;
26
+ stripeWebhookSecret: string;
27
+ }
28
+ >
29
+ >,
30
+ pathMethods: {
31
+ "/subscription/restore": "POST",
32
+ "/subscription/billing-portal": "POST",
33
+ },
34
+ } satisfies FaireAuthClientPlugin;
35
+ };
package/src/hooks.ts ADDED
@@ -0,0 +1,265 @@
1
+ import type { Context } from "@faire-auth/core/types";
2
+ import { logger, type ContextVars } from "faire-auth";
3
+ import type Stripe from "stripe";
4
+ import type { InputSubscription, StripeOptions, Subscription } from "./types";
5
+ import { getPlanByPriceInfo } from "./utils";
6
+
7
+ export async function onCheckoutSessionCompleted(
8
+ ctx: Context<ContextVars>,
9
+ options: StripeOptions,
10
+ event: Stripe.Event,
11
+ ) {
12
+ try {
13
+ const client = options.stripeClient;
14
+ const checkoutSession = event.data.object as Stripe.Checkout.Session;
15
+ if (checkoutSession.mode === "setup" || !options.subscription?.enabled) {
16
+ return;
17
+ }
18
+ const subscription = await client.subscriptions.retrieve(
19
+ checkoutSession.subscription as string,
20
+ );
21
+ const priceId = subscription.items.data[0]?.price.id;
22
+ const priceLookupKey = subscription.items.data[0]?.price.lookup_key || null;
23
+ const plan = await getPlanByPriceInfo(
24
+ options,
25
+ priceId as string,
26
+ priceLookupKey,
27
+ );
28
+ if (plan) {
29
+ const referenceId =
30
+ checkoutSession?.client_reference_id ||
31
+ checkoutSession?.metadata?.referenceId;
32
+ const subscriptionId = checkoutSession?.metadata?.subscriptionId;
33
+ const seats = subscription.items.data[0]!.quantity;
34
+ if (referenceId && subscriptionId) {
35
+ const trial =
36
+ subscription.trial_start && subscription.trial_end
37
+ ? {
38
+ trialStart: new Date(subscription.trial_start * 1000),
39
+ trialEnd: new Date(subscription.trial_end * 1000),
40
+ }
41
+ : {};
42
+
43
+ let dbSubscription = await ctx
44
+ .get("context")
45
+ .adapter.update<InputSubscription>({
46
+ model: "subscription",
47
+ update: {
48
+ plan: plan.name.toLowerCase(),
49
+ status: subscription.status,
50
+ updatedAt: new Date(),
51
+ periodStart: new Date(
52
+ subscription.items.data[0]!.current_period_start * 1000,
53
+ ),
54
+ periodEnd: new Date(
55
+ subscription.items.data[0]!.current_period_end * 1000,
56
+ ),
57
+ stripeSubscriptionId: checkoutSession.subscription as string,
58
+ seats,
59
+ ...trial,
60
+ },
61
+ where: [
62
+ {
63
+ field: "id",
64
+ value: subscriptionId,
65
+ },
66
+ ],
67
+ });
68
+
69
+ if (trial.trialStart && plan.freeTrial?.onTrialStart) {
70
+ await plan.freeTrial.onTrialStart(dbSubscription as Subscription);
71
+ }
72
+
73
+ if (!dbSubscription) {
74
+ dbSubscription = await ctx
75
+ .get("context")
76
+ .adapter.findOne<Subscription>({
77
+ model: "subscription",
78
+ where: [
79
+ {
80
+ field: "id",
81
+ value: subscriptionId,
82
+ },
83
+ ],
84
+ });
85
+ }
86
+ await options.subscription?.onSubscriptionComplete?.(
87
+ {
88
+ event,
89
+ subscription: dbSubscription as Subscription,
90
+ stripeSubscription: subscription,
91
+ plan,
92
+ },
93
+ ctx,
94
+ );
95
+ return;
96
+ }
97
+ }
98
+ } catch (e: any) {
99
+ logger.error(`Stripe webhook failed. Error: ${e.message}`);
100
+ }
101
+ }
102
+
103
+ export async function onSubscriptionUpdated(
104
+ ctx: Context<ContextVars>,
105
+ options: StripeOptions,
106
+ event: Stripe.Event,
107
+ ) {
108
+ try {
109
+ if (!options.subscription?.enabled) {
110
+ return;
111
+ }
112
+ const subscriptionUpdated = event.data.object as Stripe.Subscription;
113
+ const priceId = subscriptionUpdated.items.data[0]!.price.id;
114
+ const priceLookupKey =
115
+ subscriptionUpdated.items.data[0]!.price.lookup_key || null;
116
+ const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
117
+
118
+ const subscriptionId = subscriptionUpdated.metadata?.subscriptionId;
119
+ const customerId = subscriptionUpdated.customer?.toString();
120
+ let subscription = await ctx.get("context").adapter.findOne<Subscription>({
121
+ model: "subscription",
122
+ where: subscriptionId
123
+ ? [{ field: "id", value: subscriptionId }]
124
+ : [{ field: "stripeSubscriptionId", value: subscriptionUpdated.id }],
125
+ });
126
+ if (!subscription) {
127
+ const subs = await ctx.get("context").adapter.findMany<Subscription>({
128
+ model: "subscription",
129
+ where: [{ field: "stripeCustomerId", value: customerId }],
130
+ });
131
+ if (subs.length > 1) {
132
+ const activeSub = subs.find(
133
+ (sub: Subscription) =>
134
+ sub.status === "active" || sub.status === "trialing",
135
+ );
136
+ if (!activeSub) {
137
+ logger.warn(
138
+ `Stripe webhook error: Multiple subscriptions found for customerId: ${customerId} and no active subscription is found`,
139
+ );
140
+ return;
141
+ }
142
+ subscription = activeSub;
143
+ } else {
144
+ subscription = subs[0]!;
145
+ }
146
+ }
147
+
148
+ const seats = subscriptionUpdated.items.data[0]!.quantity;
149
+ await ctx.get("context").adapter.update({
150
+ model: "subscription",
151
+ update: {
152
+ ...(plan
153
+ ? {
154
+ plan: plan.name.toLowerCase(),
155
+ limits: plan.limits,
156
+ }
157
+ : {}),
158
+ updatedAt: new Date(),
159
+ status: subscriptionUpdated.status,
160
+ periodStart: new Date(
161
+ subscriptionUpdated.items.data[0]!.current_period_start * 1000,
162
+ ),
163
+ periodEnd: new Date(
164
+ subscriptionUpdated.items.data[0]!.current_period_end * 1000,
165
+ ),
166
+ cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end,
167
+ seats,
168
+ stripeSubscriptionId: subscriptionUpdated.id,
169
+ },
170
+ where: [
171
+ {
172
+ field: "id",
173
+ value: subscription.id,
174
+ },
175
+ ],
176
+ });
177
+ const subscriptionCanceled =
178
+ subscriptionUpdated.status === "active" &&
179
+ subscriptionUpdated.cancel_at_period_end &&
180
+ !subscription.cancelAtPeriodEnd; //if this is true, it means the subscription was canceled before the event was triggered
181
+ if (subscriptionCanceled) {
182
+ await options.subscription.onSubscriptionCancel?.({
183
+ subscription,
184
+ ...(subscriptionUpdated.cancellation_details !== undefined && {
185
+ cancellationDetails: subscriptionUpdated.cancellation_details,
186
+ }),
187
+ stripeSubscription: subscriptionUpdated,
188
+ event,
189
+ });
190
+ }
191
+ await options.subscription.onSubscriptionUpdate?.({
192
+ event,
193
+ subscription,
194
+ });
195
+ if (plan) {
196
+ if (
197
+ subscriptionUpdated.status === "active" &&
198
+ subscription.status === "trialing" &&
199
+ plan.freeTrial?.onTrialEnd
200
+ ) {
201
+ await plan.freeTrial.onTrialEnd({ subscription }, ctx);
202
+ }
203
+ if (
204
+ subscriptionUpdated.status === "incomplete_expired" &&
205
+ subscription.status === "trialing" &&
206
+ plan.freeTrial?.onTrialExpired
207
+ ) {
208
+ await plan.freeTrial.onTrialExpired(subscription, ctx);
209
+ }
210
+ }
211
+ } catch (error: any) {
212
+ logger.error(`Stripe webhook failed. Error: ${error}`);
213
+ }
214
+ }
215
+
216
+ export async function onSubscriptionDeleted(
217
+ ctx: Context<ContextVars>,
218
+ options: StripeOptions,
219
+ event: Stripe.Event,
220
+ ) {
221
+ if (!options.subscription?.enabled) {
222
+ return;
223
+ }
224
+ try {
225
+ const subscriptionDeleted = event.data.object as Stripe.Subscription;
226
+ const subscriptionId = subscriptionDeleted.id;
227
+ const subscription = await ctx
228
+ .get("context")
229
+ .adapter.findOne<Subscription>({
230
+ model: "subscription",
231
+ where: [
232
+ {
233
+ field: "stripeSubscriptionId",
234
+ value: subscriptionId,
235
+ },
236
+ ],
237
+ });
238
+ if (subscription) {
239
+ await ctx.get("context").adapter.update({
240
+ model: "subscription",
241
+ where: [
242
+ {
243
+ field: "id",
244
+ value: subscription.id,
245
+ },
246
+ ],
247
+ update: {
248
+ status: "canceled",
249
+ updatedAt: new Date(),
250
+ },
251
+ });
252
+ await options.subscription.onSubscriptionDeleted?.({
253
+ event,
254
+ stripeSubscription: subscriptionDeleted,
255
+ subscription,
256
+ });
257
+ } else {
258
+ logger.warn(
259
+ `Stripe webhook error: Subscription not found for subscriptionId: ${subscriptionId}`,
260
+ );
261
+ }
262
+ } catch (error: any) {
263
+ logger.error(`Stripe webhook failed. Error: ${error}`);
264
+ }
265
+ }