From 51a64f12006797f52d04d864f50ed2a2533df3f4 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 12:58:08 +0000 Subject: [PATCH 1/9] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- src/lib/stripe.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/lib/stripe.ts diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..afec5f9 --- /dev/null +++ b/src/lib/stripe.ts @@ -0,0 +1,31 @@ +import { loadStripe, Stripe } from "@stripe/stripe-js"; + +export type StripeEnv = "sandbox" | "live"; + +const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined; + +function paymentsEnvironment(): StripeEnv { + if (clientToken?.startsWith("pk_test_")) return "sandbox"; + if (clientToken?.startsWith("pk_live_")) return "live"; + throw new Error( + "Betalningar är inte konfigurerade för denna build. Slutför go-live i Payments-fliken för att aktivera skarpa betalningar." + ); +} + +let stripePromise: Promise | null = null; + +export function getStripe(): Promise { + if (!stripePromise) { + paymentsEnvironment(); + stripePromise = loadStripe(clientToken as string); + } + return stripePromise; +} + +export function getStripeEnvironment(): StripeEnv { + return paymentsEnvironment(); +} + +export function hasPaymentsConfigured(): boolean { + return !!clientToken && (clientToken.startsWith("pk_test_") || clientToken.startsWith("pk_live_")); +} From ab608f50af37f9cf4dcf14a928408fd8f84671dc 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 12:58:19 +0000 Subject: [PATCH 2/9] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- supabase/functions/_shared/stripe.ts | 87 ++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 supabase/functions/_shared/stripe.ts diff --git a/supabase/functions/_shared/stripe.ts b/supabase/functions/_shared/stripe.ts new file mode 100644 index 0000000..6849b24 --- /dev/null +++ b/supabase/functions/_shared/stripe.ts @@ -0,0 +1,87 @@ +import { encode } from "https://deno.land/std@0.168.0/encoding/hex.ts"; +import Stripe from "https://esm.sh/stripe@22.0.2"; + +const getEnv = (key: string): string => { + const value = Deno.env.get(key); + if (!value) throw new Error(`${key} is not configured`); + return value; +}; + +export type StripeEnv = "sandbox" | "live"; + +const GATEWAY_STRIPE_BASE = "https://connector-gateway.lovable.dev/stripe"; + +export function getConnectionApiKey(env: StripeEnv): string { + return env === "sandbox" + ? getEnv("STRIPE_SANDBOX_API_KEY") + : getEnv("STRIPE_LIVE_API_KEY"); +} + +export function createStripeClient(env: StripeEnv): Stripe { + const connectionApiKey = getConnectionApiKey(env); + const lovableApiKey = getEnv("LOVABLE_API_KEY"); + + return new Stripe(connectionApiKey, { + apiVersion: "2026-03-25.dahlia" as any, + httpClient: Stripe.createFetchHttpClient((input, init) => { + const stripeUrl = input instanceof Request ? input.url : input.toString(); + const gatewayUrl = stripeUrl.replace("https://api.stripe.com", GATEWAY_STRIPE_BASE); + return fetch(gatewayUrl, { + ...init, + headers: { + ...Object.fromEntries( + new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ).entries(), + ), + "X-Connection-Api-Key": connectionApiKey, + "Lovable-API-Key": lovableApiKey, + }, + }); + }), + }); +} + +export async function verifyWebhook( + req: Request, + env: StripeEnv, +): Promise<{ type: string; data: { object: any } }> { + const signature = req.headers.get("stripe-signature"); + const body = await req.text(); + const secret = + env === "sandbox" + ? getEnv("PAYMENTS_SANDBOX_WEBHOOK_SECRET") + : getEnv("PAYMENTS_LIVE_WEBHOOK_SECRET"); + + if (!signature || !body) throw new Error("Missing signature or body"); + + let timestamp: string | undefined; + const v1Signatures: string[] = []; + for (const part of signature.split(",")) { + const [key, value] = part.split("=", 2); + if (key === "t") timestamp = value; + if (key === "v1") v1Signatures.push(value); + } + if (!timestamp || v1Signatures.length === 0) throw new Error("Invalid signature format"); + + const age = Math.abs(Date.now() / 1000 - Number(timestamp)); + if (age > 300) throw new Error("Webhook timestamp too old"); + + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signed = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(`${timestamp}.${body}`), + ); + const expected = new TextDecoder().decode(encode(new Uint8Array(signed))); + + if (!v1Signatures.includes(expected)) throw new Error("Invalid webhook signature"); + + return JSON.parse(body); +} From 8ab301d157d9973ec448ede28167b98be4603245 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 12:58:41 +0000 Subject: [PATCH 3/9] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- supabase/functions/create-checkout/index.ts | 176 ++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 supabase/functions/create-checkout/index.ts diff --git a/supabase/functions/create-checkout/index.ts b/supabase/functions/create-checkout/index.ts new file mode 100644 index 0000000..787e1d4 --- /dev/null +++ b/supabase/functions/create-checkout/index.ts @@ -0,0 +1,176 @@ +import { corsHeaders } from "npm:@supabase/supabase-js@2/cors"; +import { z } from "npm:zod@3.23.8"; +import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts"; + +const PRICE_PER_KG_ORE = 32500; // 325 kr in öre +const WEEKS_PER_MONTH = 4; + +const BodySchema = z.object({ + method: z.enum(["card", "invoice"]), + employees: z.number().int().min(1).max(500), + kg_per_week: z.number().min(1).max(200), + company: z.string().trim().min(1).max(200), + email: z.string().trim().email().max(255), + phone: z.string().trim().max(30).optional().default(""), + address: z.string().trim().max(200).optional().default(""), + postal_code: z.string().trim().min(1).max(20), + city: z.string().trim().max(100).optional().default(""), + comments: z.string().trim().max(1000).optional().default(""), + 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 raw = await req.json(); + const parsed = BodySchema.safeParse(raw); + if (!parsed.success) { + return new Response( + JSON.stringify({ error: "Ogiltiga fält", details: parsed.error.flatten().fieldErrors }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + const b = parsed.data; + + // Stockholm postal enforcement + const cleaned = b.postal_code.replace(/\s/g, ""); + if (!/^\d{5}$/.test(cleaned) || parseInt(cleaned, 10) < 10000 || parseInt(cleaned, 10) > 19999) { + return new Response( + JSON.stringify({ error: "Vi levererar endast inom Storstockholm (100 00 – 199 99)." }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + const env: StripeEnv = b.environment; + const stripe = createStripeClient(env); + + // Monthly amount in öre = kg × 4 veckor × 325 kr + const monthlyAmountOre = Math.round(b.kg_per_week * WEEKS_PER_MONTH * PRICE_PER_KG_ORE); + + // Resolve product via existing price lookup_key + const prices = await stripe.prices.list({ lookup_keys: ["kakabonnemang_dynamic"], limit: 1 }); + if (!prices.data.length) { + return new Response(JSON.stringify({ error: "Produkt inte konfigurerad" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + const productId = + typeof prices.data[0].product === "string" + ? prices.data[0].product + : prices.data[0].product.id; + + // Find or create customer + const existing = await stripe.customers.list({ email: b.email, limit: 1 }); + const customer = + existing.data[0] ?? + (await stripe.customers.create({ + email: b.email, + name: b.company, + phone: b.phone || undefined, + address: b.address + ? { + line1: b.address, + postal_code: cleaned, + city: b.city || undefined, + country: "SE", + } + : undefined, + metadata: { + company: b.company, + employees: String(b.employees), + kg_per_week: String(b.kg_per_week), + comments: b.comments, + }, + })); + + if (b.method === "card") { + // Embedded Checkout with dynamic recurring price_data + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + ui_mode: "embedded_page" as any, + return_url: b.return_url, + customer: customer.id, + line_items: [ + { + quantity: 1, + price_data: { + currency: "sek", + product: productId, + recurring: { interval: "month" }, + unit_amount: monthlyAmountOre, + } as any, + }, + ], + automatic_tax: { enabled: true }, + customer_update: { address: "auto", name: "auto" }, + subscription_data: { + description: `Kakabonnemang – ${b.kg_per_week} kg/vecka`, + metadata: { + company: b.company, + kg_per_week: String(b.kg_per_week), + employees: String(b.employees), + }, + }, + metadata: { + company: b.company, + kg_per_week: String(b.kg_per_week), + }, + } as any); + + return new Response(JSON.stringify({ clientSecret: session.client_secret }), { + status: 200, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Invoice method: create subscription with send_invoice + const subscription = await stripe.subscriptions.create({ + customer: customer.id, + collection_method: "send_invoice", + days_until_due: 30, + items: [ + { + price_data: { + currency: "sek", + product: productId, + recurring: { interval: "month" }, + unit_amount: monthlyAmountOre, + tax_behavior: "exclusive", + } as any, + quantity: 1, + }, + ], + automatic_tax: { enabled: true }, + description: `Kakabonnemang – ${b.kg_per_week} kg/vecka`, + metadata: { + company: b.company, + kg_per_week: String(b.kg_per_week), + employees: String(b.employees), + }, + } as any); + + return new Response( + JSON.stringify({ + invoice: true, + subscription_id: subscription.id, + message: "Faktura skickas till din e-post inom kort.", + }), + { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } catch (err: any) { + console.error("create-checkout error:", err); + return new Response( + JSON.stringify({ error: err?.message ?? "Något gick fel vid checkout" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } +}); From b148fdb33c7d4f434fb3e7e19027416691d997a6 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 12:58:47 +0000 Subject: [PATCH 4/9] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- supabase/functions/payments-webhook/index.ts | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 supabase/functions/payments-webhook/index.ts diff --git a/supabase/functions/payments-webhook/index.ts b/supabase/functions/payments-webhook/index.ts new file mode 100644 index 0000000..98d8670 --- /dev/null +++ b/supabase/functions/payments-webhook/index.ts @@ -0,0 +1,27 @@ +import { verifyWebhook, type StripeEnv } from "../_shared/stripe.ts"; + +Deno.serve(async (req) => { + if (req.method !== "POST") return new Response("Method not allowed", { status: 405 }); + + const rawEnv = new URL(req.url).searchParams.get("env"); + if (rawEnv !== "sandbox" && rawEnv !== "live") { + return new Response(JSON.stringify({ received: true, ignored: "invalid env" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + const env: StripeEnv = rawEnv; + + try { + const event = await verifyWebhook(req, env); + console.log(`[payments-webhook:${env}] ${event.type}`); + // Minimal handler — Stripe dashboard is source of truth for subscriptions. + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (e) { + console.error("Webhook error:", e); + return new Response("Webhook error", { status: 400 }); + } +}); From 95ff3a8707b3a10d8973e7b5d8c322ae465acaf7 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 12:58:52 +0000 Subject: [PATCH 5/9] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- src/components/StripeEmbeddedCheckout.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/components/StripeEmbeddedCheckout.tsx diff --git a/src/components/StripeEmbeddedCheckout.tsx b/src/components/StripeEmbeddedCheckout.tsx new file mode 100644 index 0000000..b9f2aea --- /dev/null +++ b/src/components/StripeEmbeddedCheckout.tsx @@ -0,0 +1,16 @@ +import { EmbeddedCheckoutProvider, EmbeddedCheckout } from "@stripe/react-stripe-js"; +import { getStripe } from "@/lib/stripe"; + +interface Props { + fetchClientSecret: () => Promise; +} + +export function StripeEmbeddedCheckout({ fetchClientSecret }: Props) { + return ( +
+ + + +
+ ); +} From 27f64d4992ec96aba62ae36b1582204887c53fc2 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 12:58:58 +0000 Subject: [PATCH 6/9] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- src/components/PaymentTestModeBanner.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/components/PaymentTestModeBanner.tsx diff --git a/src/components/PaymentTestModeBanner.tsx b/src/components/PaymentTestModeBanner.tsx new file mode 100644 index 0000000..7865139 --- /dev/null +++ b/src/components/PaymentTestModeBanner.tsx @@ -0,0 +1,19 @@ +const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined; + +export function PaymentTestModeBanner() { + if (!clientToken) { + return ( +
+ Skarpa betalningar är inte konfigurerade. Slutför go-live i Payments-fliken. +
+ ); + } + if (clientToken.startsWith("pk_test_")) { + return ( +
+ Alla betalningar i förhandsvisningen är i testläge. Använd kort 4242 4242 4242 4242, valfritt framtida datum och CVC. +
+ ); + } + return null; +} From 44ef2d5b2c5a9621a7a236c14ee742d5cea05f97 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 12:59:03 +0000 Subject: [PATCH 7/9] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- supabase/config.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/supabase/config.toml b/supabase/config.toml index e1dbd7e..1e95f97 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -11,3 +11,9 @@ verify_jwt = false [functions.submit-order] verify_jwt = false + +[functions.create-checkout] +verify_jwt = false + +[functions.payments-webhook] +verify_jwt = false From 71eec28ed150b1b92ee3f2579ca7fd5ac5fd3516 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 12:59:52 +0000 Subject: [PATCH 8/9] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- src/components/PlansSection.tsx | 160 +++++++++++++++++++++++++------- 1 file changed, 128 insertions(+), 32 deletions(-) diff --git a/src/components/PlansSection.tsx b/src/components/PlansSection.tsx index cdf1278..7b46d20 100644 --- a/src/components/PlansSection.tsx +++ b/src/components/PlansSection.tsx @@ -1,12 +1,15 @@ import { useState, useMemo, useEffect } from "react"; -import { Loader2, CheckCircle2 } from "lucide-react"; +import { Loader2, CheckCircle2, CreditCard, FileText } from "lucide-react"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { z } from "zod"; +import { StripeEmbeddedCheckout } from "@/components/StripeEmbeddedCheckout"; +import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe"; const PRICE_PER_KG = 325; const KG_PER_EMPLOYEE = 0.3; +const WEEKS_PER_MONTH = 4; const isStockholmPostalCode = (postalCode: string) => { const cleaned = postalCode.replace(/\s/g, ""); @@ -29,13 +32,17 @@ const formSchema = z.object({ kommentarer: z.string().trim().max(1000).optional(), }); +type Method = "card" | "invoice"; + const PlansSection = () => { const { user } = useAuth(); const [employees, setEmployees] = useState(10); const [showForm, setShowForm] = useState(false); + const [method, setMethod] = useState("card"); const [submitting, setSubmitting] = useState(false); - const [submitted, setSubmitted] = useState(false); + const [submitted, setSubmitted] = useState(null); const [errors, setErrors] = useState>({}); + const [clientSecret, setClientSecret] = useState(null); const [form, setForm] = useState({ företag: "", epost: "", @@ -51,8 +58,8 @@ const PlansSection = () => { [employees] ); const weeklyPrice = recommendedKg * PRICE_PER_KG; + const monthlyPrice = weeklyPrice * WEEKS_PER_MONTH; - // Prefyll från profil om inloggad useEffect(() => { if (!user || !showForm) return; (async () => { @@ -93,31 +100,79 @@ const PlansSection = () => { return; } + if (!hasPaymentsConfigured()) { + toast.error("Betalningar är inte konfigurerade ännu."); + return; + } + setSubmitting(true); try { - const { data, error } = await supabase.functions.invoke("submit-order", { - body: { - ...result.data, - employees, - kg_per_vecka: recommendedKg, - pris_per_vecka: weeklyPrice, - }, - }); + const body = { + method, + employees, + kg_per_week: recommendedKg, + company: result.data.företag, + email: result.data.epost, + phone: result.data.telefon ?? "", + address: result.data.adress ?? "", + postal_code: result.data.postnummer, + city: result.data.stad ?? "", + comments: result.data.kommentarer ?? "", + return_url: `${window.location.origin}/?checkout=success&session_id={CHECKOUT_SESSION_ID}`, + environment: getStripeEnvironment(), + }; + + const { data, error } = await supabase.functions.invoke("create-checkout", { body }); if (error) throw error; if ((data as any)?.error) throw new Error((data as any).error); - setSubmitted(true); - } catch (err) { + + if (method === "card") { + if (!(data as any)?.clientSecret) throw new Error("Kunde inte skapa checkout-session"); + setClientSecret((data as any).clientSecret); + } else { + setSubmitted("invoice"); + } + } catch (err: any) { console.error(err); - toast.error("Kunde inte skicka beställningen. Försök igen."); + toast.error(err?.message || "Kunde inte starta abonnemang. Försök igen."); } finally { setSubmitting(false); } }; + // Show embedded checkout after we get clientSecret + if (clientSecret) { + return ( +
+
+
+
+

