import { prisma } from "@/lib/db"; import { getPlan, type Plan, type PlanKey, type FeatureKey } from "./plans"; export interface UpsertSubscriptionInput { provider: "stripe" | "paypal"; referenceId: string; plan: PlanKey; status: string; billingInterval?: "month" | "year" | null; seats?: number | null; stripeCustomerId?: string | null; stripeSubscriptionId?: string | null; paypalSubscriptionId?: string | null; paypalPlanId?: string | null; periodStart?: Date | null; periodEnd?: Date | null; cancelAtPeriodEnd?: boolean | null; } /** * The single writer both billing providers funnel into. Idempotent on the * provider subscription id, so duplicate/replayed webhooks converge on one row. */ export async function upsertSubscription(input: UpsertSubscriptionInput) { const existing = input.stripeSubscriptionId ? await prisma.subscription.findFirst({ where: { stripeSubscriptionId: input.stripeSubscriptionId }, }) : input.paypalSubscriptionId ? await prisma.subscription.findFirst({ where: { paypalSubscriptionId: input.paypalSubscriptionId }, }) : null; const data = { provider: input.provider, plan: input.plan, status: input.status, billingInterval: input.billingInterval ?? undefined, seats: input.seats ?? undefined, stripeCustomerId: input.stripeCustomerId ?? undefined, stripeSubscriptionId: input.stripeSubscriptionId ?? undefined, paypalSubscriptionId: input.paypalSubscriptionId ?? undefined, paypalPlanId: input.paypalPlanId ?? undefined, periodStart: input.periodStart ?? undefined, periodEnd: input.periodEnd ?? undefined, cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? undefined, }; if (existing) { return prisma.subscription.update({ where: { id: existing.id }, data }); } return prisma.subscription.create({ data: { referenceId: input.referenceId, ...data } }); } /** * Resolve the active plan for a billing subject (a user id, or an organization id * for Agency). Returns "free" when there is no active subscription. * * Both Stripe and PayPal write into the same `subscription` table keyed by * `referenceId`, so this is provider-agnostic by construction. */ export async function getSubjectPlanKey(referenceId: string): Promise { const sub = await prisma.subscription.findFirst({ where: { referenceId, status: { in: ["active", "trialing"] } }, orderBy: { createdAt: "desc" }, }); return (sub?.plan as PlanKey) ?? "free"; } /** The active subscription row for a subject, if any. */ export function getActiveSubscription(referenceId: string) { return prisma.subscription.findFirst({ where: { referenceId, status: { in: ["active", "trialing", "past_due"] } }, orderBy: { createdAt: "desc" }, }); } /** * Resolve the effective plan for the current request. When an organization is * active (Agency workspace), the org's plan governs; otherwise the user's own. */ export async function getEffectivePlan( userId: string, activeOrgId?: string | null ): Promise<{ plan: Plan; key: PlanKey; subjectId: string; subjectType: "user" | "organization" }> { if (activeOrgId) { const key = await getSubjectPlanKey(activeOrgId); if (key !== "free") { return { plan: getPlan(key), key, subjectId: activeOrgId, subjectType: "organization" }; } } const key = await getSubjectPlanKey(userId); return { plan: getPlan(key), key, subjectId: userId, subjectType: "user" }; } export async function subjectHasFeature( userId: string, feature: FeatureKey, activeOrgId?: string | null ): Promise { const { plan } = await getEffectivePlan(userId, activeOrgId); return plan.features.includes(feature); }