Files
route-commerce/src/lib/stripe-billing.ts
T
tyler d892b3f64f
Deploy to route.crispygoat.com / deploy (push) Failing after 9s
feat(selfhost): complete Supabase removal migration
Phase 1 — Pattern library
- Add src/lib/api.ts (typed PostgREST client, anon-key)
- Add src/lib/svc-fetch.ts (service-role fetch + PostgREST client)
- Add src/lib/db-types.ts (Database generic, RowOf helper)
- Add src/lib/auth-admin.ts (better-auth admin wrappers: createUser, listUsers, removeUser, requestPasswordReset, validateSession)

Phase 2 — Server action migration
- Migrate all 67 server actions off @supabase/supabase-js to svcApi/svcRpc
- Replace service.auth.admin.* with authAdmin.* (better-auth)
- Replace publicApi.auth.* with authAdmin.* (better-auth)
- Add typed single() null guards + nullable column fallbacks

Phase 3 — Delete mock data + debug routes
- Remove if(useMockData) branches from 7 files (-226 lines)
- Delete src/lib/mock-data.ts (no longer referenced)
- Delete 7 debug API routes (debug-admin-users, debug-auth, debug-cookie, debug-env, debug-get-admin-users, debug-hello, debug-me)

Phase 4 — Hard cut
- Delete src/lib/supabase.ts (orphan — no importers)
- Delete src/app/api/supabase + src/app/api/supabase-test routes
- Remove @supabase/ssr + @supabase/supabase-js from package.json
- Rename env vars: NEXT_PUBLIC_SUPABASE_URL → NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY → NEXT_PUBLIC_API_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY → POSTGREST_SERVICE_KEY

Phase 5 — Verify
- npx tsc --noEmit: 0 errors
- npm run lint: 0 new errors from migration (104 pre-existing errors unrelated)
- npm run build: same failure mode as main worktree (PostgREST-dependent prerender), no regression

Net: 158 files changed, +1640 / -1903 lines (-263 net)
2026-06-05 20:17:02 +00:00

703 lines
20 KiB
TypeScript