+ Slutför betalning +

+

+ {recommendedKg} kg/vecka · {monthlyPrice.toLocaleString("sv-SE")} kr/månad (exkl. moms) +

+
+
+ clientSecret} /> +
+
+ +
+
+
+ ); + } + return (
- {/* Header */}

@@ -128,25 +183,23 @@ const PlansSection = () => {

- {/* Calculator card */}
- {submitted ? ( + {submitted === "invoice" ? (

- Tack för din beställning! + Abonnemang startat!

- Vi har tagit emot din förfrågan för{" "} + Vi har skapat ditt abonnemang på{" "} {recommendedKg} kg kakor per vecka - {" "} - och kontaktar dig inom kort för att bekräfta leverans. + + . Första fakturan skickas till {form.epost} inom kort.

) : !showForm ? ( <> - {/* Employee count */}
{
- {/* Slider */}
{
- {/* Result */}
@@ -191,12 +242,14 @@ const PlansSection = () => {
- Pris per vecka + Pris per månad
- {weeklyPrice.toLocaleString("sv-SE")} kr + {monthlyPrice.toLocaleString("sv-SE")} kr +
+
+ exklusive moms · {weeklyPrice.toLocaleString("sv-SE")} kr/vecka
-
exklusive moms
+ +
+
+

- Vi kontaktar dig för att bekräfta leverans och betalning. + Alla priser exklusive moms. Moms (25%) läggs på vid betalning.

)} From 7d19a9097e1790d4e0dab36e0ab903f61ffca683 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 13:00:07 +0000 Subject: [PATCH 9/9] Changes Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com> --- src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8fcb5ce..ca8c2f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,14 +12,14 @@ import AccountConfirmation from "./pages/AccountConfirmation"; import Contact from "./pages/Contact"; import Admin from "./pages/Admin"; import FAQ from "./pages/FAQ"; +import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner"; const queryClient = new QueryClient(); function AppContent() { - - return ( + } /> } />