Changes
Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com>
This commit is contained in:
@@ -17,3 +17,6 @@ verify_jwt = false
|
|||||||
|
|
||||||
[functions.payments-webhook]
|
[functions.payments-webhook]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.create-portal-session]
|
||||||
|
verify_jwt = false
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||||
|
import { z } from "npm:zod@3.23.8";
|
||||||
|
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL")!,
|
||||||
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const BodySchema = z.object({
|
||||||
|
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 token = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
|
||||||
|
const { data: userData, error: authErr } = await supabase.auth.getUser(token);
|
||||||
|
if (authErr || !userData?.user) {
|
||||||
|
return new Response(JSON.stringify({ error: "Ej inloggad" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = BodySchema.safeParse(await req.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return new Response(JSON.stringify({ error: "Ogiltiga fält" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const env: StripeEnv = parsed.data.environment;
|
||||||
|
|
||||||
|
// Hämta senaste subscription för denna user i rätt env
|
||||||
|
const { data: sub } = await supabase
|
||||||
|
.from("subscriptions")
|
||||||
|
.select("stripe_customer_id")
|
||||||
|
.eq("user_id", userData.user.id)
|
||||||
|
.eq("environment", env)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!sub?.stripe_customer_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Inget abonnemang hittades för detta konto." }),
|
||||||
|
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = createStripeClient(env);
|
||||||
|
const portal = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: sub.stripe_customer_id as string,
|
||||||
|
return_url: parsed.data.return_url,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ url: portal.url }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("create-portal-session error:", e);
|
||||||
|
return new Response(JSON.stringify({ error: e?.message ?? "Kunde inte öppna kundportal" }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,5 +1,106 @@
|
|||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||||
import { verifyWebhook, type StripeEnv } from "../_shared/stripe.ts";
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
||||||
|
|
||||||
@@ -15,7 +116,23 @@ Deno.serve(async (req) => {
|
|||||||
try {
|
try {
|
||||||
const event = await verifyWebhook(req, env);
|
const event = await verifyWebhook(req, env);
|
||||||
console.log(`[payments-webhook:${env}] ${event.type}`);
|
console.log(`[payments-webhook:${env}] ${event.type}`);
|
||||||
// Minimal handler — Stripe dashboard is source of truth for subscriptions.
|
|
||||||
|
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 }), {
|
return new Response(JSON.stringify({ received: true }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
Reference in New Issue
Block a user