Files
podcastdistributiona/lib/billing/subscription.ts
T

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