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 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, }; // Atomic upsert keyed on whichever provider subscription id is present on the // incoming record. The DB-level @@unique on these columns lets concurrent // webhook retries converge on a single row instead of racing into duplicates. if (input.stripeSubscriptionId) { return prisma.subscription.upsert({ where: { stripeSubscriptionId: input.stripeSubscriptionId }, create: { referenceId: input.referenceId, ...data }, update: data, }); } if (input.paypalSubscriptionId) { return prisma.subscription.upsert({ where: { paypalSubscriptionId: input.paypalSubscriptionId }, create: { referenceId: input.referenceId, ...data }, update: data, }); } // Safe fallback: neither provider id is present (no unique key to upsert on), // so create a fresh row. 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) { // Only grant the org's plan if the user is an actual member of that org. // A stale/forged activeOrganizationId must not elevate a non-member. const membership = await prisma.member.findUnique({ where: { organizationId_userId: { organizationId: activeOrgId, userId } }, select: { id: true }, }); if (membership) { 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); }