import type Stripe from "stripe"; import { stripe } from "../stripe"; import { upsertSubscription } from "../subscription"; import { planFromStripePrice } from "../catalog"; import { PLAN_ORDER, type PlanKey } from "../plans"; /** Narrow attacker-influenceable metadata to a known PlanKey, else null. */ function planFromMetadata(value?: string | null): PlanKey | null { return value && (PLAN_ORDER as string[]).includes(value) ? (value as PlanKey) : null; } function normalizeStatus(status: Stripe.Subscription.Status): string { switch (status) { case "active": case "trialing": case "past_due": case "paused": return status; default: return "canceled"; // canceled | incomplete | incomplete_expired | unpaid } } async function syncStripeSubscription( sub: Stripe.Subscription, metadata?: Stripe.Metadata | null ) { const item = sub.items.data[0]; const priceId = item?.price?.id; const mapped = priceId ? planFromStripePrice(priceId) : null; // metadata.plan is attacker-influenceable; only honour it if it's a known plan. // The price-mapping fallback (derived from the real Stripe price) is preferred. const plan: PlanKey = planFromMetadata(metadata?.plan) ?? mapped?.plan ?? "free"; const referenceId = metadata?.subjectId || sub.metadata?.subjectId; if (!referenceId || referenceId.trim() === "") { console.warn("[stripe] subscription without subjectId metadata, skipping", sub.id); return; } const interval = mapped?.interval ?? (item?.price?.recurring?.interval === "year" ? "year" : "month"); await upsertSubscription({ provider: "stripe", referenceId, plan, status: normalizeStatus(sub.status), billingInterval: interval, stripeCustomerId: typeof sub.customer === "string" ? sub.customer : sub.customer.id, stripeSubscriptionId: sub.id, periodStart: sub.current_period_start ? new Date(sub.current_period_start * 1000) : null, periodEnd: sub.current_period_end ? new Date(sub.current_period_end * 1000) : null, cancelAtPeriodEnd: sub.cancel_at_period_end, }); } export async function handleStripeEvent(event: Stripe.Event): Promise { switch (event.type) { case "checkout.session.completed": { const session = event.data.object as Stripe.Checkout.Session; if (session.mode !== "subscription" || !session.subscription) break; const subId = typeof session.subscription === "string" ? session.subscription : session.subscription.id; const sub = await stripe().subscriptions.retrieve(subId); await syncStripeSubscription(sub, session.metadata); break; } case "customer.subscription.created": case "customer.subscription.updated": case "customer.subscription.deleted": { const sub = event.data.object as Stripe.Subscription; await syncStripeSubscription(sub, sub.metadata); break; } default: break; } }