From 5c2357fdc1dcbc6400b9eaf2652def9a22d726d6 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:19:54 +0000 Subject: [PATCH] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- supabase/config.toml | 3 + .../functions/create-portal-session/index.ts | 85 +++++++++++++ supabase/functions/payments-webhook/index.ts | 119 +++++++++++++++++- 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 supabase/functions/create-portal-session/index.ts diff --git a/supabase/config.toml b/supabase/config.toml index 1e95f97..d8caa1e 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -17,3 +17,6 @@ verify_jwt = false [functions.payments-webhook] verify_jwt = false + +[functions.create-portal-session] +verify_jwt = false diff --git a/supabase/functions/create-portal-session/index.ts b/supabase/functions/create-portal-session/index.ts new file mode 100644 index 0000000..24cfe20 --- /dev/null +++ b/supabase/functions/create-portal-session/index.ts @@ -0,0 +1,85 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0"; +import { z } from "npm:zod@3.23.8"; +import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, +); + +const BodySchema = z.object({ + return_url: z.string().url(), + environment: z.enum(["sandbox", "live"]), +}); + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + try { + const token = req.headers.get("Authorization")?.replace("Bearer ", "") ?? ""; + const { data: userData, error: authErr } = await supabase.auth.getUser(token); + if (authErr || !userData?.user) { + return new Response(JSON.stringify({ error: "Ej inloggad" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const parsed = BodySchema.safeParse(await req.json()); + if (!parsed.success) { + return new Response(JSON.stringify({ error: "Ogiltiga fält" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const env: StripeEnv = parsed.data.environment; + + // Hämta senaste subscription för denna user i rätt env + const { data: sub } = await supabase + .from("subscriptions") + .select("stripe_customer_id") + .eq("user_id", userData.user.id) + .eq("environment", env) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (!sub?.stripe_customer_id) { + return new Response( + JSON.stringify({ error: "Inget abonnemang hittades för detta konto." }), + { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + const stripe = createStripeClient(env); + const portal = await stripe.billingPortal.sessions.create({ + customer: sub.stripe_customer_id as string, + return_url: parsed.data.return_url, + }); + + return new Response(JSON.stringify({ url: portal.url }), { + status: 200, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (e: any) { + console.error("create-portal-session error:", e); + return new Response(JSON.stringify({ error: e?.message ?? "Kunde inte öppna kundportal" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/functions/payments-webhook/index.ts b/supabase/functions/payments-webhook/index.ts index 98d8670..84f2569 100644 --- a/supabase/functions/payments-webhook/index.ts +++ b/supabase/functions/payments-webhook/index.ts @@ -1,5 +1,106 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0"; import { verifyWebhook, type StripeEnv } from "../_shared/stripe.ts"; +const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, +); + +/** Find existing auth user by email, or create one and send magic-link invite. */ +async function ensureUser(email: string, companyName?: string): Promise { + if (!email) return null; + try { + // Search by email (paginated). For low volumes this is fine. + const { data: list } = await supabase.auth.admin.listUsers({ page: 1, perPage: 200 }); + const existing = list?.users?.find((u) => u.email?.toLowerCase() === email.toLowerCase()); + if (existing) return existing.id; + + // Create + invite so kunden får ett välkomstmejl med länk att sätta lösenord. + const siteUrl = Deno.env.get("SUPABASE_URL")?.replace(".supabase.co", ".lovable.app") ?? undefined; + const { data: invited, error: inviteErr } = await supabase.auth.admin.inviteUserByEmail(email, { + data: { company_name: companyName ?? null }, + redirectTo: siteUrl ? `${siteUrl}/account` : undefined, + }); + if (inviteErr) { + console.error("inviteUserByEmail failed:", inviteErr); + return null; + } + return invited?.user?.id ?? null; + } catch (e) { + console.error("ensureUser error:", e); + return null; + } +} + +async function upsertSubscription(sub: any, env: StripeEnv) { + const item = sub.items?.data?.[0]; + const priceId = item?.price?.lookup_key || item?.price?.id; + const periodStart = item?.current_period_start ?? sub.current_period_start; + const periodEnd = item?.current_period_end ?? sub.current_period_end; + + const customerEmail: string = + sub.metadata?.customer_email || + sub.customer_email || + (typeof sub.customer === "object" ? sub.customer?.email : null) || + ""; + const companyName = sub.metadata?.company ?? null; + const kgPerWeek = sub.metadata?.kg_per_week ? Number(sub.metadata.kg_per_week) : null; + const unitAmount = item?.price?.unit_amount ?? 0; + const quantity = item?.quantity ?? 1; + const monthlyAmount = (unitAmount * quantity) / 100; + + // Ensure a Lovable Cloud user exists for this email, unless we can already + // link via customer metadata.userId (future-proof). + let userId: string | null = sub.metadata?.userId ?? null; + if (!userId && customerEmail) { + userId = await ensureUser(customerEmail, companyName ?? undefined); + } + + await supabase.from("subscriptions").upsert( + { + user_id: userId, + customer_email: customerEmail, + company_name: companyName, + stripe_subscription_id: sub.id, + stripe_customer_id: typeof sub.customer === "string" ? sub.customer : sub.customer?.id, + price_id: priceId, + kg_per_week: kgPerWeek, + monthly_amount: monthlyAmount || null, + status: sub.status, + collection_method: sub.collection_method ?? null, + current_period_start: periodStart ? new Date(periodStart * 1000).toISOString() : null, + current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null, + cancel_at_period_end: !!sub.cancel_at_period_end, + environment: env, + updated_at: new Date().toISOString(), + }, + { onConflict: "stripe_subscription_id" }, + ); +} + +async function markCanceled(sub: any, env: StripeEnv) { + const periodEnd = sub.items?.data?.[0]?.current_period_end ?? sub.current_period_end; + await supabase + .from("subscriptions") + .update({ + status: "canceled", + cancel_at_period_end: !!sub.cancel_at_period_end, + current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null, + updated_at: new Date().toISOString(), + }) + .eq("stripe_subscription_id", sub.id) + .eq("environment", env); +} + +async function handleCheckoutCompleted(session: any, env: StripeEnv) { + // For subscription checkouts, subscription events kommer separat — men + // vi ser till att koppla user_id om vi har e-post redan här. + const email = session.customer_details?.email || session.customer_email; + if (email) { + await ensureUser(email, session.metadata?.company); + } +} + Deno.serve(async (req) => { if (req.method !== "POST") return new Response("Method not allowed", { status: 405 }); @@ -15,7 +116,23 @@ Deno.serve(async (req) => { try { const event = await verifyWebhook(req, env); console.log(`[payments-webhook:${env}] ${event.type}`); - // Minimal handler — Stripe dashboard is source of truth for subscriptions. + + switch (event.type) { + case "customer.subscription.created": + case "customer.subscription.updated": + await upsertSubscription(event.data.object, env); + break; + case "customer.subscription.deleted": + await markCanceled(event.data.object, env); + break; + case "checkout.session.completed": + await handleCheckoutCompleted(event.data.object, env); + break; + default: + // ignore + break; + } + return new Response(JSON.stringify({ received: true }), { status: 200, headers: { "Content-Type": "application/json" },