///
import Stripe from "npm:stripe@16.12.0";
type EntitlementCode = "free" | "mensal" | "trimestral" | "anual" | "pro" | "trial";
const STRIPE_SECRET_KEY = Deno.env.get("STRIPE_SECRET_KEY") ?? "";
const STRIPE_WEBHOOK_SECRET = Deno.env.get("STRIPE_WEBHOOK_SECRET") ?? "";
// ✅ nomes oficiais no Supabase Edge
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
const REQUIRED_OK = !!(
STRIPE_SECRET_KEY &&
STRIPE_WEBHOOK_SECRET &&
SUPABASE_URL &&
SUPABASE_SERVICE_ROLE_KEY
);
const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2025-11-17.clover" });
function json(data: unknown, status = 200, extraHeaders: Record = {}) {
return new Response(JSON.stringify(data), {
status,
headers: {
"content-type": "application/json",
...extraHeaders,
},
});
}
function corsHeaders(origin: string | null) {
const allowOrigin = origin ?? "*";
return {
"Access-Control-Allow-Origin": allowOrigin,
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, stripe-signature",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
}
async function supabaseAdmin(path: string, init?: RequestInit) {
const url = `${SUPABASE_URL}${path}`;
return fetch(url, {
...init,
headers: {
"content-type": "application/json",
apikey: SUPABASE_SERVICE_ROLE_KEY,
authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
...(init?.headers || {}),
},
});
}
async function upsertStripeCustomer(
user_id: string,
stripe_customer_id: string,
email?: string | null,
) {
const res = await supabaseAdmin(`/rest/v1/stripe_customers?on_conflict=user_id`, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({
user_id,
stripe_customer_id,
email: email ?? null,
updated_at: new Date().toISOString(),
}),
});
if (!res.ok) {
const t = await res.text();
throw new Error(`stripe_customers upsert failed: ${res.status} ${t}`);
}
}
async function upsertEntitlement(
user_id: string,
entitlement_code: EntitlementCode,
is_active: boolean,
valid_until: string | null,
) {
const res = await supabaseAdmin(`/rest/v1/user_entitlements?on_conflict=user_id`, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({
user_id,
entitlement_code,
is_active,
valid_until,
updated_at: new Date().toISOString(),
}),
});
if (!res.ok) {
const t = await res.text();
throw new Error(`user_entitlements upsert failed: ${res.status} ${t}`);
}
}
function safePlanCode(v: unknown): EntitlementCode {
const s = String(v ?? "").toLowerCase().trim();
if (s === "mensal" || s === "trimestral" || s === "anual" || s === "pro" || s === "trial" || s === "free") {
return s;
}
return "free";
}
function secondsToISO(sec?: number | null) {
if (!sec || !Number.isFinite(sec)) return null;
return new Date(sec * 1000).toISOString();
}
/**
* ✅ Correção do valid_until:
* Em alguns payloads, `current_period_end` NÃO vem no root da subscription.
* Ele vem em `items.data[0].current_period_end`.
*/
function getPeriodEndISO(sub: Stripe.Subscription) {
const sec =
(sub as any).current_period_end ??
(sub as any)?.items?.data?.[0]?.current_period_end ??
null;
return secondsToISO(sec);
}
async function resolveUserId(customerId?: string | null, metadataUserId?: string | null) {
if (metadataUserId) return metadataUserId;
if (!customerId) return null;
const q = new URLSearchParams();
q.set("stripe_customer_id", `eq.${customerId}`);
q.set("select", "user_id");
q.set("limit", "1");
const res = await supabaseAdmin(`/rest/v1/stripe_customers?${q.toString()}`, { method: "GET" });
if (!res.ok) return null;
const rows = await res.json();
return rows?.[0]?.user_id ?? null;
}
Deno.serve(async (req) => {
const origin = req.headers.get("origin");
const cors = corsHeaders(origin);
// Preflight (não é obrigatório pro Stripe, mas não atrapalha)
if (req.method === "OPTIONS") return new Response("ok", { headers: cors });
if (!REQUIRED_OK) {
console.error("Missing required env vars.", {
hasStripeKey: !!STRIPE_SECRET_KEY,
hasWhsec: !!STRIPE_WEBHOOK_SECRET,
hasSbUrl: !!SUPABASE_URL,
hasSr: !!SUPABASE_SERVICE_ROLE_KEY,
});
return json({ ok: false, error: "Missing required env vars" }, 500, cors);
}
// Stripe manda POST
if (req.method !== "POST") return json({ ok: false, error: "Method not allowed" }, 405, cors);
const sig = req.headers.get("stripe-signature") ?? "";
if (!sig) return json({ ok: false, error: "Missing stripe-signature" }, 400, cors);
const raw = await req.text();
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(raw, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return json({ ok: false, error: "Invalid signature" }, 400, cors);
}
try {
const t = event.type;
// 1) Checkout finalizado
if (t === "checkout.session.completed") {
const s = event.data.object as Stripe.Checkout.Session;
const customerId = (s.customer as string | null) ?? null;
const userId = await resolveUserId(customerId, (s.metadata?.user_id as string | undefined) ?? null);
if (!userId) return json({ ok: true, skipped: true, reason: "no_user_id" }, 200, cors);
const plan = safePlanCode(s.metadata?.plan_code);
const email = (s.customer_details?.email ?? s.customer_email ?? null) as string | null;
if (customerId) await upsertStripeCustomer(userId, customerId, email);
// ✅ tenta já trazer o valid_until buscando a subscription (quando existir)
let validUntil: string | null = null;
if (s.subscription) {
const sub = await stripe.subscriptions.retrieve(String(s.subscription));
validUntil = getPeriodEndISO(sub) ?? null;
}
await upsertEntitlement(userId, plan, true, validUntil);
return json({ ok: true }, 200, cors);
}
// 2) Subscription é a fonte da verdade
if (t === "customer.subscription.created" || t === "customer.subscription.updated") {
const sub = event.data.object as Stripe.Subscription;
const customerId = (sub.customer as string | null) ?? null;
const userId = await resolveUserId(customerId, (sub.metadata?.user_id as string | undefined) ?? null);
if (!userId) return json({ ok: true, skipped: true, reason: "no_user_id" }, 200, cors);
const plan = safePlanCode(sub.metadata?.plan_code);
const isActive = sub.status === "active" || sub.status === "trialing";
const validUntil = getPeriodEndISO(sub) ?? null;
if (customerId) await upsertStripeCustomer(userId, customerId, null);
await upsertEntitlement(userId, plan, isActive, validUntil);
return json({ ok: true, plan, isActive, validUntil }, 200, cors);
}
// 3) Pause/Delete: volta pro free
if (t === "customer.subscription.paused" || t === "customer.subscription.deleted") {
const sub = event.data.object as Stripe.Subscription;
const customerId = (sub.customer as string | null) ?? null;
const userId = await resolveUserId(customerId, (sub.metadata?.user_id as string | undefined) ?? null);
if (!userId) return json({ ok: true, skipped: true, reason: "no_user_id" }, 200, cors);
await upsertEntitlement(userId, "free", false, null);
return json({ ok: true }, 200, cors);
}
// 4) Pagamento Confirmado (Salvar no Histórico)
if (t === "invoice.payment_succeeded") {
const invoice = event.data.object as Stripe.Invoice;
const customerId = (invoice.customer as string | null) ?? null;
// Tenta pegar user_id do metadata da subscription ou do cliente
let userId = await resolveUserId(customerId, null);
// Fallback: Tenta pegar da subscription associada à invoice
if (!userId && invoice.subscription) {
try {
const sub = await stripe.subscriptions.retrieve(String(invoice.subscription));
userId = await resolveUserId(customerId, (sub.metadata?.user_id as string | undefined) ?? null);
} catch (e) {
console.error("Error retrieving subscription for userId fallback:", e);
}
}
if (!userId) {
console.error("Invoice payment succeeded but could not resolve userId", { customerId, invoiceId: invoice.id });
return json({ ok: true, skipped: true, reason: "no_user_id_for_invoice" }, 200, cors);
}
// Mapeia dados
const amount = (invoice.amount_paid || 0) / 100; // Centavos para Real
const currency = invoice.currency;
const status = "completed";
const method = invoice.collection_method === "charge_automatically" ? "credit_card" : "other"; // Simplificado
// Tenta adivinhar o plano pelo valor ou linhas da fatura (básico)
// Idealmente viria do metadata, mas na invoice pode ser mais chato de pegar sem chamada extra
const lines = invoice.lines?.data || [];
const planDescription = lines.length > 0 ? lines[0].description : "Assinatura";
let planType = "monthly";
if (planDescription?.toLowerCase().includes("anual")) planType = "yearly";
if (planDescription?.toLowerCase().includes("trimestral")) planType = "quarterly";
// Insere na tabela payments
const { error: payErr } = await supabaseAdmin(`/rest/v1/payments`, {
method: "POST",
body: JSON.stringify({
user_id: userId,
amount: amount,
status: status,
plan_type: planType,
payment_method: method,
created_at: new Date().toISOString()
}),
});
if (payErr) {
// Loga erro mas não retorna 500 para não travar o webhook do Stripe (que tentaria reenviar)
console.error("Error inserting payment record:", payErr);
}
return json({ ok: true, message: "Payment recorded" }, 200, cors);
}
return json({ ok: true, ignored: true, type: t }, 200, cors);
} catch (err) {
console.error("stripe-webhook handler error:", err);
return json({ ok: false, error: String((err as any)?.message ?? err) }, 500, cors);
}
});