403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
|
|
export type BillingPlan = "solo" | "team" | "annual";
|
|
export type RequiredBillingPlan = "solo" | "team";
|
|
|
|
type BillingProfileRow = {
|
|
id: string;
|
|
created_at: string | null;
|
|
is_temporary: boolean | null;
|
|
plan: string | null;
|
|
};
|
|
|
|
type StripeCustomerRow = {
|
|
id: string;
|
|
metadata: Record<string, string | null> | null;
|
|
};
|
|
|
|
type StripeSubscriptionRow = {
|
|
id: string;
|
|
status: string | null;
|
|
created: number | null;
|
|
current_period_end: number | null;
|
|
};
|
|
|
|
type StripeSubscriptionItemRow = {
|
|
subscription: string;
|
|
price: string | null;
|
|
quantity: number | null;
|
|
};
|
|
|
|
type StripePriceRow = {
|
|
id: string;
|
|
lookup_key: string | null;
|
|
metadata: Record<string, string | null> | null;
|
|
product: string | null;
|
|
};
|
|
|
|
type StripeProductRow = {
|
|
id: string;
|
|
name: string | null;
|
|
metadata: Record<string, string | null> | null;
|
|
};
|
|
|
|
export type OrganizationBillingState = {
|
|
owner_user_id: string;
|
|
member_count: number;
|
|
trial_starts_at: string;
|
|
trial_ends_at: string;
|
|
is_trial_expired: boolean;
|
|
required_plan: RequiredBillingPlan;
|
|
required_team_quantity: number;
|
|
active_subscription_plan: BillingPlan | null;
|
|
active_subscription_quantity: number;
|
|
};
|
|
|
|
const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"];
|
|
const DEFAULT_BILLING_TRIAL_DAYS = 14;
|
|
const DEFAULT_BILLING_TRIAL_ROLLOUT_AT = "2026-03-08T00:00:00.000Z";
|
|
|
|
const parseTrialDays = () => {
|
|
const parsed = Number.parseInt(process.env.BILLING_TRIAL_DAYS ?? "", 10);
|
|
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
return DEFAULT_BILLING_TRIAL_DAYS;
|
|
}
|
|
|
|
return parsed;
|
|
};
|
|
|
|
export const parseTrialRolloutDate = (
|
|
rawInput: string | null | undefined = process.env.BILLING_TRIAL_ROLLOUT_AT
|
|
) => {
|
|
const raw = rawInput?.trim();
|
|
const fallback = new Date(DEFAULT_BILLING_TRIAL_ROLLOUT_AT);
|
|
|
|
if (!raw) {
|
|
return fallback;
|
|
}
|
|
|
|
const parsed = new Date(raw);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return fallback;
|
|
}
|
|
|
|
return parsed;
|
|
};
|
|
|
|
export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null;
|
|
|
|
export const getBillableMemberCount = (profiles: BillingProfileRow[]) =>
|
|
profiles.filter((profile) => profile.is_temporary !== true).length;
|
|
|
|
export const getTrialWindow = (input: {
|
|
ownerCreatedAt: Date;
|
|
now: Date;
|
|
trialDays: number;
|
|
rolloutAt: Date | null;
|
|
}) => {
|
|
const { ownerCreatedAt, now, trialDays, rolloutAt } = input;
|
|
const trialStartDate = rolloutAt && rolloutAt > ownerCreatedAt ? rolloutAt : ownerCreatedAt;
|
|
const trialEndDate = new Date(trialStartDate);
|
|
trialEndDate.setUTCDate(trialEndDate.getUTCDate() + trialDays);
|
|
|
|
return {
|
|
trialStartDate,
|
|
trialEndDate,
|
|
isTrialExpired: now.getTime() > trialEndDate.getTime(),
|
|
};
|
|
};
|
|
|
|
const resolveRequiredPlan = (memberCount: number): RequiredBillingPlan =>
|
|
memberCount <= 1 ? "solo" : "team";
|
|
|
|
const statusWeight = (status: string | null | undefined) => {
|
|
if (status === "active") return 3;
|
|
if (status === "past_due") return 2;
|
|
if (status === "trialing") return 1;
|
|
return 0;
|
|
};
|
|
|
|
const planWeight = (plan: BillingPlan | null) => {
|
|
if (plan === "annual") return 3;
|
|
if (plan === "team") return 2;
|
|
if (plan === "solo") return 1;
|
|
return 0;
|
|
};
|
|
|
|
const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | null => {
|
|
const hint = (planHint ?? "").toLowerCase();
|
|
|
|
if (
|
|
hint.includes("founder") ||
|
|
hint.includes("annual") ||
|
|
hint.includes("beta") ||
|
|
hint.includes("infinite")
|
|
) {
|
|
return "annual";
|
|
}
|
|
|
|
if (hint.includes("team") || hint.includes("standard")) {
|
|
return "team";
|
|
}
|
|
|
|
if (hint.includes("solo") || hint.includes("free")) {
|
|
return "solo";
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const normalizeProfilePlan = (plan: string | null | undefined): BillingPlan => {
|
|
const normalized = (plan ?? "").toLowerCase();
|
|
|
|
if (normalized === "annual" || normalized === "beta" || normalized === "founder") {
|
|
return "annual";
|
|
}
|
|
|
|
if (normalized === "team" || normalized === "standard") {
|
|
return "team";
|
|
}
|
|
|
|
return "solo";
|
|
};
|
|
|
|
const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRow | undefined) => {
|
|
const parts = [
|
|
price?.metadata?.plan,
|
|
price?.metadata?.tier,
|
|
price?.lookup_key,
|
|
product?.metadata?.plan,
|
|
product?.metadata?.tier,
|
|
product?.name,
|
|
]
|
|
.map((part) => part?.toLowerCase().trim())
|
|
.filter((part): part is string => Boolean(part && part.length > 0));
|
|
|
|
return parts[0] ?? null;
|
|
};
|
|
|
|
const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => {
|
|
const { data, error } = await supabase
|
|
.from("profiles")
|
|
.select("id, created_at, is_temporary, plan")
|
|
.eq("organization_id", organizationId)
|
|
.order("created_at", { ascending: true });
|
|
|
|
if (error) {
|
|
return { error: error.message, profiles: null };
|
|
}
|
|
|
|
const profiles = (data ?? []) as BillingProfileRow[];
|
|
if (profiles.length === 0) {
|
|
return { error: "Organization has no members", profiles: null };
|
|
}
|
|
|
|
return { error: null, profiles };
|
|
};
|
|
|
|
const resolveActiveSubscription = async (
|
|
supabase: SupabaseClient,
|
|
ownerUserId: string,
|
|
ownerProfilePlan: string | null
|
|
): Promise<{ plan: BillingPlan | null; quantity: number }> => {
|
|
const { data: customers, error: customersError } = await supabase
|
|
.schema("stripe")
|
|
.from("customers")
|
|
.select("id, metadata")
|
|
.limit(1000);
|
|
|
|
if (customersError) {
|
|
throw new Error(customersError.message);
|
|
}
|
|
|
|
const customer = (customers as StripeCustomerRow[] | null)?.find(
|
|
(candidate) => candidate.metadata?.user_id === ownerUserId
|
|
);
|
|
|
|
if (!customer) {
|
|
return { plan: null, quantity: 0 };
|
|
}
|
|
|
|
const { data: subscriptions, error: subscriptionsError } = await supabase
|
|
.schema("stripe")
|
|
.from("subscriptions")
|
|
.select("id, status, created, current_period_end")
|
|
.eq("customer", customer.id)
|
|
.in("status", ACTIVE_BILLING_STATUSES);
|
|
|
|
if (subscriptionsError) {
|
|
throw new Error(subscriptionsError.message);
|
|
}
|
|
|
|
const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[];
|
|
if (normalizedSubscriptions.length === 0) {
|
|
return { plan: null, quantity: 0 };
|
|
}
|
|
|
|
const ownerFallbackPlan = normalizeProfilePlan(ownerProfilePlan);
|
|
|
|
const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id);
|
|
const { data: subscriptionItems, error: subscriptionItemsError } = await supabase
|
|
.schema("stripe")
|
|
.from("subscription_items")
|
|
.select("subscription, price, quantity")
|
|
.in("subscription", subscriptionIds);
|
|
|
|
if (subscriptionItemsError) {
|
|
throw new Error(subscriptionItemsError.message);
|
|
}
|
|
|
|
const normalizedItems = (subscriptionItems ?? []) as StripeSubscriptionItemRow[];
|
|
const priceIds = Array.from(
|
|
new Set(normalizedItems.map((item) => item.price).filter((price): price is string => !!price))
|
|
);
|
|
|
|
let pricesById = new Map<string, StripePriceRow>();
|
|
let productsById = new Map<string, StripeProductRow>();
|
|
|
|
if (priceIds.length > 0) {
|
|
const { data: prices, error: pricesError } = await supabase
|
|
.schema("stripe")
|
|
.from("prices")
|
|
.select("id, lookup_key, metadata, product")
|
|
.in("id", priceIds);
|
|
|
|
if (pricesError) {
|
|
throw new Error(pricesError.message);
|
|
}
|
|
|
|
const normalizedPrices = (prices ?? []) as StripePriceRow[];
|
|
pricesById = new Map(normalizedPrices.map((price) => [price.id, price]));
|
|
|
|
const productIds = Array.from(
|
|
new Set(
|
|
normalizedPrices
|
|
.map((price) => price.product)
|
|
.filter((product): product is string => Boolean(product))
|
|
)
|
|
);
|
|
|
|
if (productIds.length > 0) {
|
|
const { data: products, error: productsError } = await supabase
|
|
.schema("stripe")
|
|
.from("products")
|
|
.select("id, name, metadata")
|
|
.in("id", productIds);
|
|
|
|
if (productsError) {
|
|
throw new Error(productsError.message);
|
|
}
|
|
|
|
const normalizedProducts = (products ?? []) as StripeProductRow[];
|
|
productsById = new Map(normalizedProducts.map((product) => [product.id, product]));
|
|
}
|
|
}
|
|
|
|
const candidates = normalizedSubscriptions.flatMap((subscription) => {
|
|
const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id);
|
|
|
|
if (relatedItems.length === 0) {
|
|
return [
|
|
{
|
|
subscription,
|
|
plan: null as BillingPlan | null,
|
|
quantity: 1,
|
|
},
|
|
];
|
|
}
|
|
|
|
return relatedItems.map((item) => {
|
|
const price = item.price ? pricesById.get(item.price) : undefined;
|
|
const product = price?.product ? productsById.get(price.product) : undefined;
|
|
const hint = getPlanHint(price, product);
|
|
|
|
return {
|
|
subscription,
|
|
plan: inferBillingPlan(hint),
|
|
quantity: Math.max(1, item.quantity ?? 1),
|
|
};
|
|
});
|
|
});
|
|
|
|
if (candidates.length === 0) {
|
|
return { plan: ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan, quantity: 1 };
|
|
}
|
|
|
|
candidates.sort((a, b) => {
|
|
const aPlan = a.plan ?? "team";
|
|
const bPlan = b.plan ?? "team";
|
|
|
|
const byPlanWeight = planWeight(bPlan) - planWeight(aPlan);
|
|
if (byPlanWeight !== 0) return byPlanWeight;
|
|
|
|
const byStatusWeight =
|
|
statusWeight(b.subscription.status) - statusWeight(a.subscription.status);
|
|
if (byStatusWeight !== 0) return byStatusWeight;
|
|
|
|
const aPeriodEnd = a.subscription.current_period_end ?? a.subscription.created ?? 0;
|
|
const bPeriodEnd = b.subscription.current_period_end ?? b.subscription.created ?? 0;
|
|
return bPeriodEnd - aPeriodEnd;
|
|
});
|
|
|
|
const winner = candidates[0];
|
|
return {
|
|
// Keep legacy fallback for unknown paid subscriptions but prefer owner profile plan
|
|
// so founder/beta organizations do not downgrade to team because of metadata gaps.
|
|
plan: winner.plan ?? (ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan),
|
|
quantity: winner.quantity,
|
|
};
|
|
};
|
|
|
|
export const getOrganizationBillingState = async (
|
|
supabase: SupabaseClient,
|
|
organizationId: number,
|
|
nowInput: Date = new Date()
|
|
): Promise<{ data: OrganizationBillingState | null; error: string | null }> => {
|
|
const { profiles, error: profilesError } = await getOrganizationProfiles(
|
|
supabase,
|
|
organizationId
|
|
);
|
|
if (profilesError || !profiles) {
|
|
return { data: null, error: profilesError ?? "Failed to load organization members" };
|
|
}
|
|
|
|
const owner = getOrganizationOwner(profiles);
|
|
if (!owner) {
|
|
return { data: null, error: "Organization has no members" };
|
|
}
|
|
|
|
const ownerCreatedAt = owner.created_at ? new Date(owner.created_at) : nowInput;
|
|
const trialWindow = getTrialWindow({
|
|
ownerCreatedAt,
|
|
now: new Date(nowInput),
|
|
trialDays: parseTrialDays(),
|
|
rolloutAt: parseTrialRolloutDate(),
|
|
});
|
|
|
|
const memberCount = getBillableMemberCount(profiles);
|
|
const requiredPlan = resolveRequiredPlan(memberCount);
|
|
|
|
try {
|
|
const activeSubscription = await resolveActiveSubscription(supabase, owner.id, owner.plan);
|
|
|
|
return {
|
|
data: {
|
|
owner_user_id: owner.id,
|
|
member_count: memberCount,
|
|
trial_starts_at: trialWindow.trialStartDate.toISOString(),
|
|
trial_ends_at: trialWindow.trialEndDate.toISOString(),
|
|
is_trial_expired: trialWindow.isTrialExpired,
|
|
required_plan: requiredPlan,
|
|
required_team_quantity: requiredPlan === "team" ? Math.max(1, memberCount) : 1,
|
|
active_subscription_plan: activeSubscription.plan,
|
|
active_subscription_quantity: activeSubscription.quantity,
|
|
},
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
data: null,
|
|
error: error instanceof Error ? error.message : "Failed to resolve billing state",
|
|
};
|
|
}
|
|
};
|