Files

119 lines
4.4 KiB
TypeScript
Raw Permalink Normal View History

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,
};
2026-06-20 20:59:03 -04:00
// 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,
});
}
2026-06-20 20:59:03 -04:00
// 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<PlanKey> {
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) {
2026-06-20 20:59:03 -04:00
// 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<boolean> {
const { plan } = await getEffectivePlan(userId, activeOrgId);
return plan.features.includes(feature);
}