// Enhanced Stripe Billing - Full Subscription Management
// Supports plans, add-ons, upgrades, cancellations, and customer portal
import Stripe from "stripe";
// Lazy-initialize Stripe client to avoid build-time errors when env vars aren't set
let _stripe: Stripe | null = null;
function getStripeClient(): Stripe {
if (!_stripe) {
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY environment variable is not set");
}
_stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2026-05-27.dahlia",
});
}
return _stripe;
}
// Export stripe proxy that lazily initializes
export const stripe = {
get customers() { return getStripeClient().customers; },
get subscriptions() { return getStripeClient().subscriptions; },
get invoices() { return getStripeClient().invoices; },
get checkout() { return getStripeClient().checkout; },
get billingPortal() { return getStripeClient().billingPortal; },
get paymentMethods() { return getStripeClient().paymentMethods; },
get paymentIntents() { return getStripeClient().paymentIntents; },
get refunds() { return getStripeClient().refunds; },
get webhooks() { return getStripeClient().webhooks; },
};
// Plan tier configurations
export const PLANS = {
starter: {
id: "starter",
name: "Starter",
description: "Perfect for small farms getting started",
monthlyPrice: 4900, // $49.00 in cents
annualPrice: 44100, // $441.00 (15% discount)
features: [
"Up to 25 products",
"10 stops/month",
"1 user",
"Basic pickup management",
"Email support",
],
limits: {
max_users: 1,
max_stops_monthly: 10,
max_products: 25,
},
},
farm: {
id: "farm",
name: "Farm",
description: "For growing farms with more needs",
monthlyPrice: 14900, // $149.00
annualPrice: 152280, // $1,522.80 (10% discount)
features: [
"Unlimited products",
"Unlimited stops",
"5 users",
"Wholesale Portal",
"Harvest Reach",
"Priority support",
],
limits: {
max_users: 5,
max_stops_monthly: -1, // unlimited
max_products: -1,
},
},
enterprise: {
id: "enterprise",
name: "Enterprise",
description: "Custom solution for larger operations",
monthlyPrice: 39900, // $399.00
annualPrice: 0, // Custom pricing
features: [
"Everything in Farm",
"AI Intelligence Pack",
"SMS Campaigns",
"Square Sync",
"Water Log",
"Unlimited users",
"Unlimited brands",
"Custom development",
"Dedicated SLA",
],
limits: {
max_users: -1,
max_stops_monthly: -1,
max_products: -1,
},
},
} as const;
// Add-ons configuration
export const ADDONS = {
wholesale_portal: {
id: "wholesale_portal",
name: "Wholesale Portal",
description: "Full wholesale customer portal with ordering",
monthlyPrice: 9900, // $99.00
},
harvest_reach: {
id: "harvest_reach",
name: "Harvest Reach",
description: "Email and SMS marketing campaigns",
monthlyPrice: 7900, // $79.00
},
ai_tools: {
id: "ai_tools",
name: "AI Intelligence Pack",
description: "AI-powered insights and automation",
monthlyPrice: 5900, // $59.00
},
water_log: {
id: "water_log",
name: "Water Log",
description: "Track irrigation and water usage",
monthlyPrice: 3900, // $39.00
},
square_sync: {
id: "square_sync",
name: "Square Sync",
description: "Sync inventory with Square POS",
monthlyPrice: 3900, // $39.00
},
sms_campaigns: {
id: "sms_campaigns",
name: "SMS Campaigns",
description: "SMS marketing via Twilio",
monthlyPrice: 2900, // $29.00
},
} as const;
// Stripe Price IDs (configurable via environment)
const PRICE_IDS = {
starter_monthly: process.env.STRIPE_PRICE_STARTER_MONTHLY || "price_starter_monthly",
starter_annual: process.env.STRIPE_PRICE_STARTER_ANNUAL || "price_starter_annual",
farm_monthly: process.env.STRIPE_PRICE_FARM_MONTHLY || "price_farm_monthly",
farm_annual: process.env.STRIPE_PRICE_FARM_ANNUAL || "price_farm_annual",
enterprise_monthly: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY || "price_enterprise_monthly",
wholesale_portal: process.env.STRIPE_PRICE_WHOLESALE_PORTAL || "price_wholesale_portal",
harvest_reach: process.env.STRIPE_PRICE_HARVEST_REACH || "price_harvest_reach",
ai_tools: process.env.STRIPE_PRICE_AI_TOOLS || "price_ai_tools",
water_log: process.env.STRIPE_PRICE_WATER_LOG || "price_water_log",
square_sync: process.env.STRIPE_PRICE_SQUARE_SYNC || "price_square_sync",
sms_campaigns: process.env.STRIPE_PRICE_SMS_CAMPAIGNS || "price_sms_campaigns",
};
// ============================================
// SUBSCRIPTION MANAGEMENT
// ============================================
export interface CreateSubscriptionOptions {
brandId: string;
brandName: string;
email: string;
plan: keyof typeof PLANS;
interval: "monthly" | "annual";
successUrl: string;
cancelUrl: string;
metadata?: Record<string, string>;
}
export async function createSubscription(options: CreateSubscriptionOptions) {
const { brandId, brandName, email, plan, interval, successUrl, cancelUrl, metadata } = options;
// Get or create Stripe customer
let customerId = await getOrCreateCustomer(brandId, brandName, email);
// Get price ID for selected plan
const priceId = interval === "annual"
? PRICE_IDS[`${plan}_annual` as keyof typeof PRICE_IDS]
: PRICE_IDS[`${plan}_monthly` as keyof typeof PRICE_IDS];
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: {
brand_id: brandId,
brand_name: brandName,
plan_tier: plan,
interval,
},
trial_period_days: 14, // 14-day trial for new subscriptions
},
metadata: {
brand_id: brandId,
...metadata,
},
allow_promotion_codes: true,
billing_address_collection: "required",
});
return session;
}
export async function createAddonSubscription(options: {
customerId: string;
addon: keyof typeof ADDONS;
brandId: string;
successUrl: string;
cancelUrl: string;
}) {
const { customerId, addon, brandId, successUrl, cancelUrl } = options;
const priceId = PRICE_IDS[addon as keyof typeof PRICE_IDS];
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: {
brand_id: brandId,
addon_key: addon,
addon_name: ADDONS[addon].name,
},
},
metadata: {
brand_id: brandId,
addon_key: addon,
type: "addon",
},
});
return session;
}
// ============================================
// CUSTOMER PORTAL
// ============================================
export async function createCustomerPortalSession(options: {
customerId: string;
returnUrl: string;
}) {
const { customerId, returnUrl } = options;
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
return session;
}
// ============================================
// SUBSCRIPTION UPDATES
// ============================================
export async function cancelSubscription(subscriptionId: string, immediate: boolean = false) {
if (immediate) {
return await stripe.subscriptions.cancel(subscriptionId);
}
return await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
export async function reactivateSubscription(subscriptionId: string) {
return await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
}
export async function changePlan(subscriptionId: string, newPlan: keyof typeof PLANS, interval: "monthly" | "annual") {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Get new price ID
const priceId = interval === "annual"
? PRICE_IDS[`${newPlan}_annual` as keyof typeof PRICE_IDS]
: PRICE_IDS[`${newPlan}_monthly` as keyof typeof PRICE_IDS];
// Prorate the change
return await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscription.items.data[0].id,
price: priceId,
},
],
proration_behavior: "create_prorations",
});
}
// ============================================
// CUSTOMER MANAGEMENT
// ============================================
export async function getOrCreateCustomer(brandId: string, name: string, email: string) {
// Check if customer exists for this brand
const existingCustomers = await stripe.customers.list({
email,
limit: 1,
});
if (existingCustomers.data.length > 0) {
return existingCustomers.data[0].id;
}
// Create new customer
const customer = await stripe.customers.create({
email,
name,
metadata: {
brand_id: brandId,
},
});
return customer.id;
}
export async function getCustomer(customerId: string) {
return await stripe.customers.retrieve(customerId);
}
export async function updateCustomer(customerId: string, data: Partial<Stripe.CustomerUpdateParams>) {
return await stripe.customers.update(customerId, data);
}
// ============================================
// PAYMENT METHODS
// ============================================
export async function listPaymentMethods(customerId: string) {
return await stripe.paymentMethods.list({
customer: customerId,
type: "card",
});
}
export async function attachPaymentMethod(paymentMethodId: string, customerId: string) {
return await stripe.paymentMethods.attach(paymentMethodId, {
customer: customerId,
});
}
export async function setDefaultPaymentMethod(customerId: string, paymentMethodId: string) {
return await stripe.customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
}
// ============================================
// INVOICES
// ============================================
export async function listInvoices(customerId: string, limit: number = 24) {
return await stripe.invoices.list({
customer: customerId,
limit,
});
}
export async function getInvoice(invoiceId: string) {
return await stripe.invoices.retrieve(invoiceId);
}
export async function getUpcomingInvoice(customerId: string) {
// Use Stripe's upcoming invoice preview endpoint
return await stripe.invoices.createPreview({
customer: customerId,
});
}
// ============================================
// PAYMENTS & REFUNDS
// ============================================
export async function createPaymentIntent(options: {
amount: number;
currency?: string;
customerId?: string;
metadata?: Record<string, string>;
}) {
return await stripe.paymentIntents.create({
amount: options.amount,
currency: options.currency || "usd",
customer: options.customerId,
metadata: options.metadata,
automatic_payment_methods: {
enabled: true,
},
});
}
export async function createRefund(paymentIntentId: string, amount?: number, reason?: string) {
return await stripe.refunds.create({
payment_intent: paymentIntentId,
amount,
reason: reason as Stripe.RefundCreateParams.Reason || "requested_by_customer",
});
}
// ============================================
// WEBHOOK PROCESSING
// ============================================
export async function processWebhook(payload: Buffer, signature: string) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
try {
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object);
break;
case "customer.subscription.created":
await handleSubscriptionCreated(event.data.object);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object);
break;
case "invoice.payment_succeeded":
await handleInvoicePaymentSucceeded(event.data.object);
break;
case "invoice.payment_failed":
await handleInvoicePaymentFailed(event.data.object);
break;
case "customer.updated":
await handleCustomerUpdated(event.data.object);
break;
}
return { received: true };
} catch (error) {
console.error("Webhook processing error:", error);
throw error;
}
}
// Webhook handlers
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const { brand_id, type } = session.metadata || {};
if (type === "addon") {
// Handle addon checkout
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
await enableAddonFeature(brand_id, subscription.metadata.addon_key);
} else {
// Handle plan checkout
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
await updateBrandSubscription(
brand_id,
subscription.id,
subscription.status,
subscription.metadata.plan_tier,
(subscription as unknown as { current_period_end: number | null }).current_period_end
);
}
}
async function handleSubscriptionCreated(subscription: Stripe.Subscription) {
const { brand_id, plan_tier } = subscription.metadata as { brand_id?: string; plan_tier?: string };
await updateBrandSubscription(
brand_id,
subscription.id,
subscription.status,
plan_tier,
(subscription as unknown as { current_period_end: number | null }).current_period_end
);
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const { brand_id, plan_tier, addon_key } = subscription.metadata as { brand_id?: string; plan_tier?: string; addon_key?: string };
if (addon_key) {
// Add-on subscription
if (subscription.status === "active") {
await enableAddonFeature(brand_id, addon_key);
} else {
await disableAddonFeature(brand_id, addon_key);
}
} else {
// Plan subscription
await updateBrandSubscription(
brand_id,
subscription.id,
subscription.status,
plan_tier,
(subscription as unknown as { current_period_end: number | null }).current_period_end
);
}
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const { brand_id } = subscription.metadata as { brand_id?: string };
// Downgrade to free tier or cancel
await updateBrandSubscription(
brand_id,
null,
"cancelled",
undefined,
null
);
}
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
// Log successful payment
console.log(`Payment succeeded for customer: ${invoice.customer}`);
}
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
// Notify brand about failed payment
console.log(`Payment failed for customer: ${invoice.customer}`);
// You could trigger an email notification here
// await sendPaymentFailedNotification(invoice.customer as string);
}
async function handleCustomerUpdated(customer: Stripe.Customer) {
// Sync customer data with our database
console.log(`Customer updated: ${customer.id}`);
}
// ============================================
// DATABASE HELPERS
// ============================================
async function updateBrandSubscription(
brandId: string | undefined,
subscriptionId: string | null,
status: string,
planTier: string | undefined,
periodEnd: number | null
) {
if (!brandId) return;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
try {
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_subscription_id: subscriptionId,
p_subscription_status: status,
p_plan_tier: planTier,
p_current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
}),
}
);
} catch (error) {
console.error("Failed to update brand subscription:", error);
}
}
async function enableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
try {
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_feature_key: addonKey,
p_enabled: true,
}),
}
);
} catch (error) {
console.error("Failed to enable addon feature:", error);
}
}
async function disableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
try {
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_feature_key: addonKey,
p_enabled: false,
}),
}
);
} catch (error) {
console.error("Failed to disable addon feature:", error);
}
}
// ============================================
// USAGE BILLING
// ============================================
export async function createUsageRecord(options: {
subscriptionItemId: string;
quantity: number;
timestamp?: number;
idempotencyKey?: string;
}) {
// Use the metering endpoint for usage-based billing
return await fetch(
`https://api.stripe.com/v1/billing/meter_events`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
event_name: "usage",
payload: JSON.stringify({
subscription_item_id: options.subscriptionItemId,
quantity: options.quantity.toString(),
timestamp: (options.timestamp || Math.floor(Date.now() / 1000)).toString(),
}),
}),
}
);
}
export async function getUsageSummary(subscriptionItemId: string) {
// List subscription items with usage summaries
const subscription = await stripe.subscriptions.retrieve(
subscriptionItemId.split("_")[0]
);
const item = subscription.items.data.find(i => i.id === subscriptionItemId);
return item || null;
}
// ============================================
// EXPORT STRIPE INSTANCE AND HELPERS
// ============================================
export async function getSubscription(subscriptionId: string) {
return await stripe.subscriptions.retrieve(subscriptionId);
}
export async function listSubscriptions(customerId: string) {
return await stripe.subscriptions.list({
customer: customerId,
status: "all",
limit: 10,
});
}