@codaijs/keel 0.1.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/dist/__tests__/cli.test.d.ts +2 -0
- package/dist/__tests__/cli.test.d.ts.map +1 -0
- package/dist/__tests__/cli.test.js +173 -0
- package/dist/__tests__/cli.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +86 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/sail-installer.test.d.ts +2 -0
- package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
- package/dist/__tests__/sail-installer.test.js +158 -0
- package/dist/__tests__/sail-installer.test.js.map +1 -0
- package/dist/create-runner.d.ts +11 -0
- package/dist/create-runner.d.ts.map +1 -0
- package/dist/create-runner.js +63 -0
- package/dist/create-runner.js.map +1 -0
- package/dist/create.d.ts +10 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/create.js +15 -0
- package/dist/create.js.map +1 -0
- package/dist/manage.d.ts +24 -0
- package/dist/manage.d.ts.map +1 -0
- package/dist/manage.js +1461 -0
- package/dist/manage.js.map +1 -0
- package/dist/prompts.d.ts +36 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +208 -0
- package/dist/prompts.js.map +1 -0
- package/dist/sail-installer.d.ts +37 -0
- package/dist/sail-installer.d.ts.map +1 -0
- package/dist/sail-installer.js +935 -0
- package/dist/sail-installer.js.map +1 -0
- package/dist/scaffold.d.ts +10 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +297 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +57 -0
- package/sails/_template/addon.json +20 -0
- package/sails/_template/install.ts +402 -0
- package/sails/admin-dashboard/README.md +117 -0
- package/sails/admin-dashboard/addon.json +28 -0
- package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
- package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
- package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
- package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
- package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
- package/sails/admin-dashboard/install.ts +305 -0
- package/sails/analytics/README.md +178 -0
- package/sails/analytics/addon.json +27 -0
- package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
- package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
- package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
- package/sails/analytics/install.ts +297 -0
- package/sails/file-uploads/README.md +191 -0
- package/sails/file-uploads/addon.json +30 -0
- package/sails/file-uploads/files/backend/routes/files.ts +198 -0
- package/sails/file-uploads/files/backend/schema/files.ts +36 -0
- package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
- package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
- package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
- package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
- package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
- package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
- package/sails/file-uploads/install.ts +466 -0
- package/sails/gdpr/README.md +174 -0
- package/sails/gdpr/addon.json +27 -0
- package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
- package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
- package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
- package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
- package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
- package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
- package/sails/gdpr/install.ts +756 -0
- package/sails/google-oauth/README.md +121 -0
- package/sails/google-oauth/addon.json +22 -0
- package/sails/google-oauth/files/GoogleButton.tsx +50 -0
- package/sails/google-oauth/install.ts +252 -0
- package/sails/i18n/README.md +193 -0
- package/sails/i18n/addon.json +30 -0
- package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
- package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
- package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
- package/sails/i18n/files/frontend/locales/de/common.json +44 -0
- package/sails/i18n/files/frontend/locales/en/common.json +44 -0
- package/sails/i18n/install.ts +407 -0
- package/sails/push-notifications/README.md +163 -0
- package/sails/push-notifications/addon.json +31 -0
- package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
- package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
- package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
- package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
- package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
- package/sails/push-notifications/install.ts +384 -0
- package/sails/r2-storage/README.md +101 -0
- package/sails/r2-storage/addon.json +29 -0
- package/sails/r2-storage/files/backend/services/storage.ts +71 -0
- package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
- package/sails/r2-storage/install.ts +412 -0
- package/sails/rate-limiting/README.md +145 -0
- package/sails/rate-limiting/addon.json +20 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
- package/sails/rate-limiting/install.ts +300 -0
- package/sails/registry.json +107 -0
- package/sails/stripe/README.md +214 -0
- package/sails/stripe/addon.json +24 -0
- package/sails/stripe/files/backend/routes/stripe.ts +154 -0
- package/sails/stripe/files/backend/schema/stripe.ts +74 -0
- package/sails/stripe/files/backend/services/stripe.ts +224 -0
- package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
- package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
- package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
- package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
- package/sails/stripe/install.ts +378 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from "express";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { db } from "../db";
|
|
4
|
+
import { customers, subscriptions } from "../db/schema/stripe";
|
|
5
|
+
import {
|
|
6
|
+
createOrGetCustomer,
|
|
7
|
+
createCheckoutSession,
|
|
8
|
+
createPortalSession,
|
|
9
|
+
handleWebhookEvent,
|
|
10
|
+
} from "../services/stripe";
|
|
11
|
+
import { requireAuth } from "../middleware/auth";
|
|
12
|
+
|
|
13
|
+
export const stripeRouter = Router();
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// POST /create-checkout-session
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
stripeRouter.post(
|
|
20
|
+
"/create-checkout-session",
|
|
21
|
+
requireAuth,
|
|
22
|
+
async (req: Request, res: Response) => {
|
|
23
|
+
try {
|
|
24
|
+
const { priceId } = req.body;
|
|
25
|
+
|
|
26
|
+
if (!priceId || typeof priceId !== "string") {
|
|
27
|
+
return res.status(400).json({ error: "priceId is required" });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const user = req.user!;
|
|
31
|
+
const stripeCustomerId = await createOrGetCustomer(user.id, user.email);
|
|
32
|
+
|
|
33
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
34
|
+
const session = await createCheckoutSession(
|
|
35
|
+
stripeCustomerId,
|
|
36
|
+
priceId,
|
|
37
|
+
`${baseUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
38
|
+
`${baseUrl}/checkout/cancel`
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return res.json({ url: session.url });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error("Error creating checkout session:", error);
|
|
44
|
+
return res.status(500).json({ error: "Failed to create checkout session" });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// POST /create-portal-session
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
stripeRouter.post(
|
|
54
|
+
"/create-portal-session",
|
|
55
|
+
requireAuth,
|
|
56
|
+
async (req: Request, res: Response) => {
|
|
57
|
+
try {
|
|
58
|
+
const user = req.user!;
|
|
59
|
+
|
|
60
|
+
const customer = await db.query.customers.findFirst({
|
|
61
|
+
where: eq(customers.userId, user.id),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!customer) {
|
|
65
|
+
return res.status(404).json({ error: "No subscription found" });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
69
|
+
const session = await createPortalSession(
|
|
70
|
+
customer.stripeCustomerId,
|
|
71
|
+
`${baseUrl}/settings`
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return res.json({ url: session.url });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error("Error creating portal session:", error);
|
|
77
|
+
return res.status(500).json({ error: "Failed to create portal session" });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// POST /webhook
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stripe webhook endpoint.
|
|
88
|
+
*
|
|
89
|
+
* IMPORTANT: This route must receive the raw request body (not parsed as JSON).
|
|
90
|
+
* The Express app must be configured with:
|
|
91
|
+
*
|
|
92
|
+
* app.use("/api/stripe/webhook", express.raw({ type: "application/json" }));
|
|
93
|
+
*
|
|
94
|
+
* This should be placed BEFORE the general express.json() middleware.
|
|
95
|
+
*/
|
|
96
|
+
stripeRouter.post("/webhook", async (req: Request, res: Response) => {
|
|
97
|
+
const signature = req.headers["stripe-signature"];
|
|
98
|
+
|
|
99
|
+
if (!signature || typeof signature !== "string") {
|
|
100
|
+
return res.status(400).json({ error: "Missing stripe-signature header" });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await handleWebhookEvent(req.body, signature);
|
|
105
|
+
return res.json({ received: true });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error("Webhook error:", error);
|
|
108
|
+
return res
|
|
109
|
+
.status(400)
|
|
110
|
+
.json({ error: error instanceof Error ? error.message : "Webhook failed" });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// GET /subscription
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
stripeRouter.get(
|
|
119
|
+
"/subscription",
|
|
120
|
+
requireAuth,
|
|
121
|
+
async (req: Request, res: Response) => {
|
|
122
|
+
try {
|
|
123
|
+
const user = req.user!;
|
|
124
|
+
|
|
125
|
+
const customer = await db.query.customers.findFirst({
|
|
126
|
+
where: eq(customers.userId, user.id),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!customer) {
|
|
130
|
+
return res.json({ subscription: null });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const subscription = await db.query.subscriptions.findFirst({
|
|
134
|
+
where: eq(subscriptions.customerId, customer.id),
|
|
135
|
+
orderBy: (sub, { desc }) => [desc(sub.createdAt)],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return res.json({
|
|
139
|
+
subscription: subscription
|
|
140
|
+
? {
|
|
141
|
+
id: subscription.id,
|
|
142
|
+
status: subscription.status,
|
|
143
|
+
priceId: subscription.stripePriceId,
|
|
144
|
+
currentPeriodEnd: subscription.currentPeriodEnd,
|
|
145
|
+
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
146
|
+
}
|
|
147
|
+
: null,
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error("Error fetching subscription:", error);
|
|
151
|
+
return res.status(500).json({ error: "Failed to fetch subscription" });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
text,
|
|
4
|
+
timestamp,
|
|
5
|
+
boolean,
|
|
6
|
+
uuid,
|
|
7
|
+
} from "drizzle-orm/pg-core";
|
|
8
|
+
import { relations } from "drizzle-orm";
|
|
9
|
+
import { users } from "./users";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stripe customers table.
|
|
13
|
+
*
|
|
14
|
+
* Maps internal user IDs to Stripe customer IDs. Each user has at most one
|
|
15
|
+
* Stripe customer record, created on their first checkout session.
|
|
16
|
+
*/
|
|
17
|
+
export const customers = pgTable("stripe_customers", {
|
|
18
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
19
|
+
userId: text("user_id")
|
|
20
|
+
.notNull()
|
|
21
|
+
.unique()
|
|
22
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
23
|
+
stripeCustomerId: text("stripe_customer_id").notNull().unique(),
|
|
24
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
25
|
+
.notNull()
|
|
26
|
+
.defaultNow(),
|
|
27
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
28
|
+
.notNull()
|
|
29
|
+
.defaultNow()
|
|
30
|
+
.$onUpdate(() => new Date()),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const customersRelations = relations(customers, ({ one, many }) => ({
|
|
34
|
+
user: one(users, {
|
|
35
|
+
fields: [customers.userId],
|
|
36
|
+
references: [users.id],
|
|
37
|
+
}),
|
|
38
|
+
subscriptions: many(subscriptions),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Stripe subscriptions table.
|
|
43
|
+
*
|
|
44
|
+
* Tracks the state of each Stripe subscription. Updated via webhooks whenever
|
|
45
|
+
* Stripe fires subscription lifecycle events.
|
|
46
|
+
*/
|
|
47
|
+
export const subscriptions = pgTable("stripe_subscriptions", {
|
|
48
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
49
|
+
customerId: uuid("customer_id")
|
|
50
|
+
.notNull()
|
|
51
|
+
.references(() => customers.id, { onDelete: "cascade" }),
|
|
52
|
+
stripeSubscriptionId: text("stripe_subscription_id").notNull().unique(),
|
|
53
|
+
status: text("status").notNull().default("incomplete"),
|
|
54
|
+
stripePriceId: text("stripe_price_id").notNull(),
|
|
55
|
+
currentPeriodStart: timestamp("current_period_start", {
|
|
56
|
+
withTimezone: true,
|
|
57
|
+
}),
|
|
58
|
+
currentPeriodEnd: timestamp("current_period_end", { withTimezone: true }),
|
|
59
|
+
cancelAtPeriodEnd: boolean("cancel_at_period_end").notNull().default(false),
|
|
60
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
61
|
+
.notNull()
|
|
62
|
+
.defaultNow(),
|
|
63
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
64
|
+
.notNull()
|
|
65
|
+
.defaultNow()
|
|
66
|
+
.$onUpdate(() => new Date()),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({
|
|
70
|
+
customer: one(customers, {
|
|
71
|
+
fields: [subscriptions.customerId],
|
|
72
|
+
references: [customers.id],
|
|
73
|
+
}),
|
|
74
|
+
}));
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { db } from "../db";
|
|
4
|
+
import { customers, subscriptions } from "../db/schema/stripe";
|
|
5
|
+
import { env } from "../env";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Stripe SDK instance
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
|
|
12
|
+
apiVersion: "2024-12-18.acacia",
|
|
13
|
+
typescript: true,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Customer management
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Retrieve the existing Stripe customer for a user, or create one.
|
|
22
|
+
*/
|
|
23
|
+
export async function createOrGetCustomer(
|
|
24
|
+
userId: string,
|
|
25
|
+
email: string
|
|
26
|
+
): Promise<string> {
|
|
27
|
+
// Check for existing customer record
|
|
28
|
+
const existing = await db.query.customers.findFirst({
|
|
29
|
+
where: eq(customers.userId, userId),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (existing) {
|
|
33
|
+
return existing.stripeCustomerId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Create a new Stripe customer
|
|
37
|
+
const stripeCustomer = await stripe.customers.create({
|
|
38
|
+
email,
|
|
39
|
+
metadata: { userId },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Persist the mapping
|
|
43
|
+
await db.insert(customers).values({
|
|
44
|
+
userId,
|
|
45
|
+
stripeCustomerId: stripeCustomer.id,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return stripeCustomer.id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Checkout session
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create a Stripe Checkout session for a subscription.
|
|
57
|
+
*/
|
|
58
|
+
export async function createCheckoutSession(
|
|
59
|
+
customerId: string,
|
|
60
|
+
priceId: string,
|
|
61
|
+
successUrl: string,
|
|
62
|
+
cancelUrl: string
|
|
63
|
+
): Promise<Stripe.Checkout.Session> {
|
|
64
|
+
return stripe.checkout.sessions.create({
|
|
65
|
+
customer: customerId,
|
|
66
|
+
mode: "subscription",
|
|
67
|
+
payment_method_types: ["card"],
|
|
68
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
69
|
+
success_url: successUrl,
|
|
70
|
+
cancel_url: cancelUrl,
|
|
71
|
+
subscription_data: {
|
|
72
|
+
metadata: { customerId },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Customer portal session
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a Stripe Customer Portal session so the user can manage their
|
|
83
|
+
* subscription, update payment methods, or cancel.
|
|
84
|
+
*/
|
|
85
|
+
export async function createPortalSession(
|
|
86
|
+
customerId: string,
|
|
87
|
+
returnUrl: string
|
|
88
|
+
): Promise<Stripe.BillingPortal.Session> {
|
|
89
|
+
return stripe.billingPortal.sessions.create({
|
|
90
|
+
customer: customerId,
|
|
91
|
+
return_url: returnUrl,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Webhook event handler
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Verify and process an incoming Stripe webhook event.
|
|
101
|
+
*/
|
|
102
|
+
export async function handleWebhookEvent(
|
|
103
|
+
payload: string | Buffer,
|
|
104
|
+
signature: string
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const event = stripe.webhooks.constructEvent(
|
|
107
|
+
payload,
|
|
108
|
+
signature,
|
|
109
|
+
env.STRIPE_WEBHOOK_SECRET
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
switch (event.type) {
|
|
113
|
+
case "checkout.session.completed": {
|
|
114
|
+
const session = event.data.object as Stripe.Checkout.Session;
|
|
115
|
+
await handleCheckoutCompleted(session);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case "customer.subscription.updated": {
|
|
120
|
+
const subscription = event.data.object as Stripe.Subscription;
|
|
121
|
+
await handleSubscriptionUpdated(subscription);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case "customer.subscription.deleted": {
|
|
126
|
+
const subscription = event.data.object as Stripe.Subscription;
|
|
127
|
+
await handleSubscriptionDeleted(subscription);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
default:
|
|
132
|
+
console.log(`Unhandled Stripe event type: ${event.type}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Internal event handlers
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
async function handleCheckoutCompleted(
|
|
141
|
+
session: Stripe.Checkout.Session
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
if (session.mode !== "subscription" || !session.subscription) return;
|
|
144
|
+
|
|
145
|
+
const stripeSubscriptionId =
|
|
146
|
+
typeof session.subscription === "string"
|
|
147
|
+
? session.subscription
|
|
148
|
+
: session.subscription.id;
|
|
149
|
+
|
|
150
|
+
const stripeCustomerId =
|
|
151
|
+
typeof session.customer === "string"
|
|
152
|
+
? session.customer
|
|
153
|
+
: session.customer?.id;
|
|
154
|
+
|
|
155
|
+
if (!stripeCustomerId) return;
|
|
156
|
+
|
|
157
|
+
// Look up the internal customer record
|
|
158
|
+
const customer = await db.query.customers.findFirst({
|
|
159
|
+
where: eq(customers.stripeCustomerId, stripeCustomerId),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!customer) {
|
|
163
|
+
console.error(
|
|
164
|
+
`No customer record found for Stripe customer ${stripeCustomerId}`
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Fetch the full subscription from Stripe to get price info
|
|
170
|
+
const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId);
|
|
171
|
+
const priceId = stripeSub.items.data[0]?.price.id ?? "";
|
|
172
|
+
|
|
173
|
+
// Upsert the subscription record
|
|
174
|
+
await db
|
|
175
|
+
.insert(subscriptions)
|
|
176
|
+
.values({
|
|
177
|
+
customerId: customer.id,
|
|
178
|
+
stripeSubscriptionId,
|
|
179
|
+
status: stripeSub.status,
|
|
180
|
+
stripePriceId: priceId,
|
|
181
|
+
currentPeriodStart: new Date(stripeSub.current_period_start * 1000),
|
|
182
|
+
currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
|
|
183
|
+
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
|
|
184
|
+
})
|
|
185
|
+
.onConflictDoUpdate({
|
|
186
|
+
target: subscriptions.stripeSubscriptionId,
|
|
187
|
+
set: {
|
|
188
|
+
status: stripeSub.status,
|
|
189
|
+
stripePriceId: priceId,
|
|
190
|
+
currentPeriodStart: new Date(stripeSub.current_period_start * 1000),
|
|
191
|
+
currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
|
|
192
|
+
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function handleSubscriptionUpdated(
|
|
198
|
+
subscription: Stripe.Subscription
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const priceId = subscription.items.data[0]?.price.id ?? "";
|
|
201
|
+
|
|
202
|
+
await db
|
|
203
|
+
.update(subscriptions)
|
|
204
|
+
.set({
|
|
205
|
+
status: subscription.status,
|
|
206
|
+
stripePriceId: priceId,
|
|
207
|
+
currentPeriodStart: new Date(subscription.current_period_start * 1000),
|
|
208
|
+
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
209
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
210
|
+
})
|
|
211
|
+
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function handleSubscriptionDeleted(
|
|
215
|
+
subscription: Stripe.Subscription
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
await db
|
|
218
|
+
.update(subscriptions)
|
|
219
|
+
.set({
|
|
220
|
+
status: "canceled",
|
|
221
|
+
cancelAtPeriodEnd: false,
|
|
222
|
+
})
|
|
223
|
+
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
|
|
224
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useSubscription } from "@/hooks/useSubscription";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Displays the current user's subscription status with options to manage,
|
|
5
|
+
* upgrade, or subscribe.
|
|
6
|
+
*/
|
|
7
|
+
export function SubscriptionStatus() {
|
|
8
|
+
const { subscription, isLoading, error } = useSubscription();
|
|
9
|
+
|
|
10
|
+
const handleManage = async () => {
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetch("/api/stripe/create-portal-session", {
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
credentials: "include",
|
|
16
|
+
});
|
|
17
|
+
const data = await response.json();
|
|
18
|
+
if (data.url) {
|
|
19
|
+
window.location.href = data.url;
|
|
20
|
+
}
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error("Failed to open customer portal:", err);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (isLoading) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="animate-pulse rounded-lg border border-gray-200 p-6 dark:border-gray-700">
|
|
29
|
+
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
|
|
30
|
+
<div className="mt-3 h-3 w-48 rounded bg-gray-200 dark:bg-gray-700" />
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (error) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-900/20">
|
|
38
|
+
<p className="text-sm text-red-600 dark:text-red-400">
|
|
39
|
+
Failed to load subscription status.
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// No subscription — show upgrade prompt
|
|
46
|
+
if (!subscription) {
|
|
47
|
+
return (
|
|
48
|
+
<div className="rounded-lg border border-gray-200 p-6 dark:border-gray-700">
|
|
49
|
+
<div className="flex items-center justify-between">
|
|
50
|
+
<div>
|
|
51
|
+
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
52
|
+
Free Plan
|
|
53
|
+
</h3>
|
|
54
|
+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
55
|
+
Upgrade to unlock premium features.
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
<a
|
|
59
|
+
href="/pricing"
|
|
60
|
+
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
|
|
61
|
+
>
|
|
62
|
+
Upgrade
|
|
63
|
+
</a>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Status badge colors
|
|
70
|
+
const statusColors: Record<string, string> = {
|
|
71
|
+
active: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
|
|
72
|
+
trialing: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
|
|
73
|
+
past_due: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
|
|
74
|
+
canceled: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
|
|
75
|
+
incomplete: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const statusLabel: Record<string, string> = {
|
|
79
|
+
active: "Active",
|
|
80
|
+
trialing: "Trial",
|
|
81
|
+
past_due: "Past Due",
|
|
82
|
+
canceled: "Canceled",
|
|
83
|
+
incomplete: "Incomplete",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const periodEnd = subscription.currentPeriodEnd
|
|
87
|
+
? new Date(subscription.currentPeriodEnd).toLocaleDateString(undefined, {
|
|
88
|
+
year: "numeric",
|
|
89
|
+
month: "long",
|
|
90
|
+
day: "numeric",
|
|
91
|
+
})
|
|
92
|
+
: null;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="rounded-lg border border-gray-200 p-6 dark:border-gray-700">
|
|
96
|
+
<div className="flex items-start justify-between">
|
|
97
|
+
<div>
|
|
98
|
+
<div className="flex items-center gap-2">
|
|
99
|
+
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
100
|
+
Subscription
|
|
101
|
+
</h3>
|
|
102
|
+
<span
|
|
103
|
+
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
104
|
+
statusColors[subscription.status] ?? statusColors.incomplete
|
|
105
|
+
}`}
|
|
106
|
+
>
|
|
107
|
+
{statusLabel[subscription.status] ?? subscription.status}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{subscription.cancelAtPeriodEnd && (
|
|
112
|
+
<p className="mt-1 text-sm text-yellow-600 dark:text-yellow-400">
|
|
113
|
+
Cancels at end of billing period
|
|
114
|
+
</p>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{periodEnd && (
|
|
118
|
+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
119
|
+
{subscription.cancelAtPeriodEnd
|
|
120
|
+
? `Access until ${periodEnd}`
|
|
121
|
+
: `Renews on ${periodEnd}`}
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<button
|
|
127
|
+
onClick={handleManage}
|
|
128
|
+
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
129
|
+
>
|
|
130
|
+
Manage
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
interface Subscription {
|
|
4
|
+
id: string;
|
|
5
|
+
status: string;
|
|
6
|
+
priceId: string;
|
|
7
|
+
currentPeriodEnd: string | null;
|
|
8
|
+
cancelAtPeriodEnd: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface UseSubscriptionResult {
|
|
12
|
+
subscription: Subscription | null;
|
|
13
|
+
isLoading: boolean;
|
|
14
|
+
error: Error | null;
|
|
15
|
+
refetch: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hook to fetch and manage the current user's subscription state.
|
|
20
|
+
*
|
|
21
|
+
* Automatically fetches on mount. Call `refetch()` to manually refresh
|
|
22
|
+
* (e.g., after returning from Stripe Checkout or the customer portal).
|
|
23
|
+
*/
|
|
24
|
+
export function useSubscription(): UseSubscriptionResult {
|
|
25
|
+
const [subscription, setSubscription] = useState<Subscription | null>(null);
|
|
26
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
27
|
+
const [error, setError] = useState<Error | null>(null);
|
|
28
|
+
|
|
29
|
+
const fetchSubscription = useCallback(async () => {
|
|
30
|
+
setIsLoading(true);
|
|
31
|
+
setError(null);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch("/api/stripe/subscription", {
|
|
35
|
+
credentials: "include",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new Error(`Failed to fetch subscription: ${response.statusText}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const data = await response.json();
|
|
43
|
+
setSubscription(data.subscription ?? null);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
setError(
|
|
46
|
+
err instanceof Error ? err : new Error("Unknown error fetching subscription")
|
|
47
|
+
);
|
|
48
|
+
setSubscription(null);
|
|
49
|
+
} finally {
|
|
50
|
+
setIsLoading(false);
|
|
51
|
+
}
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
fetchSubscription();
|
|
56
|
+
}, [fetchSubscription]);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
subscription,
|
|
60
|
+
isLoading,
|
|
61
|
+
error,
|
|
62
|
+
refetch: fetchSubscription,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Helper to check if a subscription is in an active/usable state.
|
|
68
|
+
*/
|
|
69
|
+
export function isSubscriptionActive(
|
|
70
|
+
subscription: Subscription | null
|
|
71
|
+
): boolean {
|
|
72
|
+
if (!subscription) return false;
|
|
73
|
+
return ["active", "trialing"].includes(subscription.status);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Helper to check if the user has a specific plan by price ID.
|
|
78
|
+
*/
|
|
79
|
+
export function hasPlans(
|
|
80
|
+
subscription: Subscription | null,
|
|
81
|
+
priceIds: string[]
|
|
82
|
+
): boolean {
|
|
83
|
+
if (!subscription) return false;
|
|
84
|
+
if (!isSubscriptionActive(subscription)) return false;
|
|
85
|
+
return priceIds.includes(subscription.priceId);
|
|
86
|
+
}
|