xtablo-source/api/src/stripe.ts

288 lines
8.9 KiB
TypeScript
Raw Normal View History

2025-11-04 09:53:31 +00:00
import type { StripeSync } from "@supabase/stripe-sync-engine";
import type { SupabaseClient, User } from "@supabase/supabase-js";
2025-11-04 09:53:31 +00:00
import { Hono } from "hono";
import Stripe from "stripe";
2025-11-04 09:53:31 +00:00
import type { AppConfig } from "./config.js";
import type { Middlewares } from "./middleware.js";
export const getStripeWebhookRouter = (stripeSync: StripeSync) => {
const stripeWebhookRouter = new Hono();
/**
* Stripe webhook handler using @supabase/stripe-sync-engine
* This automatically syncs all Stripe events to Supabase tables
* Repository: https://github.com/supabase/stripe-sync-engine
*/
stripeWebhookRouter.post("/", async (c) => {
try {
const signature = c.req.header("stripe-signature");
if (!signature) {
return c.json({ error: "No signature provided" }, 400);
}
// Get raw body for signature verification
const rawBody = await c.req.text();
// Process webhook using Stripe Sync Engine
// This handles signature verification and syncing automatically
await stripeSync.processWebhook(rawBody, signature);
return c.json({ received: true });
} catch (error) {
console.error("Webhook error:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Webhook processing failed" },
400
);
}
2025-11-04 09:53:31 +00:00
});
return stripeWebhookRouter;
};
export const getStripeRouter = (middlewares: Middlewares, config: AppConfig, stripe: Stripe) => {
const stripeRouter = new Hono<{
Variables: {
user: User;
supabase: SupabaseClient;
};
}>();
stripeRouter.use(middlewares.authMiddleware);
// ============================================================================
// Authenticated endpoints
// ============================================================================
/**
* Create a Stripe Checkout Session
* POST /api/v1/stripe/create-checkout-session
*/
stripeRouter.post(
"/create-checkout-session",
middlewares.regularUserCheckMiddleware,
async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const body = await c.req.json();
const { priceId, successUrl, cancelUrl } = body;
if (!priceId) {
return c.json({ error: "priceId is required" }, 400);
}
try {
// Get or create Stripe customer
let customerId: string;
// Check if customer already exists by querying stripe schema with metadata filter
// Note: Using service role, so we filter manually by metadata
const { data: customers } = await supabase
.schema("stripe")
.from("customers")
.select("id, metadata")
.limit(1000); // Get all customers to filter by metadata
const existingCustomer = customers?.find(
(c: Stripe.Customer) => c.metadata?.user_id === user.id
);
if (existingCustomer) {
customerId = existingCustomer.id;
} else {
// Create new Stripe customer with user_id in metadata
// stripe-sync-engine will automatically sync this to the database via webhook
const customer = await stripe.customers.create({
email: user.email!,
metadata: {
user_id: user.id, // Stored in metadata for tracking
},
});
customerId = customer.id;
}
// Create Checkout Session
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: "subscription",
success_url: successUrl || `${config.XTABLO_URL}/settings?success=true`,
cancel_url: cancelUrl || `${config.XTABLO_URL}/settings?canceled=true`,
metadata: {
user_id: user.id,
},
subscription_data: {
metadata: {
user_id: user.id,
},
},
});
return c.json({ sessionId: session.id, url: session.url });
} catch (error) {
console.error("Error creating checkout session:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Failed to create checkout session" },
500
);
}
}
2025-11-04 09:53:31 +00:00
);
/**
* Create a Stripe Customer Portal Session
* POST /api/v1/stripe/create-portal-session
*/
stripeRouter.post("/create-portal-session", middlewares.regularUserCheckMiddleware, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const body = await c.req.json();
const { returnUrl } = body;
try {
// Get Stripe customer ID by filtering metadata
const { data: customers } = await supabase
.schema("stripe")
.from("customers")
.select("id, metadata");
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
if (!customer) {
return c.json({ error: "No Stripe customer found" }, 404);
}
// Create portal session
const session = await stripe.billingPortal.sessions.create({
customer: customer.id,
return_url: returnUrl || `${config.XTABLO_URL}/settings`,
});
2025-11-04 09:53:31 +00:00
return c.json({ url: session.url });
} catch (error) {
console.error("Error creating portal session:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Failed to create portal session" },
500
);
}
2025-11-04 09:53:31 +00:00
});
// Note: Subscription status queries are handled directly from the frontend
// using Supabase client with RLS policies. No API endpoints needed for reads.
/**
* Cancel subscription at period end
* POST /api/v1/stripe/cancel-subscription
*/
stripeRouter.post("/cancel-subscription", middlewares.regularUserCheckMiddleware, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
try {
// Get user's Stripe customer first
const { data: customers } = await supabase
.schema("stripe")
.from("customers")
.select("id, metadata");
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
if (!customer) {
return c.json({ error: "Customer not found" }, 404);
}
// Get user's active subscription for this customer
const { data: subscription } = await supabase
.schema("stripe")
.from("subscriptions")
.select("id, status")
.eq("customer", customer.id)
.in("status", ["active", "trialing"])
.maybeSingle();
if (!subscription) {
return c.json({ error: "No active subscription found" }, 404);
}
// Cancel subscription at period end in Stripe
// The webhook will automatically sync the change to our database
await stripe.subscriptions.cancel(subscription.id);
return c.json({
success: true,
message: "Subscription will cancel at period end",
});
} catch (error) {
console.error("Error canceling subscription:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Failed to cancel subscription" },
500
);
}
2025-11-04 09:53:31 +00:00
});
/**
* Reactivate a canceled subscription
* POST /api/v1/stripe/reactivate-subscription
*/
stripeRouter.post(
"/reactivate-subscription",
middlewares.regularUserCheckMiddleware,
async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
try {
// Get user's Stripe customer first
const { data: customers } = await supabase
.schema("stripe")
.from("customers")
.select("id, metadata");
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
if (!customer) {
return c.json({ error: "No subscription found to reactivate" }, 404);
}
// Get user's subscription that's set to cancel
const { data: subscription } = await supabase
.schema("stripe")
.from("subscriptions")
.select("id, cancel_at_period_end")
.eq("customer", customer.id)
.eq("cancel_at_period_end", true)
.maybeSingle();
if (!subscription) {
return c.json({ error: "No subscription found to reactivate" }, 404);
}
// Reactivate subscription in Stripe
// The webhook will automatically sync the change to our database
await stripe.subscriptions.update(subscription.id, {
cancel_at_period_end: false,
});
return c.json({ success: true, message: "Subscription reactivated" });
} catch (error) {
console.error("Error reactivating subscription:", error);
return c.json(
{ error: error instanceof Error ? error.message : "Failed to reactivate subscription" },
500
);
}
}
2025-11-04 09:53:31 +00:00
);
2025-11-04 09:53:31 +00:00
return stripeRouter;
};