migrate: replace Supabase REST with Drizzle/pg in billing + integrations + wholesale (wave 3)

This commit is contained in:
2026-06-07 03:25:22 +00:00
parent eb9621d238
commit 99a3d66636
26 changed files with 1216 additions and 1776 deletions
+68 -40
View File
@@ -36,12 +36,12 @@
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
import { and, eq, count } from "drizzle-orm";
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export type BillingSubscriptionStatus =
| "active"
| "trialing"
@@ -97,48 +97,76 @@ export async function getBillingOverview(
if (!brandId) return { success: false, error: "brandId required" };
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
const planRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
// Replicate the RPC inline via raw SQL on tenants + plans.
const planRes = await pool.query<{
plan_tier: string;
plan_name: string | null;
max_users: number;
max_stops_monthly: number;
max_products: number;
usage: { users: number; stops_this_month: number; products: number } | null;
}>(
`SELECT
t.plan_tier,
t.name AS plan_name,
COALESCE(t.max_users, 1) AS max_users,
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
WHERE t.id = $1`,
[brandId]
);
const planData = planRes.ok ? await planRes.json() : null;
const planData = planRes.rows[0] ?? null;
// 2) Brand row: subscription state, name, stripe_customer_id
const brandRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`,
{ headers: svcHeaders(supabaseKey) }
const brandRes = await pool.query<{
name: string;
stripe_customer_id: string | null;
stripe_subscription_id: string | null;
stripe_subscription_status: string | null;
stripe_current_period_end: string | null;
}>(
`SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id,
NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end
FROM tenants WHERE id = $1`,
[brandId]
);
const brandRows = brandRes.ok ? await brandRes.json() : [];
const brand = Array.isArray(brandRows) ? brandRows[0] : null;
const brand = brandRes.rows[0] ?? null;
// 3) Enabled add-ons (feature flags)
const addonsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
// 3) Enabled add-ons (feature flags from brand_settings)
const addonsRes = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
`SELECT feature_flags FROM brand_settings WHERE tenant_id = $1`,
[brandId]
);
const enabledAddons = addonsRes.ok ? await addonsRes.json() : {};
const flags = addonsRes.rows[0]?.feature_flags ?? {};
const enabledAddons: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags ?? {})) {
enabledAddons[k] = v === true || v === "true" || v === 1 || v === "1";
}
// 4) Active product count — same semantics as the dashboard
// (active=true AND deleted_at IS NULL). Keeps the billing page in
// sync with /admin "Active Products" stat.
const productRes = await fetch(
`${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`,
{ headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } }
// (active=true). Keeps the billing page in sync with /admin
// "Active Products" stat.
const productCountRows = await withTenant(brandId, (db) =>
db
.select({ value: count() })
.from(products)
.where(
and(
eq(products.tenantId, brandId),
eq(products.active, true)
)
)
);
const productCountHeader = productRes.headers.get("Content-Range");
let activeProductCount = 0;
if (productCountHeader && productCountHeader.includes("/")) {
const total = productCountHeader.split("/").pop();
activeProductCount = parseInt(total ?? "0", 10) || 0;
}
const activeProductCount = Number(productCountRows[0]?.value ?? 0);
// 5) Admin context (so we know if the user can manage / is platform)
const adminUser = await getAdminUser();
@@ -146,7 +174,7 @@ export async function getBillingOverview(
const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings;
// ── Normalize plan data ──────────────────────────────────────────────────
const planTier = (planData?.plan_tier ?? "starter") as PlanTierKey;
const planTier = ((planData?.plan_tier as PlanTierKey | undefined) ?? "starter");
const limits = {
max_users: planData?.max_users ?? 1,
max_stops_monthly: planData?.max_stops_monthly ?? 10,
@@ -209,7 +237,7 @@ export async function getBillingOverview(
success: true,
data: {
brandId,
brandName: brand?.name ?? planData?.brand_name ?? null,
brandName: brand?.name ?? planData?.plan_name ?? null,
planTier,
planCycle,
planMonthlyPrice,
@@ -220,7 +248,7 @@ export async function getBillingOverview(
currentPeriodEnd: brand?.stripe_current_period_end ?? null,
limits,
usage,
enabledAddons: (enabledAddons ?? {}) as Record<string, boolean>,
enabledAddons,
addons,
displayedInvoiceAmount,
monthlyTotal,
+5 -8
View File
@@ -1,6 +1,6 @@
"use server";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
type LineItem = {
id: string;
@@ -32,16 +32,13 @@ export async function createRetailStripeCheckoutSession(
}));
// Get brand name for Stripe metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let brandName = "Route Commerce";
try {
const brandRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
{ headers: { ...svcHeaders(supabaseKey) } }
const brandRes = await pool.query<{ name: string }>(
"SELECT name FROM tenants WHERE id = $1 LIMIT 1",
[brandId]
);
const brands = await brandRes.json() as Array<{ name: string }>;
if (brands?.[0]?.name) brandName = brands[0].name;
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
} catch {
// use default
}
+6 -9
View File
@@ -1,6 +1,6 @@
"use server";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
/**
* Creates a Stripe PaymentIntent for the supplied cart so the browser
@@ -63,17 +63,14 @@ export async function createRetailPaymentIntent(
}
// Pull the brand name for Stripe receipts + metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
let brandName = "Route Commerce";
if (supabaseUrl && supabaseKey && brandId) {
if (brandId) {
try {
const brandRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
{ headers: { ...svcHeaders(supabaseKey) } }
const brandRes = await pool.query<{ name: string }>(
"SELECT name FROM tenants WHERE id = $1 LIMIT 1",
[brandId]
);
const brands = (await brandRes.json()) as Array<{ name: string }>;
if (brands?.[0]?.name) brandName = brands[0].name;
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
} catch {
// ignore — use default
}
+20 -29
View File
@@ -1,7 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
// ── Price ID config ────────────────────────────────────────────────────────────
// Maps plan/addon keys to Stripe price IDs via environment variables
@@ -43,16 +43,12 @@ export async function createStripeCheckoutSession(
const priceId = getPriceId(priceKey);
if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get brand's Stripe customer ID
const custRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id,name`,
{ headers: { ...svcHeaders(supabaseKey) } }
const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>(
"SELECT stripe_customer_id, name FROM tenants WHERE id = $1 LIMIT 1",
[brandId]
);
const brands = await custRes.json() as Array<{ stripe_customer_id: string | null; name: string }>;
const brand = brands[0];
const brand = custRes.rows[0];
if (!brand?.stripe_customer_id) {
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
}
@@ -123,20 +119,15 @@ export async function cancelAddonSubscription(
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get active subscription for this brand
const subRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_subscription`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
const subRes = await pool.query<{ stripe_subscription_id: string | null }>(
"SELECT stripe_subscription_id FROM subscriptions WHERE tenant_id = $1 LIMIT 1",
[brandId]
);
if (!subRes.ok) return { success: false, error: "Failed to get subscription" };
const subData = await subRes.json() as { stripe_subscription_id?: string };
if (subRes.rows.length === 0) {
return { success: false, error: "No active subscription found" };
}
const subData = subRes.rows[0];
if (!subData?.stripe_subscription_id) {
return { success: false, error: "No active subscription found" };
}
@@ -162,14 +153,14 @@ export async function cancelAddonSubscription(
});
// Disable the feature flag locally
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }),
}
);
try {
await pool.query(
"SELECT set_brand_feature($1, $2, $3)",
[brandId, addonKey, false]
);
} catch {
// best-effort: webhook reconciliation will eventually fix the flag
}
return { success: true };
}
+75 -84
View File
@@ -1,7 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
const adminUser = await getAdminUser();
@@ -13,16 +13,12 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get stripe_customer_id from brands table
const custRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id`,
{ headers: svcHeaders(supabaseKey) }
// Get stripe_customer_id from tenants table
const custRes = await pool.query<{ stripe_customer_id: string | null }>(
"SELECT stripe_customer_id FROM tenants WHERE id = $1 LIMIT 1",
[brandId]
);
const custData = await custRes.json();
const stripeCustomerId = custData?.[0]?.stripe_customer_id;
const stripeCustomerId = custRes.rows[0]?.stripe_customer_id;
if (!stripeCustomerId) {
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
@@ -49,20 +45,15 @@ export async function updateBrandPlanTier(brandId: string, planTier: string): Pr
return { success: false, error: "Invalid plan tier" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_plan_tier: planTier }),
}
);
if (!res.ok) return { success: false, error: "Failed to update plan tier" };
return { success: true };
try {
await pool.query(
"SELECT update_brand_plan_tier($1, $2)",
[brandId, planTier]
);
return { success: true };
} catch {
return { success: false, error: "Failed to update plan tier" };
}
}
export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> {
@@ -72,76 +63,76 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_stripe_customer_id: stripeCustomerId }),
}
);
if (!res.ok) return { success: false, error: "Failed to update Stripe customer ID" };
return { success: true };
try {
await pool.query(
"SELECT update_brand_stripe_customer_id($1, $2)",
[brandId, stripeCustomerId]
);
return { success: true };
} catch {
return { success: false, error: "Failed to update Stripe customer ID" };
}
}
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
// Replicate get_brand_plan_info via a JOIN on tenants + plans
const res = await pool.query<{
plan_tier: string;
plan_name: string | null;
max_users: number;
max_stops_monthly: number;
max_products: number;
usage: { users: number; stops_this_month: number; products: number } | null;
}>(
`SELECT
t.plan_tier,
t.name AS plan_name,
COALESCE(t.max_users, 1) AS max_users,
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
WHERE t.id = $1`,
[brandId]
);
if (!res.ok) return { success: false, error: "Failed to fetch plan info" };
const data = await res.json();
if (!Array.isArray(data) && typeof data !== "object") return { success: false, error: "Invalid plan info response" };
const data = res.rows[0];
if (!data) return { success: false, error: "Brand not found" };
return { success: true, data };
}
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
// get_brand_features returns JSONB — a single object, not an array
if (!res.ok) return {};
const data = await res.json();
if (typeof data !== "object" || data === null) return {};
return data as Record<string, boolean>;
const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
"SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1",
[brandId]
);
const flags = res.rows[0]?.feature_flags ?? {};
if (!flags || typeof flags !== "object") return {};
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
out[k] = v === true || v === "true" || v === 1 || v === "1";
}
return out;
}
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data.slice(0, limit);
}
try {
const res = await pool.query(
"SELECT * FROM get_wholesale_orders($1)",
[brandId]
);
const data = res.rows;
if (!Array.isArray(data)) return [];
return data.slice(0, limit);
} catch {
return [];
}
}