import { eq, sql } from "drizzle-orm"; import type { Stripe } from "stripe"; import { db } from "@/db"; import { billingCustomerStatus, usageLedger } from "@/db/schema"; type SyncedSubscriptionStatus = | "active" | "trialing" | "canceled" | "past_due" | "incomplete"; // Stripe.Subscription.Status carries more values than we store (e.g. // "unpaid", "paused", "incomplete_expired") plus a forward-compat string // branch, so a plain equality narrow on the input doesn't narrow the return // type — switch on literals and return our own constants instead. Anything // we don't actively treat as active/paying collapses to "canceled". export function mapStripeSubscriptionStatus( status: Stripe.Subscription.Status, ): SyncedSubscriptionStatus { switch (status) { case "active": return "active"; case "trialing": return "trialing"; case "past_due": return "past_due"; case "incomplete": return "incomplete"; default: return "canceled"; } } export async function syncStripeSubscriptionStatus(args: { organizationId: string; planId: string; status: SyncedSubscriptionStatus; isPaying: boolean; stripeCustomerJson: Record; }) { await db .insert(billingCustomerStatus) .values({ organizationId: args.organizationId, isPaying: args.isPaying, paidPlanId: args.planId, paidPlanStatus: args.status, customerJson: JSON.stringify(args.stripeCustomerJson), syncedAt: new Date().toISOString(), }) .onConflictDoUpdate({ target: billingCustomerStatus.organizationId, set: { isPaying: args.isPaying, paidPlanId: args.planId, paidPlanStatus: args.status, customerJson: JSON.stringify(args.stripeCustomerJson), syncedAt: new Date().toISOString(), updatedAt: sql`(current_timestamp)`, }, }); } export interface OrganizationBillingDetails { isPaying: boolean; planId: "free" | "starter" | "pro" | "agency"; status: | "active" | "trialing" | "canceled" | "past_due" | "incomplete" | "none"; currentPeriodEnd: number | null; daysRemaining: number | null; hoursRemaining: number | null; formattedExpiry: string | null; isTrial: boolean; cancelAtPeriodEnd: boolean; interval: "monthly" | "annual"; customerId: string | null; hasCustomer: boolean; } interface ParsedCustomerData { periodEndMs: number | null; isTrial: boolean; cancelAtPeriodEnd: boolean; customerId: string | null; interval: "monthly" | "annual"; } function parseStripeCustomerJson( jsonStr: string, isTrialing: boolean, ): ParsedCustomerData { let periodEndMs: number | null = null; let isTrial = isTrialing; let cancelAtPeriodEnd = false; let customerId: string | null = null; let interval: "monthly" | "annual" = "monthly"; try { const data = JSON.parse(jsonStr) as Record; const sub = data && typeof data.subscription === "object" && data.subscription !== null ? (data.subscription as Record) : data; const rawCustomer = data.customer ?? sub?.customer; if (typeof rawCustomer === "string") { customerId = rawCustomer; } else if ( rawCustomer && typeof rawCustomer === "object" && "id" in rawCustomer ) { customerId = String((rawCustomer as { id: unknown }).id); } if (sub) { if (sub.status === "trialing" || Boolean(sub.trial_end)) { isTrial = true; } cancelAtPeriodEnd = Boolean(sub.cancel_at_period_end); const items = sub.items as | { data?: Array<{ current_period_end?: number; plan?: { interval?: string }; }>; } | undefined; const itemPeriodEnd = items?.data?.[0]?.current_period_end; const subPeriodEnd = (sub.current_period_end ?? sub.trial_end) as | number | undefined; const rawSeconds = itemPeriodEnd ?? subPeriodEnd; if (typeof rawSeconds === "number" && rawSeconds > 0) { periodEndMs = rawSeconds * 1000; } if (items?.data?.[0]?.plan?.interval === "year") { interval = "annual"; } } else if (typeof data.expires_at === "number") { periodEndMs = data.expires_at * 1000; } } catch { // Ignore JSON parse errors } return { periodEndMs, isTrial, cancelAtPeriodEnd, customerId, interval }; } function calculateTimeRemaining(periodEndMs: number | null) { if (!periodEndMs) { return { daysRemaining: null, hoursRemaining: null, formattedExpiry: null }; } const diffMs = periodEndMs - Date.now(); const totalHours = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60))); const daysRemaining = Math.floor(totalHours / 24); const hoursRemaining = totalHours % 24; let formattedExpiry: string; try { formattedExpiry = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", }).format(new Date(periodEndMs)); } catch { formattedExpiry = new Date(periodEndMs).toDateString(); } return { daysRemaining, hoursRemaining, formattedExpiry }; } export async function getOrganizationBillingStatus( organizationId: string, ): Promise { const [row] = await db .select() .from(billingCustomerStatus) .where(eq(billingCustomerStatus.organizationId, organizationId)) .limit(1); if (!row) { return { isPaying: false, planId: "free", status: "none", currentPeriodEnd: null, daysRemaining: null, hoursRemaining: null, formattedExpiry: null, isTrial: false, cancelAtPeriodEnd: false, interval: "monthly", customerId: null, hasCustomer: false, }; } const isValidPaying = Boolean(row.isPaying) && (row.paidPlanStatus === "active" || row.paidPlanStatus === "trialing"); const validPlanIds = ["free", "starter", "pro", "agency"] as const; const matchedPlan = validPlanIds.includes( row.paidPlanId as (typeof validPlanIds)[number], ) ? (row.paidPlanId as "free" | "starter" | "pro" | "agency") : "free"; const parsed = parseStripeCustomerJson( row.customerJson, row.paidPlanStatus === "trialing", ); let finalPeriodEnd = parsed.periodEndMs; if (isValidPaying && !finalPeriodEnd && row.syncedAt) { const syncTime = new Date(row.syncedAt).getTime(); if (!Number.isNaN(syncTime)) { const days = parsed.isTrial ? 7 : parsed.interval === "annual" ? 365 : 30; finalPeriodEnd = syncTime + days * 24 * 60 * 60 * 1000; } } const { daysRemaining, hoursRemaining, formattedExpiry } = calculateTimeRemaining(finalPeriodEnd); return { isPaying: isValidPaying, planId: isValidPaying ? matchedPlan : "free", status: (row.paidPlanStatus as OrganizationBillingDetails["status"]) ?? "none", currentPeriodEnd: finalPeriodEnd, daysRemaining, hoursRemaining, formattedExpiry, isTrial: parsed.isTrial, cancelAtPeriodEnd: parsed.cancelAtPeriodEnd, interval: parsed.interval, customerId: parsed.customerId, hasCustomer: Boolean(parsed.customerId), }; } // "YYYY-MM" in UTC, used as the local ledger's reset boundary. Calendar-month // (not rolling 30-day) to match how Autumn's monthly feature resets, and // because it's what a customer expects from "150 lookups / month". function currentPeriodMonth(): string { return new Date().toISOString().slice(0, 7); } /** * Credits consumed so far in the current calendar month, from the local * ledger kept when there's no Autumn behind the app (direct-Stripe self-host * mode). A row from a past month reads as 0 — it's rewritten on the next * spend, not eagerly reset here, so a month with no usage needs no write. */ export async function getLocalUsageCreditsUsed( organizationId: string, ): Promise { const [row] = await db .select() .from(usageLedger) .where(eq(usageLedger.organizationId, organizationId)) .limit(1); if (!row || row.periodMonth !== currentPeriodMonth()) return 0; return row.creditsUsed; } /** * Adds `creditsDelta` to the organization's local usage ledger, rolling over * to a fresh 0 balance first if the stored row is from a previous month. * Companion to `trackUsageCreditSpend`'s Autumn branch — this is the * no-Autumn equivalent of that function's `autumn.track` calls. */ export async function addLocalUsageCredits( organizationId: string, creditsDelta: number, ): Promise { if (creditsDelta <= 0) return; const periodMonth = currentPeriodMonth(); // Single upsert, not read-then-write: concurrent spends from the same org // must add onto each other atomically rather than racing a JS-side sum. await db .insert(usageLedger) .values({ organizationId, periodMonth, creditsUsed: creditsDelta, updatedAt: new Date().toISOString(), }) .onConflictDoUpdate({ target: usageLedger.organizationId, set: { periodMonth, // Roll over to a fresh balance when the stored row is from a past // month; otherwise add onto it in the same statement. creditsUsed: sql`CASE WHEN ${usageLedger.periodMonth} = ${periodMonth} THEN ${usageLedger.creditsUsed} + ${creditsDelta} ELSE ${creditsDelta} END`, updatedAt: sql`(current_timestamp)`, }, }); }