From c1d75b1ee95cdd8af7fa10beb312ab3efb160032 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:14 +0000 Subject: [PATCH 1/7] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- src/integrations/supabase/types.ts | 64 +++++++++++++++++++ ...1_52085f03-1c74-4baa-bffd-133ac1ffb57e.sql | 60 +++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 supabase/migrations/20260711181911_52085f03-1c74-4baa-bffd-133ac1ffb57e.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 6ba0e35..cd38c86 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -186,6 +186,66 @@ export type Database = { } Relationships: [] } + subscriptions: { + Row: { + cancel_at_period_end: boolean + collection_method: string | null + company_name: string | null + created_at: string + current_period_end: string | null + current_period_start: string | null + customer_email: string + environment: string + id: string + kg_per_week: number | null + monthly_amount: number | null + price_id: string | null + status: string + stripe_customer_id: string + stripe_subscription_id: string + updated_at: string + user_id: string | null + } + Insert: { + cancel_at_period_end?: boolean + collection_method?: string | null + company_name?: string | null + created_at?: string + current_period_end?: string | null + current_period_start?: string | null + customer_email: string + environment?: string + id?: string + kg_per_week?: number | null + monthly_amount?: number | null + price_id?: string | null + status?: string + stripe_customer_id: string + stripe_subscription_id: string + updated_at?: string + user_id?: string | null + } + Update: { + cancel_at_period_end?: boolean + collection_method?: string | null + company_name?: string | null + created_at?: string + current_period_end?: string | null + current_period_start?: string | null + customer_email?: string + environment?: string + id?: string + kg_per_week?: number | null + monthly_amount?: number | null + price_id?: string | null + status?: string + stripe_customer_id?: string + stripe_subscription_id?: string + updated_at?: string + user_id?: string | null + } + Relationships: [] + } user_roles: { Row: { created_at: string @@ -212,6 +272,10 @@ export type Database = { [_ in never]: never } Functions: { + has_active_kaka_subscription: { + Args: { user_uuid: string } + Returns: boolean + } has_role: { Args: { _role: Database["public"]["Enums"]["app_role"] diff --git a/supabase/migrations/20260711181911_52085f03-1c74-4baa-bffd-133ac1ffb57e.sql b/supabase/migrations/20260711181911_52085f03-1c74-4baa-bffd-133ac1ffb57e.sql new file mode 100644 index 0000000..a26a105 --- /dev/null +++ b/supabase/migrations/20260711181911_52085f03-1c74-4baa-bffd-133ac1ffb57e.sql @@ -0,0 +1,60 @@ + +CREATE TABLE public.subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + customer_email TEXT NOT NULL, + company_name TEXT, + stripe_subscription_id TEXT NOT NULL UNIQUE, + stripe_customer_id TEXT NOT NULL, + price_id TEXT, + kg_per_week NUMERIC, + monthly_amount NUMERIC, + status TEXT NOT NULL DEFAULT 'active', + collection_method TEXT, + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + cancel_at_period_end BOOLEAN NOT NULL DEFAULT false, + environment TEXT NOT NULL DEFAULT 'sandbox', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id); +CREATE INDEX idx_subscriptions_customer_email ON public.subscriptions(customer_email); +CREATE INDEX idx_subscriptions_stripe_id ON public.subscriptions(stripe_subscription_id); + +GRANT SELECT ON public.subscriptions TO authenticated; +GRANT ALL ON public.subscriptions TO service_role; + +ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view own subscriptions" + ON public.subscriptions FOR SELECT + TO authenticated + USING (auth.uid() = user_id); + +CREATE POLICY "Admins can view all subscriptions" + ON public.subscriptions FOR SELECT + TO authenticated + USING (public.has_role(auth.uid(), 'admin'::app_role)); + +CREATE TRIGGER update_subscriptions_updated_at + BEFORE UPDATE ON public.subscriptions + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE OR REPLACE FUNCTION public.has_active_kaka_subscription(user_uuid UUID) +RETURNS BOOLEAN +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.subscriptions + WHERE user_id = user_uuid + AND ( + (status IN ('active','trialing','past_due') AND (current_period_end IS NULL OR current_period_end > now())) + OR (status = 'canceled' AND current_period_end > now()) + ) + ); +$$; From 4e2c456c65e6a70af95992fe7677e581f2af43d6 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:22 +0000 Subject: [PATCH 2/7] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- .../20260711181920_cb5615af-f3fd-407b-add7-95ab8e176b2b.sql | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 supabase/migrations/20260711181920_cb5615af-f3fd-407b-add7-95ab8e176b2b.sql diff --git a/supabase/migrations/20260711181920_cb5615af-f3fd-407b-add7-95ab8e176b2b.sql b/supabase/migrations/20260711181920_cb5615af-f3fd-407b-add7-95ab8e176b2b.sql new file mode 100644 index 0000000..17178f3 --- /dev/null +++ b/supabase/migrations/20260711181920_cb5615af-f3fd-407b-add7-95ab8e176b2b.sql @@ -0,0 +1,3 @@ + +REVOKE EXECUTE ON FUNCTION public.has_active_kaka_subscription(UUID) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.has_active_kaka_subscription(UUID) TO service_role; 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 3/7] 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" }, From b2861d12109329881af50471bd2a6e2b9ac054cd 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:20:10 +0000 Subject: [PATCH 4/7] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- src/pages/Account.tsx | 3 ++- supabase/functions/create-checkout/index.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/Account.tsx b/src/pages/Account.tsx index 054aae1..6cd8b63 100644 --- a/src/pages/Account.tsx +++ b/src/pages/Account.tsx @@ -7,8 +7,9 @@ import { Label } from "@/components/ui/label"; import { useAuth } from "@/hooks/useAuth"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; +import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe"; import Header from "@/components/Header"; -import { Save, Package } from "lucide-react"; +import { Save, Package, ExternalLink } from "lucide-react"; // Validate Stockholm postal codes (100 00 - 199 99) diff --git a/supabase/functions/create-checkout/index.ts b/supabase/functions/create-checkout/index.ts index 11eb29a..3b0ccf8 100644 --- a/supabase/functions/create-checkout/index.ts +++ b/supabase/functions/create-checkout/index.ts @@ -204,12 +204,14 @@ Deno.serve(async (req) => { description: `Kakabonnemang – ${b.kg_per_week} kg/vecka`, metadata: { company: b.company, + customer_email: b.email, kg_per_week: String(b.kg_per_week), employees: String(b.employees), }, }, metadata: { company: b.company, + customer_email: b.email, kg_per_week: String(b.kg_per_week), }, } as any); @@ -251,6 +253,7 @@ Deno.serve(async (req) => { description: `Kakabonnemang – ${b.kg_per_week} kg/vecka`, metadata: { company: b.company, + customer_email: b.email, kg_per_week: String(b.kg_per_week), employees: String(b.employees), }, From 646db7299d7f7b5d81c9cc0505c0c357b5c477fb 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:20:19 +0000 Subject: [PATCH 5/7] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- src/pages/Account.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/pages/Account.tsx b/src/pages/Account.tsx index 6cd8b63..6de65d8 100644 --- a/src/pages/Account.tsx +++ b/src/pages/Account.tsx @@ -54,6 +54,16 @@ const getPostalCodeError = (postalCode: string) => { return null; }; +interface SubscriptionRow { + id: string; + status: string; + kg_per_week: number | null; + monthly_amount: number | null; + current_period_end: string | null; + cancel_at_period_end: boolean; + collection_method: string | null; +} + const Account = () => { const [profile, setProfile] = useState({ company_name: "", @@ -69,6 +79,8 @@ const Account = () => { }); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); + const [subscription, setSubscription] = useState(null); + const [openingPortal, setOpeningPortal] = useState(false); const { user, loading: authLoading, signOut } = useAuth(); const { toast } = useToast(); const navigate = useNavigate(); From 6b1ea0698fe9e45e05afb33bff3fb1f991640073 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:20:30 +0000 Subject: [PATCH 6/7] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- src/pages/Account.tsx | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/pages/Account.tsx b/src/pages/Account.tsx index 6de65d8..0a40fdc 100644 --- a/src/pages/Account.tsx +++ b/src/pages/Account.tsx @@ -100,9 +100,57 @@ const Account = () => { if (user) { fetchProfile(); + fetchSubscription(); } }, [user, authLoading, navigate]); + const fetchSubscription = async () => { + if (!user) return; + try { + const env = hasPaymentsConfigured() ? getStripeEnvironment() : "sandbox"; + const { data } = await supabase + .from("subscriptions") + .select("id, status, kg_per_week, monthly_amount, current_period_end, cancel_at_period_end, collection_method") + .eq("user_id", user.id) + .eq("environment", env) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + setSubscription((data as SubscriptionRow | null) ?? null); + } catch (e) { + console.error("fetchSubscription error:", e); + } + }; + + const handleManageSubscription = async () => { + if (!hasPaymentsConfigured()) { + toast({ title: "Betalningar inte konfigurerade", variant: "destructive" }); + return; + } + setOpeningPortal(true); + try { + const { data, error } = await supabase.functions.invoke("create-portal-session", { + body: { + return_url: `${window.location.origin}/account`, + environment: getStripeEnvironment(), + }, + }); + if (error) throw error; + if ((data as any)?.error) throw new Error((data as any).error); + const url = (data as any)?.url; + if (!url) throw new Error("Ingen portal-URL mottagen"); + window.open(url, "_blank", "noopener,noreferrer"); + } catch (e: any) { + toast({ + title: "Kunde inte öppna kundportalen", + description: e?.message ?? String(e), + variant: "destructive", + }); + } finally { + setOpeningPortal(false); + } + }; + const fetchProfile = async () => { try { const { data, error } = await supabase From 14ccf70a24e85e4aa569ddf9a49f3bc28aeb101b 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:20:48 +0000 Subject: [PATCH 7/7] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- src/pages/Account.tsx | 104 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 20 deletions(-) diff --git a/src/pages/Account.tsx b/src/pages/Account.tsx index 0a40fdc..08dfcbf 100644 --- a/src/pages/Account.tsx +++ b/src/pages/Account.tsx @@ -363,26 +363,90 @@ const Account = () => {

Mitt abonnemang

-
-

- Du har för närvarande inget aktivt abonnemang. -

- -
+ {subscription ? ( +
+
+
+
+ Ditt abonnemang +
+
+ {subscription.kg_per_week ?? "—"} kg / vecka +
+ {subscription.monthly_amount && ( +
+ {Math.round(subscription.monthly_amount * 1.25).toLocaleString("sv-SE")} kr/mån ink moms + {" · "} + {subscription.collection_method === "send_invoice" ? "Faktura" : "Kort"} +
+ )} +
+ + {subscription.cancel_at_period_end + ? "Avslutas vid periodens slut" + : subscription.status === "active" + ? "Aktiv" + : subscription.status === "trialing" + ? "Provperiod" + : subscription.status === "past_due" + ? "Obetald – Stripe försöker igen" + : subscription.status === "canceled" + ? "Avslutad" + : subscription.status} + +
+ + {subscription.current_period_end && ( +

+ {subscription.cancel_at_period_end || subscription.status === "canceled" + ? "Sista leveransperiod till: " + : "Nästa förnyelse: "} + {new Date(subscription.current_period_end).toLocaleDateString("sv-SE")} +

+ )} + + +

+ Ändra kort, ladda ner fakturor eller säg upp — tillgång kvar till periodens slut. +

+
+ ) : ( +
+

+ Du har för närvarande inget aktivt abonnemang. +

+ +
+ )}