xtablo-source/apps/api/src/helpers/billing.ts
2026-03-08 22:13:32 +01:00

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",
};
}
};