2025-11-04 09:53:31 +00:00
|
|
|
import type { StripeSync } from "@supabase/stripe-sync-engine";
|
2025-11-03 08:47:10 +00:00
|
|
|
import type { SupabaseClient, User } from "@supabase/supabase-js";
|
2025-11-04 09:53:31 +00:00
|
|
|
import { Hono } from "hono";
|
2025-11-03 08:47:10 +00:00
|
|
|
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-03 08:47:10 +00:00
|
|
|
}
|
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-03 08:47:10 +00:00
|
|
|
}
|
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-03 08:47:10 +00:00
|
|
|
|
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-03 08:47:10 +00:00
|
|
|
}
|
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-03 08:47:10 +00:00
|
|
|
}
|
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-03 08:47:10 +00:00
|
|
|
}
|
2025-11-04 09:53:31 +00:00
|
|
|
);
|
2025-11-03 08:47:10 +00:00
|
|
|
|
2025-11-04 09:53:31 +00:00
|
|
|
return stripeRouter;
|
|
|
|
|
};
|