import type { StripeSync } from "@supabase/stripe-sync-engine"; import type { SupabaseClient, User } from "@supabase/supabase-js"; import { Hono } from "hono"; import Stripe from "stripe"; 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 ); } }); 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 ); } } ); /** * 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`, }); 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 ); } }); // 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 ); } }); /** * 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 ); } } ); return stripeRouter; };