105 lines
3.7 KiB
TypeScript
105 lines
3.7 KiB
TypeScript
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<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) {
|
|
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);
|
|
}
|