Merge main: Guidad Felsökning flyttar ur roten till felsokning/
Main är sedan den här grenen skapades en helt annan produkt — Semantika,
en mobilapp med egen CDK-infrastruktur. Den äger nu repots rot: en
npm-workspaces-monorepo med apps/mobile, services/api och infra.
För att båda ska rymmas i samma repo flyttar Guidad Felsökning in i en
egen katalog i stället för att göra anspråk på roten:
felsokning/app webbklienten (Vite, egen package.json och
eslint-/vitest-konfiguration)
felsokning/services plattformstjänsten och AI-orkestern
felsokning/infra Terraform och databasschemat
felsokning/docs vision, moduler, drift
felsokning/supabase edge-funktion och migrationer
Merge:n hade tagit bort 128 filer som Guidad Felsökning bygger på —
värdapplikationens komponenter, Supabase-klienten, tillgångar — eftersom
main raderat dem och den här grenen inte råkat ändra just dem. De är
återställda på sin nya plats. Utan dem gick varken bygget eller
testerna: ai.ts och synk.ts importerar Supabase-klienten.
Semantikas rotfiler är orörda: package.json, eslint.config.js och
.github/workflows/ är deras. Guidad Felsökning har egna motsvarigheter i
sin katalog.
CI flyttar samtidigt från GitHub Actions till .gitea/workflows — samma
syntax, egna runners. .github/workflows/ tillhör Semantika härefter.
Verifierat på den nya platsen: 96 vitest-tester, typkontroll, eslint på
både klient och tjänster, bygge, och integrationstest mot riktig Postgres.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import { z } from "npm:zod@3.23.8";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
|
||||
|
||||
const supabaseAdmin = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
async function sendOrderEmail(payload: Record<string, unknown>) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-order-email`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) console.error("send-order-email failed:", res.status, await res.text());
|
||||
} catch (e) {
|
||||
console.error("send-order-email error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function insertOrder(row: {
|
||||
method: "card" | "invoice";
|
||||
b: any;
|
||||
cleaned: string;
|
||||
monthlyAmountOre: number;
|
||||
stripeSessionId?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
stripeCustomerId: string;
|
||||
}) {
|
||||
const { b, method, cleaned, monthlyAmountOre } = row;
|
||||
const orderNumber = `ABO-${Date.now().toString(36).toUpperCase()}`;
|
||||
const totalKr = monthlyAmountOre / 100;
|
||||
const { error } = await supabaseAdmin.from("orders").insert({
|
||||
order_number: orderNumber,
|
||||
customer_email: b.email,
|
||||
customer_name: b.company,
|
||||
total_amount: totalKr,
|
||||
currency: "SEK",
|
||||
status: method === "invoice" ? "invoiced" : "pending",
|
||||
items: [
|
||||
{
|
||||
typ: "Kakabonnemang",
|
||||
betalning: method === "card" ? "Kort (månadsvis)" : "Faktura (månadsvis)",
|
||||
antal_anställda: b.employees,
|
||||
kg_per_vecka: b.kg_per_week,
|
||||
pris_per_månad_exkl_moms: totalKr,
|
||||
valuta: "SEK",
|
||||
leverans: {
|
||||
adress: b.address,
|
||||
postnummer: cleaned,
|
||||
stad: b.city,
|
||||
},
|
||||
telefon: b.phone,
|
||||
kommentarer: b.comments,
|
||||
stripe_session_id: row.stripeSessionId ?? null,
|
||||
stripe_subscription_id: row.stripeSubscriptionId ?? null,
|
||||
stripe_customer_id: row.stripeCustomerId,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (error) console.error("Order insert failed:", error);
|
||||
|
||||
await sendOrderEmail({
|
||||
order_number: orderNumber,
|
||||
customer_email: b.email,
|
||||
customer_name: b.company,
|
||||
kg_per_week: b.kg_per_week,
|
||||
employees: b.employees,
|
||||
monthly_amount_sek: totalKr,
|
||||
method,
|
||||
address: b.address ?? "",
|
||||
postal_code: cleaned,
|
||||
phone: b.phone ?? "",
|
||||
comments: b.comments ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
// TEMP TEST: 1 kr/kg (sätt tillbaka till 32500 efter testet)
|
||||
const PRICE_PER_KG_ORE = 100; // 1 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().min(1).max(200),
|
||||
postal_code: z.string().trim().min(1).max(20),
|
||||
city: z.string().trim().min(1).max(100),
|
||||
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);
|
||||
|
||||
// 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_data: {
|
||||
name: "Kakabonnemang",
|
||||
description: "Månatligt kakabonnemang för företag inom Storstockholm.",
|
||||
tax_code: "txcd_40060003",
|
||||
},
|
||||
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,
|
||||
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);
|
||||
|
||||
await insertOrder({
|
||||
method: "card",
|
||||
b,
|
||||
cleaned,
|
||||
monthlyAmountOre,
|
||||
stripeSessionId: session.id,
|
||||
stripeSubscriptionId: null,
|
||||
stripeCustomerId: customer.id,
|
||||
});
|
||||
|
||||
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_data: {
|
||||
name: "Kakabonnemang",
|
||||
description: "Månatligt kakabonnemang för företag inom Storstockholm.",
|
||||
tax_code: "txcd_40060003",
|
||||
},
|
||||
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,
|
||||
customer_email: b.email,
|
||||
kg_per_week: String(b.kg_per_week),
|
||||
employees: String(b.employees),
|
||||
},
|
||||
} as any);
|
||||
|
||||
await insertOrder({
|
||||
method: "invoice",
|
||||
b,
|
||||
cleaned,
|
||||
monthlyAmountOre,
|
||||
stripeSessionId: null,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripeCustomerId: customer.id,
|
||||
});
|
||||
|
||||
|
||||
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" } },
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user