Skapat abonnemangsflöde
X-Lovable-Edit-ID: edt-825926b7-8451-4268-900d-95b2863865db Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com>
This commit is contained in:
+2
-2
@@ -12,14 +12,14 @@ import AccountConfirmation from "./pages/AccountConfirmation";
|
|||||||
import Contact from "./pages/Contact";
|
import Contact from "./pages/Contact";
|
||||||
import Admin from "./pages/Admin";
|
import Admin from "./pages/Admin";
|
||||||
import FAQ from "./pages/FAQ";
|
import FAQ from "./pages/FAQ";
|
||||||
|
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<PaymentTestModeBanner />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/contact" element={<Contact />} />
|
<Route path="/contact" element={<Contact />} />
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
|
||||||
|
|
||||||
|
export function PaymentTestModeBanner() {
|
||||||
|
if (!clientToken) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-red-100 border-b border-red-300 px-4 py-2 text-center text-sm text-red-800">
|
||||||
|
Skarpa betalningar är inte konfigurerade. Slutför go-live i Payments-fliken.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (clientToken.startsWith("pk_test_")) {
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-orange-100 border-b border-orange-300 px-4 py-2 text-center text-sm text-orange-800">
|
||||||
|
Alla betalningar i förhandsvisningen är i testläge. Använd kort <strong>4242 4242 4242 4242</strong>, valfritt framtida datum och CVC.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+128
-32
@@ -1,12 +1,15 @@
|
|||||||
import { useState, useMemo, useEffect } from "react";
|
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 { supabase } from "@/integrations/supabase/client";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { StripeEmbeddedCheckout } from "@/components/StripeEmbeddedCheckout";
|
||||||
|
import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe";
|
||||||
|
|
||||||
const PRICE_PER_KG = 325;
|
const PRICE_PER_KG = 325;
|
||||||
const KG_PER_EMPLOYEE = 0.3;
|
const KG_PER_EMPLOYEE = 0.3;
|
||||||
|
const WEEKS_PER_MONTH = 4;
|
||||||
|
|
||||||
const isStockholmPostalCode = (postalCode: string) => {
|
const isStockholmPostalCode = (postalCode: string) => {
|
||||||
const cleaned = postalCode.replace(/\s/g, "");
|
const cleaned = postalCode.replace(/\s/g, "");
|
||||||
@@ -29,13 +32,17 @@ const formSchema = z.object({
|
|||||||
kommentarer: z.string().trim().max(1000).optional(),
|
kommentarer: z.string().trim().max(1000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type Method = "card" | "invoice";
|
||||||
|
|
||||||
const PlansSection = () => {
|
const PlansSection = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [employees, setEmployees] = useState(10);
|
const [employees, setEmployees] = useState(10);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [method, setMethod] = useState<Method>("card");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState<null | "card" | "invoice">(null);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
företag: "",
|
företag: "",
|
||||||
epost: "",
|
epost: "",
|
||||||
@@ -51,8 +58,8 @@ const PlansSection = () => {
|
|||||||
[employees]
|
[employees]
|
||||||
);
|
);
|
||||||
const weeklyPrice = recommendedKg * PRICE_PER_KG;
|
const weeklyPrice = recommendedKg * PRICE_PER_KG;
|
||||||
|
const monthlyPrice = weeklyPrice * WEEKS_PER_MONTH;
|
||||||
|
|
||||||
// Prefyll från profil om inloggad
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !showForm) return;
|
if (!user || !showForm) return;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -93,31 +100,79 @@ const PlansSection = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasPaymentsConfigured()) {
|
||||||
|
toast.error("Betalningar är inte konfigurerade ännu.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase.functions.invoke("submit-order", {
|
const body = {
|
||||||
body: {
|
method,
|
||||||
...result.data,
|
employees,
|
||||||
employees,
|
kg_per_week: recommendedKg,
|
||||||
kg_per_vecka: recommendedKg,
|
company: result.data.företag,
|
||||||
pris_per_vecka: weeklyPrice,
|
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 (error) throw error;
|
||||||
if ((data as any)?.error) throw new Error((data as any).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);
|
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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show embedded checkout after we get clientSecret
|
||||||
|
if (clientSecret) {
|
||||||
|
return (
|
||||||
|
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
|
||||||
|
<h2 className="font-display text-3xl md:text-5xl font-semibold mb-2">
|
||||||
|
Slutför betalning
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{recommendedKg} kg/vecka · {monthlyPrice.toLocaleString("sv-SE")} kr/månad (exkl. moms)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border border-border rounded-sm p-4 md:p-6">
|
||||||
|
<StripeEmbeddedCheckout fetchClientSecret={async () => clientSecret} />
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setClientSecret(null)}
|
||||||
|
className="text-sm text-muted-foreground underline hover:text-foreground"
|
||||||
|
>
|
||||||
|
← Avbryt och gå tillbaka
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
|
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center mb-10 md:mb-14">
|
<div className="text-center mb-10 md:mb-14">
|
||||||
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
|
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
|
||||||
<h2 className="font-display text-3xl md:text-5xl font-semibold mb-4">
|
<h2 className="font-display text-3xl md:text-5xl font-semibold mb-4">
|
||||||
@@ -128,25 +183,23 @@ const PlansSection = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calculator card */}
|
|
||||||
<div className="bg-card border border-border rounded-sm shadow-[0_10px_40px_-15px_rgba(0,0,0,0.15)] p-6 md:p-12">
|
<div className="bg-card border border-border rounded-sm shadow-[0_10px_40px_-15px_rgba(0,0,0,0.15)] p-6 md:p-12">
|
||||||
{submitted ? (
|
{submitted === "invoice" ? (
|
||||||
<div className="text-center py-8 animate-fade-in">
|
<div className="text-center py-8 animate-fade-in">
|
||||||
<CheckCircle2 className="w-16 h-16 text-primary mx-auto mb-4" />
|
<CheckCircle2 className="w-16 h-16 text-primary mx-auto mb-4" />
|
||||||
<h3 className="font-display text-3xl md:text-4xl font-semibold mb-3">
|
<h3 className="font-display text-3xl md:text-4xl font-semibold mb-3">
|
||||||
Tack för din beställning!
|
Abonnemang startat!
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground max-w-md mx-auto">
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
Vi har tagit emot din förfrågan för{" "}
|
Vi har skapat ditt abonnemang på{" "}
|
||||||
<strong className="text-foreground">
|
<strong className="text-foreground">
|
||||||
{recommendedKg} kg kakor per vecka
|
{recommendedKg} kg kakor per vecka
|
||||||
</strong>{" "}
|
</strong>
|
||||||
och kontaktar dig inom kort för att bekräfta leverans.
|
. Första fakturan skickas till <strong>{form.epost}</strong> inom kort.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : !showForm ? (
|
) : !showForm ? (
|
||||||
<>
|
<>
|
||||||
{/* Employee count */}
|
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div
|
<div
|
||||||
key={employees}
|
key={employees}
|
||||||
@@ -159,7 +212,6 @@ const PlansSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Slider */}
|
|
||||||
<div className="mb-10 md:mb-12">
|
<div className="mb-10 md:mb-12">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -177,7 +229,6 @@ const PlansSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Result */}
|
|
||||||
<div className="text-center border-t border-border pt-8 md:pt-10 space-y-6">
|
<div className="text-center border-t border-border pt-8 md:pt-10 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
|
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
|
||||||
@@ -191,12 +242,14 @@ const PlansSection = () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
|
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
|
||||||
Pris per vecka
|
Pris per månad
|
||||||
</div>
|
</div>
|
||||||
<div className="font-display text-3xl md:text-4xl font-semibold text-primary tabular-nums">
|
<div className="font-display text-3xl md:text-4xl font-semibold text-primary tabular-nums">
|
||||||
{weeklyPrice.toLocaleString("sv-SE")} kr
|
{monthlyPrice.toLocaleString("sv-SE")} kr
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs mt-1">
|
||||||
|
exklusive moms · {weeklyPrice.toLocaleString("sv-SE")} kr/vecka
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-xs mt-1">exklusive moms</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -211,12 +264,12 @@ const PlansSection = () => {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-5 animate-fade-in">
|
<form onSubmit={handleSubmit} className="space-y-5 animate-fade-in">
|
||||||
<div className="text-center mb-2">
|
<div className="text-center mb-2">
|
||||||
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-1">
|
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-1">
|
||||||
Din beställning
|
Ditt abonnemang
|
||||||
</div>
|
</div>
|
||||||
<div className="font-display text-2xl md:text-3xl font-semibold">
|
<div className="font-display text-2xl md:text-3xl font-semibold">
|
||||||
{recommendedKg} kg/vecka —{" "}
|
{recommendedKg} kg/vecka —{" "}
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
{weeklyPrice.toLocaleString("sv-SE")} kr
|
{monthlyPrice.toLocaleString("sv-SE")} kr/mån
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
@@ -224,6 +277,47 @@ const PlansSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Betalningsmetod */}
|
||||||
|
<div>
|
||||||
|
<label className="block mb-2 text-sm font-semibold">Betalningsmetod</label>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMethod("card")}
|
||||||
|
className={`flex items-center gap-3 p-4 rounded-sm border text-left transition-colors ${
|
||||||
|
method === "card"
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CreditCard className="w-5 h-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-sm">Kort</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Automatiskt varje månad
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMethod("invoice")}
|
||||||
|
className={`flex items-center gap-3 p-4 rounded-sm border text-left transition-colors ${
|
||||||
|
method === "invoice"
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-5 h-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-sm">Faktura</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Månadsvis via e-post
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block mb-1.5 text-sm font-semibold">
|
<label className="block mb-1.5 text-sm font-semibold">
|
||||||
Företagsnamn <span className="text-primary">*</span>
|
Företagsnamn <span className="text-primary">*</span>
|
||||||
@@ -332,15 +426,17 @@ const PlansSection = () => {
|
|||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
Skickar...
|
{method === "card" ? "Öppnar betalning..." : "Startar abonnemang..."}
|
||||||
</>
|
</>
|
||||||
|
) : method === "card" ? (
|
||||||
|
"Fortsätt till betalning"
|
||||||
) : (
|
) : (
|
||||||
"Skicka beställning"
|
"Starta abonnemang (faktura)"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-muted-foreground text-xs">
|
<p className="text-center text-muted-foreground text-xs">
|
||||||
Vi kontaktar dig för att bekräfta leverans och betalning.
|
Alla priser exklusive moms. Moms (25%) läggs på vid betalning.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from "@stripe/react-stripe-js";
|
||||||
|
import { getStripe } from "@/lib/stripe";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fetchClientSecret: () => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StripeEmbeddedCheckout({ fetchClientSecret }: Props) {
|
||||||
|
return (
|
||||||
|
<div id="checkout">
|
||||||
|
<EmbeddedCheckoutProvider stripe={getStripe()} options={{ fetchClientSecret }}>
|
||||||
|
<EmbeddedCheckout />
|
||||||
|
</EmbeddedCheckoutProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Stripe | null> | null = null;
|
||||||
|
|
||||||
|
export function getStripe(): Promise<Stripe | null> {
|
||||||
|
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_"));
|
||||||
|
}
|
||||||
@@ -11,3 +11,9 @@ verify_jwt = false
|
|||||||
|
|
||||||
[functions.submit-order]
|
[functions.submit-order]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.create-checkout]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.payments-webhook]
|
||||||
|
verify_jwt = false
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user