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 Admin from "./pages/Admin";
|
||||
import FAQ from "./pages/FAQ";
|
||||
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function AppContent() {
|
||||
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<PaymentTestModeBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
<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 { 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<Method>("card");
|
||||
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 [clientSecret, setClientSecret] = useState<string | null>(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 (
|
||||
<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 (
|
||||
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10 md:mb-14">
|
||||
<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">
|
||||
@@ -128,25 +183,23 @@ const PlansSection = () => {
|
||||
</p>
|
||||
</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">
|
||||
{submitted ? (
|
||||
{submitted === "invoice" ? (
|
||||
<div className="text-center py-8 animate-fade-in">
|
||||
<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">
|
||||
Tack för din beställning!
|
||||
Abonnemang startat!
|
||||
</h3>
|
||||
<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">
|
||||
{recommendedKg} kg kakor per vecka
|
||||
</strong>{" "}
|
||||
och kontaktar dig inom kort för att bekräfta leverans.
|
||||
</strong>
|
||||
. Första fakturan skickas till <strong>{form.epost}</strong> inom kort.
|
||||
</p>
|
||||
</div>
|
||||
) : !showForm ? (
|
||||
<>
|
||||
{/* Employee count */}
|
||||
<div className="text-center mb-8">
|
||||
<div
|
||||
key={employees}
|
||||
@@ -159,7 +212,6 @@ const PlansSection = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div className="mb-10 md:mb-12">
|
||||
<input
|
||||
type="range"
|
||||
@@ -177,7 +229,6 @@ const PlansSection = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
<div className="text-center border-t border-border pt-8 md:pt-10 space-y-6">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
|
||||
@@ -191,12 +242,14 @@ const PlansSection = () => {
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
|
||||
Pris per vecka
|
||||
Pris per månad
|
||||
</div>
|
||||
<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 className="text-muted-foreground text-xs mt-1">exklusive moms</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -211,12 +264,12 @@ const PlansSection = () => {
|
||||
<form onSubmit={handleSubmit} className="space-y-5 animate-fade-in">
|
||||
<div className="text-center mb-2">
|
||||
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-1">
|
||||
Din beställning
|
||||
Ditt abonnemang
|
||||
</div>
|
||||
<div className="font-display text-2xl md:text-3xl font-semibold">
|
||||
{recommendedKg} kg/vecka —{" "}
|
||||
<span className="text-primary">
|
||||
{weeklyPrice.toLocaleString("sv-SE")} kr
|
||||
{monthlyPrice.toLocaleString("sv-SE")} kr/mån
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
@@ -224,6 +277,47 @@ const PlansSection = () => {
|
||||
</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>
|
||||
<label className="block mb-1.5 text-sm font-semibold">
|
||||
Företagsnamn <span className="text-primary">*</span>
|
||||
@@ -332,15 +426,17 @@ const PlansSection = () => {
|
||||
{submitting ? (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</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]
|
||||
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