Files
alva/felsokning/supabase/functions/payments-webhook/index.ts
T
Claude 301c477e25 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
2026-08-04 12:22:49 +00:00

170 lines
6.3 KiB
TypeScript

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<string | null> {
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);
}
// Markera ordern som betald genom att matcha stripe_session_id i items[0].
const paid = session.payment_status === "paid" || session.status === "complete";
if (paid && session.id) {
// Match by scanning recent pending orders for this session id inside items[0]
const { data: rows, error: selErr } = await supabase
.from("orders")
.select("id, items, status")
.eq("status", "pending")
.order("created_at", { ascending: false })
.limit(50);
if (selErr) console.error("orders select failed:", selErr);
const match = rows?.find((r: any) =>
Array.isArray(r.items) && r.items.some((it: any) => it?.stripe_session_id === session.id)
);
if (match) {
const { error: updErr } = await supabase
.from("orders")
.update({ status: "paid", updated_at: new Date().toISOString() })
.eq("id", match.id);
if (updErr) console.error("orders update failed:", updErr);
} else {
console.log("No pending order matched session", session.id);
}
}
}
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}`);
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" },
});
} catch (e) {
console.error("Webhook error:", e);
return new Response("Webhook error", { status: 400 });
}
});