migrate: replace Supabase REST with Drizzle/pg in billing + integrations + wholesale (wave 3)
This commit is contained in:
+45
-51
@@ -5,10 +5,7 @@ import "server-only";
|
||||
|
||||
import { ADDONS, type PlanTierKey, type AddonKey } from "./pricing";
|
||||
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// ── Subscription status types ──────────────────────────────────────────────────
|
||||
|
||||
@@ -29,22 +26,18 @@ export interface BrandSubscription {
|
||||
plan_tier: PlanTierKey;
|
||||
}
|
||||
|
||||
// ── RPC call helper ───────────────────────────────────────────────────────────
|
||||
// ── RPC call helper (raw SQL via pg pool) ─────────────────────────────────────
|
||||
|
||||
async function rpc<T = unknown>(
|
||||
async function rpc<T = Record<string, unknown>>(
|
||||
fn: string,
|
||||
body: Record<string, unknown>
|
||||
params: ReadonlyArray<unknown>
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${fn}`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(SERVICE_KEY), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`RPC ${fn} failed: ${err}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
const placeholders = params.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const { rows } = await pool.query<Record<string, unknown>>(
|
||||
`SELECT * FROM ${fn}(${placeholders})`,
|
||||
params as unknown[]
|
||||
);
|
||||
return rows as unknown as T;
|
||||
}
|
||||
|
||||
// ── Feature sync ─────────────────────────────────────────────────────────────
|
||||
@@ -75,7 +68,7 @@ export async function syncSubscriptionFeatures(
|
||||
if (tierItem) {
|
||||
const newTier = priceToTier[tierItem.priceId];
|
||||
if (newTier) {
|
||||
await rpc("update_brand_plan_tier", { p_brand_id: brandId, p_plan_tier: newTier });
|
||||
await rpc("update_brand_plan_tier", [brandId, newTier]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +77,7 @@ export async function syncSubscriptionFeatures(
|
||||
if (!priceId) continue;
|
||||
const item = subscriptionItems.find((i) => i.priceId === priceId);
|
||||
const enabled = item?.enabled ?? false;
|
||||
await rpc("set_brand_feature", { p_brand_id: brandId, p_feature_key: addonKey, p_enabled: enabled });
|
||||
await rpc("set_brand_feature", [brandId, addonKey, enabled]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +96,11 @@ export async function createOrUpdateSubscription(
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
|
||||
// Get brand's Stripe customer
|
||||
const brands = await rpc<Array<{ stripe_customer_id: string | null }>>(
|
||||
const brandRows = await rpc<Array<BrandSubscription>>(
|
||||
"get_brand_subscription",
|
||||
{ p_brand_id: brandId }
|
||||
[brandId]
|
||||
);
|
||||
const brandData = brands[0] as unknown as BrandSubscription | undefined;
|
||||
const brandData = brandRows[0];
|
||||
const customerId = brandData?.stripe_customer_id;
|
||||
|
||||
if (!customerId) throw new Error("No Stripe customer for brand. Complete Stripe setup first.");
|
||||
@@ -117,7 +110,7 @@ export async function createOrUpdateSubscription(
|
||||
if (!priceId) throw new Error(`No price configured for ${priceKey} (${billingCycle})`);
|
||||
|
||||
// Check if brand already has an active subscription — update it instead of creating new
|
||||
const existingSubId = (brandData as unknown as { stripe_subscription_id?: string })?.stripe_subscription_id;
|
||||
const existingSubId = brandData?.stripe_subscription_id;
|
||||
|
||||
let subscription;
|
||||
if (existingSubId) {
|
||||
@@ -155,12 +148,12 @@ export async function createOrUpdateSubscription(
|
||||
|
||||
// Save subscription ID to brand
|
||||
const subData = subscription as unknown as { id: string; status: string; current_period_end: number };
|
||||
await rpc("set_brand_subscription", {
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: subData.id,
|
||||
p_status: subData.status,
|
||||
p_current_period_end: new Date(subData.current_period_end * 1000).toISOString(),
|
||||
});
|
||||
await rpc("set_brand_subscription", [
|
||||
brandId,
|
||||
subData.id,
|
||||
subData.status,
|
||||
new Date(subData.current_period_end * 1000).toISOString(),
|
||||
]);
|
||||
|
||||
const latestInvoice = subscription.latest_invoice as { payment_intent?: { client_secret?: string } } | null;
|
||||
const invoiceOrNull = typeof subscription.latest_invoice !== "string" ? latestInvoice : null;
|
||||
@@ -179,8 +172,8 @@ export async function cancelBrandSubscription(
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured");
|
||||
|
||||
const brandData = await rpc<BrandSubscription[]>("get_brand_subscription", { p_brand_id: brandId });
|
||||
const brand = brandData[0] as unknown as BrandSubscription | undefined;
|
||||
const brandRows = await rpc<BrandSubscription[]>("get_brand_subscription", [brandId]);
|
||||
const brand = brandRows[0];
|
||||
if (!brand?.stripe_subscription_id) throw new Error("No active subscription to cancel");
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
@@ -200,21 +193,21 @@ export async function cancelBrandSubscription(
|
||||
// Disable the feature flag
|
||||
const addonKey = getAddonKeyFromPriceKey(priceKey);
|
||||
if (addonKey) {
|
||||
await rpc("set_brand_feature", { p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false });
|
||||
await rpc("set_brand_feature", [brandId, addonKey, false]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Cancel entire subscription
|
||||
await stripe.subscriptions.cancel(brand.stripe_subscription_id);
|
||||
await rpc("set_brand_subscription", {
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: "",
|
||||
p_status: "canceled",
|
||||
p_current_period_end: null,
|
||||
});
|
||||
await rpc("set_brand_subscription", [
|
||||
brandId,
|
||||
"",
|
||||
"canceled",
|
||||
null,
|
||||
]);
|
||||
// Disable all add-on features
|
||||
for (const addonKey of Object.keys(ADDONS) as AddonKey[]) {
|
||||
await rpc("set_brand_feature", { p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false });
|
||||
await rpc("set_brand_feature", [brandId, addonKey, false]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,24 +215,24 @@ export async function cancelBrandSubscription(
|
||||
// ── Past due notification ─────────────────────────────────────────────────────
|
||||
|
||||
export async function sendPastDueNotification(brandId: string): Promise<void> {
|
||||
const brandData = await rpc<Array<{ name: string }>>("get_brand_subscription", { p_brand_id: brandId });
|
||||
const brand = brandData[0] as unknown as { name?: string } | undefined;
|
||||
const brandRows = await rpc<Array<{ name: string }>>("get_brand_subscription", [brandId]);
|
||||
const brand = brandRows[0];
|
||||
const brandName = brand?.name ?? "your brand";
|
||||
|
||||
const adminEmail = await getAdminEmail(brandId);
|
||||
|
||||
await rpc("enqueue_notification", {
|
||||
p_brand_id: brandId,
|
||||
p_email_to: adminEmail ?? "team@cielohermosa.com",
|
||||
p_subject: `Payment Failed — ${brandName}`,
|
||||
p_body_html: `
|
||||
await rpc("enqueue_notification", [
|
||||
brandId,
|
||||
adminEmail ?? "team@cielohermosa.com",
|
||||
`Payment Failed — ${brandName}`,
|
||||
`
|
||||
<h2>Payment Failed</h2>
|
||||
<p>We were unable to charge your card for the Cielo Hermosa platform subscription.</p>
|
||||
<p>Please update your payment method within 7 days to avoid service interruption.</p>
|
||||
<p><a href="${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing">Update Payment Method →</a></p>
|
||||
`,
|
||||
p_body_text: `Payment failed for ${brandName}. Please update your payment method at ${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing to avoid service interruption.`,
|
||||
});
|
||||
`Payment failed for ${brandName}. Please update your payment method at ${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing to avoid service interruption.`,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -267,9 +260,10 @@ async function getAdminEmail(brandId: string): Promise<string | null> {
|
||||
try {
|
||||
const data = await rpc<{ notification_email?: string; from_email?: string }>(
|
||||
"get_wholesale_settings",
|
||||
{ p_brand_id: brandId }
|
||||
[brandId]
|
||||
);
|
||||
return data?.notification_email ?? data?.from_email ?? null;
|
||||
const settings = Array.isArray(data) ? data[0] : data;
|
||||
return settings?.notification_email ?? settings?.from_email ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -296,4 +290,4 @@ export function resolvePriceKeyFromPriceId(priceId: string): { type: "plan" | "a
|
||||
if (addonMap[priceId]) return { type: "addon", key: addonMap[priceId] };
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-18
@@ -10,7 +10,9 @@
|
||||
* 3. Use isFeatureEnabled(brandId, "key") to gate UI elements
|
||||
*/
|
||||
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { brandSettings } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type BrandFeatureKey =
|
||||
| "harvest_reach"
|
||||
@@ -170,25 +172,23 @@ export async function isFeatureEnabled(
|
||||
async function fetchBrandFeatures(
|
||||
brandId: string
|
||||
): Promise<Record<BrandFeatureKey, boolean> | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!supabaseUrl || !supabaseKey) return null;
|
||||
|
||||
try {
|
||||
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 }),
|
||||
}
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({ featureFlags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.limit(1)
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data ?? null;
|
||||
const flags = rows[0]?.featureFlags as Record<string, unknown> | null | undefined;
|
||||
if (!flags || typeof flags !== "object") return null;
|
||||
// Coerce to boolean (JSONB may store as boolean, string, or number)
|
||||
const result = {} as Record<BrandFeatureKey, boolean>;
|
||||
for (const key of Object.keys(ADDON_CATALOG) as BrandFeatureKey[]) {
|
||||
const v = (flags as Record<string, unknown>)[key];
|
||||
result[key] = v === true || v === "true" || v === 1 || v === "1";
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
+19
-56
@@ -2,6 +2,7 @@
|
||||
// Supports plans, add-ons, upgrades, cancellations, and customer portal
|
||||
|
||||
import Stripe from "stripe";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// Lazy-initialize Stripe client to avoid build-time errors when env vars aren't set
|
||||
let _stripe: Stripe | null = null;
|
||||
@@ -565,27 +566,17 @@ async function updateBrandSubscription(
|
||||
periodEnd: number | null
|
||||
) {
|
||||
if (!brandId) return;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_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,
|
||||
}),
|
||||
}
|
||||
await pool.query(
|
||||
"SELECT set_brand_subscription($1, $2, $3, $4, $5)",
|
||||
[
|
||||
brandId,
|
||||
subscriptionId,
|
||||
status,
|
||||
planTier ?? null,
|
||||
periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to update brand subscription:", error);
|
||||
@@ -594,25 +585,11 @@ async function updateBrandSubscription(
|
||||
|
||||
async function enableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
|
||||
if (!brandId || !addonKey) return;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_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,
|
||||
}),
|
||||
}
|
||||
await pool.query(
|
||||
"SELECT set_brand_feature($1, $2, $3)",
|
||||
[brandId, addonKey, true]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to enable addon feature:", error);
|
||||
@@ -621,25 +598,11 @@ async function enableAddonFeature(brandId: string | undefined, addonKey: string
|
||||
|
||||
async function disableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
|
||||
if (!brandId || !addonKey) return;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_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,
|
||||
}),
|
||||
}
|
||||
await pool.query(
|
||||
"SELECT set_brand_feature($1, $2, $3)",
|
||||
[brandId, addonKey, false]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to disable addon feature:", error);
|
||||
|
||||
Reference in New Issue
Block a user