From 75a228f29210d2ea9d76583ac50344ce2732299c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 16:45:08 -0800 Subject: [PATCH 01/28] Initial backend impl --- common/src/constants/analytics-events.ts | 9 + common/src/constants/grant-priorities.ts | 1 + common/src/constants/subscription-plans.ts | 26 + common/src/types/grant.ts | 2 + .../src/__tests__/usage-service.test.ts | 4 +- packages/billing/src/index.ts | 6 + packages/billing/src/subscription-webhooks.ts | 335 +++++++++ packages/billing/src/subscription.ts | 690 ++++++++++++++++++ packages/internal/src/db/schema.ts | 66 ++ packages/internal/src/env-schema.ts | 2 + web/src/app/api/stripe/webhook/route.ts | 57 +- .../app/profile/components/usage-display.tsx | 11 +- 12 files changed, 1203 insertions(+), 6 deletions(-) create mode 100644 common/src/constants/subscription-plans.ts create mode 100644 packages/billing/src/subscription-webhooks.ts create mode 100644 packages/billing/src/subscription.ts diff --git a/common/src/constants/analytics-events.ts b/common/src/constants/analytics-events.ts index e620fdb721..6f3bfe856a 100644 --- a/common/src/constants/analytics-events.ts +++ b/common/src/constants/analytics-events.ts @@ -30,6 +30,15 @@ export enum AnalyticsEvent { ADVISORY_LOCK_CONTENTION = 'backend.advisory_lock_contention', TRANSACTION_RETRY_THRESHOLD_EXCEEDED = 'backend.transaction_retry_threshold_exceeded', + // Backend - Subscription + SUBSCRIPTION_CREATED = 'backend.subscription_created', + SUBSCRIPTION_CANCELED = 'backend.subscription_canceled', + SUBSCRIPTION_PAYMENT_FAILED = 'backend.subscription_payment_failed', + SUBSCRIPTION_BLOCK_CREATED = 'backend.subscription_block_created', + SUBSCRIPTION_BLOCK_LIMIT_HIT = 'backend.subscription_block_limit_hit', + SUBSCRIPTION_WEEKLY_LIMIT_HIT = 'backend.subscription_weekly_limit_hit', + SUBSCRIPTION_CREDITS_MIGRATED = 'backend.subscription_credits_migrated', + // Web SIGNUP = 'web.signup', diff --git a/common/src/constants/grant-priorities.ts b/common/src/constants/grant-priorities.ts index a2c1c84c34..49cae0786e 100644 --- a/common/src/constants/grant-priorities.ts +++ b/common/src/constants/grant-priorities.ts @@ -1,6 +1,7 @@ import type { GrantType } from '@codebuff/common/types/grant' export const GRANT_PRIORITIES: Record = { + subscription: 10, free: 20, referral: 30, ad: 40, diff --git a/common/src/constants/subscription-plans.ts b/common/src/constants/subscription-plans.ts new file mode 100644 index 0000000000..14e8d1aa7e --- /dev/null +++ b/common/src/constants/subscription-plans.ts @@ -0,0 +1,26 @@ +export const PLAN_NAMES = ['pro'] as const +export type PlanName = (typeof PLAN_NAMES)[number] + +export interface PlanConfig { + name: PlanName + displayName: string + monthlyPrice: number + creditsPerBlock: number + blockDurationHours: number + weeklyCreditsLimit: number +} + +export const PLANS = { + pro: { + name: 'pro', + displayName: 'Pro', + monthlyPrice: 200, + creditsPerBlock: 1250, + blockDurationHours: 5, + weeklyCreditsLimit: 15000, + }, +} as const satisfies Record + +export function isPlanName(name: string): name is PlanName { + return (PLAN_NAMES as readonly string[]).includes(name) +} diff --git a/common/src/types/grant.ts b/common/src/types/grant.ts index 93d708cb6c..33534a4354 100644 --- a/common/src/types/grant.ts +++ b/common/src/types/grant.ts @@ -1,6 +1,7 @@ export type GrantType = | 'free' | 'referral' + | 'subscription' | 'purchase' | 'admin' | 'organization' @@ -9,6 +10,7 @@ export type GrantType = export const GrantTypeValues = [ 'free', 'referral', + 'subscription', 'purchase', 'admin', 'organization', diff --git a/packages/billing/src/__tests__/usage-service.test.ts b/packages/billing/src/__tests__/usage-service.test.ts index e1f9466c01..ebf617b014 100644 --- a/packages/billing/src/__tests__/usage-service.test.ts +++ b/packages/billing/src/__tests__/usage-service.test.ts @@ -19,8 +19,8 @@ const mockBalance = { totalRemaining: 1000, totalDebt: 0, netBalance: 1000, - breakdown: { free: 500, paid: 500, referral: 0, purchase: 0, admin: 0, organization: 0, ad: 0 }, - principals: { free: 500, paid: 500, referral: 0, purchase: 0, admin: 0, organization: 0, ad: 0 }, + breakdown: { free: 500, referral: 0, subscription: 0, purchase: 500, admin: 0, organization: 0, ad: 0 }, + principals: { free: 500, referral: 0, subscription: 0, purchase: 500, admin: 0, organization: 0, ad: 0 }, } describe('usage-service', () => { diff --git a/packages/billing/src/index.ts b/packages/billing/src/index.ts index 9545ea5226..ac1cbcdfd9 100644 --- a/packages/billing/src/index.ts +++ b/packages/billing/src/index.ts @@ -19,5 +19,11 @@ export * from './usage-service' // Credit delegation export * from './credit-delegation' +// Subscription +export * from './subscription' + +// Subscription webhooks +export * from './subscription-webhooks' + // Utilities export * from './utils' diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts new file mode 100644 index 0000000000..e161ec8cd2 --- /dev/null +++ b/packages/billing/src/subscription-webhooks.ts @@ -0,0 +1,335 @@ +import { trackEvent } from '@codebuff/common/analytics' +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { PLANS } from '@codebuff/common/constants/subscription-plans' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { eq } from 'drizzle-orm' + +import { handleSubscribe } from './subscription' + +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { PlanConfig } from '@codebuff/common/constants/subscription-plans' +import type Stripe from 'stripe' + +/** + * Looks up a user ID by Stripe customer ID. + */ +async function getUserIdByCustomerId( + customerId: string, +): Promise { + const userRecord = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.stripe_customer_id, customerId)) + .limit(1) + return userRecord[0]?.id ?? null +} + +/** + * Resolves a PlanConfig from a Stripe price ID. + * Compares against the configured env var for each plan. + */ +function getPlanFromPriceId(priceId: string): PlanConfig { + if (!env.STRIPE_SUBSCRIPTION_200_PRICE_ID) { + throw new Error( + 'STRIPE_SUBSCRIPTION_200_PRICE_ID env var is not configured', + ) + } + if (env.STRIPE_SUBSCRIPTION_200_PRICE_ID === priceId) { + return PLANS.pro + } + throw new Error(`Unknown subscription price ID: ${priceId}`) +} + +// --------------------------------------------------------------------------- +// invoice.paid +// --------------------------------------------------------------------------- + +/** + * Handles a paid invoice for a subscription. + * + * - On first payment (`subscription_create`): calls `handleSubscribe` to + * migrate the user's renewal date and unused credits (Option B). + * - On every payment: upserts the `subscription` row with fresh billing + * period dates from Stripe. + */ +export async function handleSubscriptionInvoicePaid(params: { + invoice: Stripe.Invoice + logger: Logger +}): Promise { + const { invoice, logger } = params + + if (!invoice.subscription) return + const subscriptionId = + typeof invoice.subscription === 'string' + ? invoice.subscription + : invoice.subscription.id + const customerId = + typeof invoice.customer === 'string' + ? invoice.customer + : invoice.customer?.id + + if (!customerId) { + logger.warn( + { invoiceId: invoice.id }, + 'Subscription invoice has no customer ID', + ) + return + } + + const stripeSub = await stripeServer.subscriptions.retrieve(subscriptionId) + const priceId = stripeSub.items.data[0]?.price.id + if (!priceId) { + logger.error( + { subscriptionId }, + 'Stripe subscription has no price on first item', + ) + return + } + + let plan: PlanConfig + try { + plan = getPlanFromPriceId(priceId) + } catch { + logger.warn( + { subscriptionId, priceId }, + 'Subscription invoice for unrecognised price — skipping', + ) + return + } + + // Look up the user for this customer + const userId = await getUserIdByCustomerId(customerId) + + // On first invoice, migrate renewal date & credits (Option B) + if (invoice.billing_reason === 'subscription_create') { + if (userId) { + await handleSubscribe({ + userId, + stripeSubscription: stripeSub, + logger, + }) + } else { + logger.warn( + { customerId, subscriptionId }, + 'No user found for customer — skipping handleSubscribe', + ) + } + } + + // Upsert subscription row + await db + .insert(schema.subscription) + .values({ + stripe_subscription_id: subscriptionId, + stripe_customer_id: customerId, + user_id: userId, + stripe_price_id: priceId, + plan_name: plan.name, + status: 'active', + billing_period_start: new Date(stripeSub.current_period_start * 1000), + billing_period_end: new Date(stripeSub.current_period_end * 1000), + cancel_at_period_end: stripeSub.cancel_at_period_end, + }) + .onConflictDoUpdate({ + target: schema.subscription.stripe_subscription_id, + set: { + status: 'active', + user_id: userId, + stripe_price_id: priceId, + plan_name: plan.name, + billing_period_start: new Date( + stripeSub.current_period_start * 1000, + ), + billing_period_end: new Date(stripeSub.current_period_end * 1000), + cancel_at_period_end: stripeSub.cancel_at_period_end, + updated_at: new Date(), + }, + }) + + logger.info( + { + subscriptionId, + customerId, + planName: plan.name, + billingReason: invoice.billing_reason, + }, + 'Processed subscription invoice.paid', + ) +} + +// --------------------------------------------------------------------------- +// invoice.payment_failed +// --------------------------------------------------------------------------- + +/** + * Immediately sets the subscription to `past_due` — no grace period. + * User reverts to free-tier behaviour until payment is fixed. + */ +export async function handleSubscriptionInvoicePaymentFailed(params: { + invoice: Stripe.Invoice + logger: Logger +}): Promise { + const { invoice, logger } = params + + if (!invoice.subscription) return + const subscriptionId = + typeof invoice.subscription === 'string' + ? invoice.subscription + : invoice.subscription.id + + const customerId = + typeof invoice.customer === 'string' + ? invoice.customer + : invoice.customer?.id + const userId = customerId + ? await getUserIdByCustomerId(customerId) + : null + + await db + .update(schema.subscription) + .set({ + status: 'past_due', + updated_at: new Date(), + }) + .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) + + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_PAYMENT_FAILED, + userId: userId ?? 'system', + properties: { subscriptionId, invoiceId: invoice.id }, + logger, + }) + + logger.warn( + { subscriptionId, invoiceId: invoice.id }, + 'Subscription payment failed — set to past_due', + ) +} + +// --------------------------------------------------------------------------- +// customer.subscription.updated +// --------------------------------------------------------------------------- + +/** + * Syncs plan details and cancellation intent from Stripe. + */ +export async function handleSubscriptionUpdated(params: { + stripeSubscription: Stripe.Subscription + logger: Logger +}): Promise { + const { stripeSubscription, logger } = params + const subscriptionId = stripeSubscription.id + const priceId = stripeSubscription.items.data[0]?.price.id + + if (!priceId) { + logger.error( + { subscriptionId }, + 'Subscription update has no price — skipping', + ) + return + } + + let planName: string + try { + const plan = getPlanFromPriceId(priceId) + planName = plan.name + } catch { + logger.warn( + { subscriptionId, priceId }, + 'Subscription updated with unrecognised price — skipping', + ) + return + } + + const customerId = + typeof stripeSubscription.customer === 'string' + ? stripeSubscription.customer + : stripeSubscription.customer.id + const userId = await getUserIdByCustomerId(customerId) + + // Upsert — webhook ordering is not guaranteed by Stripe, so this event + // may arrive before invoice.paid creates the row. + await db + .insert(schema.subscription) + .values({ + stripe_subscription_id: subscriptionId, + stripe_customer_id: customerId, + user_id: userId, + stripe_price_id: priceId, + plan_name: planName, + cancel_at_period_end: stripeSubscription.cancel_at_period_end, + billing_period_start: new Date( + stripeSubscription.current_period_start * 1000, + ), + billing_period_end: new Date( + stripeSubscription.current_period_end * 1000, + ), + }) + .onConflictDoUpdate({ + target: schema.subscription.stripe_subscription_id, + set: { + user_id: userId, + stripe_price_id: priceId, + plan_name: planName, + cancel_at_period_end: stripeSubscription.cancel_at_period_end, + billing_period_start: new Date( + stripeSubscription.current_period_start * 1000, + ), + billing_period_end: new Date( + stripeSubscription.current_period_end * 1000, + ), + updated_at: new Date(), + }, + }) + + logger.info( + { + subscriptionId, + planName, + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, + }, + 'Processed subscription update', + ) +} + +// --------------------------------------------------------------------------- +// customer.subscription.deleted +// --------------------------------------------------------------------------- + +/** + * Marks the subscription as canceled in our database. + */ +export async function handleSubscriptionDeleted(params: { + stripeSubscription: Stripe.Subscription + logger: Logger +}): Promise { + const { stripeSubscription, logger } = params + const subscriptionId = stripeSubscription.id + + const customerId = + typeof stripeSubscription.customer === 'string' + ? stripeSubscription.customer + : stripeSubscription.customer.id + const userId = await getUserIdByCustomerId(customerId) + + await db + .update(schema.subscription) + .set({ + status: 'canceled', + canceled_at: new Date(), + updated_at: new Date(), + }) + .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) + + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_CANCELED, + userId: userId ?? 'system', + properties: { subscriptionId }, + logger, + }) + + logger.info({ subscriptionId }, 'Subscription canceled') +} diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts new file mode 100644 index 0000000000..ec916ec4c0 --- /dev/null +++ b/packages/billing/src/subscription.ts @@ -0,0 +1,690 @@ +import { trackEvent } from '@codebuff/common/analytics' +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { GRANT_PRIORITIES } from '@codebuff/common/constants/grant-priorities' +import { + PLANS, + isPlanName, +} from '@codebuff/common/constants/subscription-plans' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { withAdvisoryLockTransaction } from '@codebuff/internal/db/transaction' +import { + and, + desc, + eq, + gt, + gte, + inArray, + isNull, + lt, + or, + sql, +} from 'drizzle-orm' + +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type Stripe from 'stripe' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type SubscriptionRow = typeof schema.subscription.$inferSelect + +type DbConn = Pick + +export interface SubscriptionLimits { + creditsPerBlock: number + blockDurationHours: number + weeklyCreditsLimit: number +} + +export interface WeeklyUsage { + used: number + limit: number + remaining: number + resetsAt: Date + percentUsed: number +} + +export interface BlockGrant { + grantId: string + credits: number + expiresAt: Date + isNew: boolean +} + +export interface WeeklyLimitError { + error: 'weekly_limit_reached' + used: number + limit: number + resetsAt: Date +} + +export type BlockGrantResult = BlockGrant | WeeklyLimitError + +export function isWeeklyLimitError( + result: BlockGrantResult, +): result is WeeklyLimitError { + return 'error' in result +} + +export interface RateLimitStatus { + limited: boolean + reason?: 'block_exhausted' | 'weekly_limit' + canStartNewBlock: boolean + + blockUsed?: number + blockLimit?: number + blockResetsAt?: Date + + weeklyUsed: number + weeklyLimit: number + weeklyResetsAt: Date + weeklyPercentUsed: number +} + +// --------------------------------------------------------------------------- +// Date helpers +// --------------------------------------------------------------------------- + +function startOfDay(date: Date): Date { + const d = new Date(date) + d.setUTCHours(0, 0, 0, 0) + return d +} + +function addDays(date: Date, days: number): Date { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000) +} + +function addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000) +} + +/** + * Get the start of the current billing-aligned week. + * Weeks start on the same day-of-week as the billing period started. + */ +export function getWeekStart( + billingPeriodStart: Date, + now: Date = new Date(), +): Date { + const billingDayOfWeek = billingPeriodStart.getUTCDay() + const currentDayOfWeek = now.getUTCDay() + const daysBack = (currentDayOfWeek - billingDayOfWeek + 7) % 7 + return startOfDay(addDays(now, -daysBack)) +} + +/** + * Get the end of the current billing-aligned week (start of next week). + */ +export function getWeekEnd( + billingPeriodStart: Date, + now: Date = new Date(), +): Date { + return addDays(getWeekStart(billingPeriodStart, now), 7) +} + +// --------------------------------------------------------------------------- +// Subscription limits +// --------------------------------------------------------------------------- + +/** + * Resolves the effective subscription limits for a user. + * Checks `limit_override` first, then falls back to the plan constants. + */ +export async function getSubscriptionLimits(params: { + userId: string + planName: string + logger: Logger + conn?: DbConn +}): Promise { + const { userId, planName, logger, conn = db } = params + + const overrides = await conn + .select() + .from(schema.limitOverride) + .where(eq(schema.limitOverride.user_id, userId)) + .limit(1) + + if (overrides.length > 0) { + const o = overrides[0] + logger.debug( + { userId, creditsPerBlock: o.credits_per_block }, + 'Using limit override for user', + ) + return { + creditsPerBlock: o.credits_per_block, + blockDurationHours: o.block_duration_hours, + weeklyCreditsLimit: o.weekly_credit_limit, + } + } + + if (!isPlanName(planName)) { + throw new Error(`Unknown plan name: ${planName}`) + } + + const plan = PLANS[planName] + return { + creditsPerBlock: plan.creditsPerBlock, + blockDurationHours: plan.blockDurationHours, + weeklyCreditsLimit: plan.weeklyCreditsLimit, + } +} + +// --------------------------------------------------------------------------- +// Weekly usage tracking +// --------------------------------------------------------------------------- + +/** + * Calculates credits consumed from subscription grants during the current + * billing-aligned week. + */ +export async function getWeeklyUsage(params: { + stripeSubscriptionId: string + billingPeriodStart: Date + weeklyCreditsLimit: number + logger: Logger + conn?: DbConn +}): Promise { + const { + stripeSubscriptionId, + billingPeriodStart, + weeklyCreditsLimit, + conn = db, + } = params + + const now = new Date() + const weekStart = getWeekStart(billingPeriodStart, now) + const weekEnd = getWeekEnd(billingPeriodStart, now) + + const result = await conn + .select({ + total: sql`COALESCE(SUM(${schema.creditLedger.principal} - ${schema.creditLedger.balance}), 0)`, + }) + .from(schema.creditLedger) + .where( + and( + eq( + schema.creditLedger.stripe_subscription_id, + stripeSubscriptionId, + ), + eq(schema.creditLedger.type, 'subscription'), + gte(schema.creditLedger.created_at, weekStart), + lt(schema.creditLedger.created_at, weekEnd), + ), + ) + + const used = Number(result[0]?.total ?? 0) + + return { + used, + limit: weeklyCreditsLimit, + remaining: Math.max(0, weeklyCreditsLimit - used), + resetsAt: weekEnd, + percentUsed: weeklyCreditsLimit > 0 + ? Math.round((used / weeklyCreditsLimit) * 100) + : 0, + } +} + +// --------------------------------------------------------------------------- +// Block grant management +// --------------------------------------------------------------------------- + +/** + * Ensures the user has an active subscription block grant. + * + * 1. Returns the existing active grant if one exists with balance > 0. + * 2. Checks the weekly limit — returns an error if reached. + * 3. Creates a new block grant and returns it. + * + * All operations are serialised under an advisory lock for the user. + */ +export async function ensureActiveBlockGrant(params: { + userId: string + subscription: SubscriptionRow + logger: Logger +}): Promise { + const { userId, subscription, logger } = params + const subscriptionId = subscription.stripe_subscription_id + + const { result } = await withAdvisoryLockTransaction({ + callback: async (tx) => { + const now = new Date() + + // 1. Check for an existing active block grant + const existingGrants = await tx + .select() + .from(schema.creditLedger) + .where( + and( + eq(schema.creditLedger.user_id, userId), + eq(schema.creditLedger.type, 'subscription'), + eq(schema.creditLedger.stripe_subscription_id, subscriptionId), + gt(schema.creditLedger.expires_at, now), + gt(schema.creditLedger.balance, 0), + ), + ) + .orderBy(desc(schema.creditLedger.expires_at)) + .limit(1) + + if (existingGrants.length > 0) { + const g = existingGrants[0] + return { + grantId: g.operation_id, + credits: g.balance, + expiresAt: g.expires_at!, + isNew: false, + } satisfies BlockGrant + } + + // 2. Resolve limits + const limits = await getSubscriptionLimits({ + userId, + planName: subscription.plan_name, + logger, + conn: tx, + }) + + // 3. Check weekly limit before creating a new block + const weekly = await getWeeklyUsage({ + stripeSubscriptionId: subscriptionId, + billingPeriodStart: subscription.billing_period_start, + weeklyCreditsLimit: limits.weeklyCreditsLimit, + logger, + conn: tx, + }) + + if (weekly.used >= weekly.limit) { + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_WEEKLY_LIMIT_HIT, + userId, + properties: { + subscriptionId, + weeklyUsed: weekly.used, + weeklyLimit: weekly.limit, + }, + logger, + }) + + return { + error: 'weekly_limit_reached', + used: weekly.used, + limit: weekly.limit, + resetsAt: weekly.resetsAt, + } satisfies WeeklyLimitError + } + + // 4. Create new block grant + const expiresAt = addHours(now, limits.blockDurationHours) + const operationId = `block-${subscriptionId}-${now.getTime()}` + + const [newGrant] = await tx + .insert(schema.creditLedger) + .values({ + operation_id: operationId, + user_id: userId, + stripe_subscription_id: subscriptionId, + type: 'subscription', + principal: limits.creditsPerBlock, + balance: limits.creditsPerBlock, + priority: GRANT_PRIORITIES.subscription, + expires_at: expiresAt, + description: `${subscription.plan_name} block (${limits.blockDurationHours}h)`, + }) + .onConflictDoNothing({ target: schema.creditLedger.operation_id }) + .returning() + + if (!newGrant) { + throw new Error( + 'Failed to create block grant — possible duplicate operation', + ) + } + + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_BLOCK_CREATED, + userId, + properties: { + subscriptionId, + operationId, + credits: limits.creditsPerBlock, + expiresAt: expiresAt.toISOString(), + weeklyUsed: weekly.used, + weeklyLimit: weekly.limit, + }, + logger, + }) + + logger.info( + { + userId, + subscriptionId, + operationId, + credits: limits.creditsPerBlock, + expiresAt, + }, + 'Created new subscription block grant', + ) + + return { + grantId: newGrant.operation_id, + credits: limits.creditsPerBlock, + expiresAt, + isNew: true, + } satisfies BlockGrant + }, + lockKey: `user:${userId}`, + context: { userId, subscriptionId }, + logger, + }) + + return result +} + +// --------------------------------------------------------------------------- +// Rate limiting +// --------------------------------------------------------------------------- + +/** + * Checks the subscriber's current rate-limit status. + * + * Two layers: + * - **Block**: 5-hour window with a fixed credit allowance + * - **Weekly**: billing-aligned weekly cap + */ +export async function checkRateLimit(params: { + userId: string + subscription: SubscriptionRow + logger: Logger +}): Promise { + const { userId, subscription, logger } = params + const subscriptionId = subscription.stripe_subscription_id + const now = new Date() + + const limits = await getSubscriptionLimits({ + userId, + planName: subscription.plan_name, + logger, + }) + + const weekly = await getWeeklyUsage({ + stripeSubscriptionId: subscriptionId, + billingPeriodStart: subscription.billing_period_start, + weeklyCreditsLimit: limits.weeklyCreditsLimit, + logger, + }) + + // Weekly limit takes precedence + if (weekly.used >= weekly.limit) { + return { + limited: true, + reason: 'weekly_limit', + canStartNewBlock: false, + weeklyUsed: weekly.used, + weeklyLimit: weekly.limit, + weeklyResetsAt: weekly.resetsAt, + weeklyPercentUsed: weekly.percentUsed, + } + } + + // Find most recent block grant for this subscription + const blocks = await db + .select() + .from(schema.creditLedger) + .where( + and( + eq(schema.creditLedger.user_id, userId), + eq(schema.creditLedger.type, 'subscription'), + eq(schema.creditLedger.stripe_subscription_id, subscriptionId), + ), + ) + .orderBy(desc(schema.creditLedger.created_at)) + .limit(1) + + const currentBlock = blocks[0] + + // No block yet or block expired → can start a new one + if (!currentBlock || !currentBlock.expires_at || currentBlock.expires_at <= now) { + return { + limited: false, + canStartNewBlock: true, + weeklyUsed: weekly.used, + weeklyLimit: weekly.limit, + weeklyResetsAt: weekly.resetsAt, + weeklyPercentUsed: weekly.percentUsed, + } + } + + // Block active but exhausted + if (currentBlock.balance <= 0) { + return { + limited: true, + reason: 'block_exhausted', + canStartNewBlock: false, + blockUsed: currentBlock.principal, + blockLimit: currentBlock.principal, + blockResetsAt: currentBlock.expires_at, + weeklyUsed: weekly.used, + weeklyLimit: weekly.limit, + weeklyResetsAt: weekly.resetsAt, + weeklyPercentUsed: weekly.percentUsed, + } + } + + // Block active with credits remaining + return { + limited: false, + canStartNewBlock: false, + blockUsed: currentBlock.principal - currentBlock.balance, + blockLimit: currentBlock.principal, + blockResetsAt: currentBlock.expires_at, + weeklyUsed: weekly.used, + weeklyLimit: weekly.limit, + weeklyResetsAt: weekly.resetsAt, + weeklyPercentUsed: weekly.percentUsed, + } +} + +// --------------------------------------------------------------------------- +// Subscription lookup +// --------------------------------------------------------------------------- + +export async function getActiveSubscription(params: { + userId: string + logger: Logger +}): Promise { + const { userId } = params + + const subs = await db + .select() + .from(schema.subscription) + .where( + and( + eq(schema.subscription.user_id, userId), + eq(schema.subscription.status, 'active'), + ), + ) + .limit(1) + + return subs[0] ?? null +} + +export async function isSubscriber(params: { + userId: string + logger: Logger +}): Promise { + const sub = await getActiveSubscription(params) + return sub !== null +} + +// --------------------------------------------------------------------------- +// Subscribe flow (Option B — unify renewal dates) +// --------------------------------------------------------------------------- + +/** + * Handles the first-time-subscribe side-effects: + * 1. Moves `next_quota_reset` to Stripe's `current_period_end`. + * 2. Increments `subscription_count`. + * 3. Migrates unused free/referral credits into a single grant aligned to + * the new reset date. + * + * All operations run inside an advisory-locked transaction. + */ +export async function handleSubscribe(params: { + userId: string + stripeSubscription: Stripe.Subscription + logger: Logger +}): Promise { + const { userId, stripeSubscription, logger } = params + + // Idempotency: skip if this subscription was already processed + const existing = await db + .select({ stripe_subscription_id: schema.subscription.stripe_subscription_id }) + .from(schema.subscription) + .where(eq(schema.subscription.stripe_subscription_id, stripeSubscription.id)) + .limit(1) + + if (existing.length > 0) { + logger.info( + { userId, subscriptionId: stripeSubscription.id }, + 'Subscription already processed — skipping handleSubscribe', + ) + return + } + + const newResetDate = new Date(stripeSubscription.current_period_end * 1000) + + await withAdvisoryLockTransaction({ + callback: async (tx) => { + // Move next_quota_reset and bump subscription_count + await tx + .update(schema.user) + .set({ + next_quota_reset: newResetDate, + subscription_count: sql`${schema.user.subscription_count} + 1`, + }) + .where(eq(schema.user.id, userId)) + + // Migrate unused credits so nothing is lost + await migrateUnusedCredits({ tx, userId, expiresAt: newResetDate, logger }) + }, + lockKey: `user:${userId}`, + context: { userId, subscriptionId: stripeSubscription.id }, + logger, + }) + + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_CREATED, + userId, + properties: { + subscriptionId: stripeSubscription.id, + newResetDate: newResetDate.toISOString(), + }, + logger, + }) + + logger.info( + { + userId, + subscriptionId: stripeSubscription.id, + newResetDate, + }, + 'Processed subscribe: reset date moved and credits migrated', + ) +} + +// --------------------------------------------------------------------------- +// Internal: credit migration +// --------------------------------------------------------------------------- + +type DbTransaction = Parameters[0] extends ( + tx: infer T, +) => unknown + ? T + : never + +/** + * Migrates unused free & referral credits into a single grant that expires + * at `expiresAt`. The old grants have their balance zeroed. + */ +async function migrateUnusedCredits(params: { + tx: DbTransaction + userId: string + expiresAt: Date + logger: Logger +}): Promise { + const { tx, userId, expiresAt, logger } = params + const now = new Date() + + // Find all free/referral grants with remaining balance + const unusedGrants = await tx + .select() + .from(schema.creditLedger) + .where( + and( + eq(schema.creditLedger.user_id, userId), + inArray(schema.creditLedger.type, ['free', 'referral']), + gt(schema.creditLedger.balance, 0), + or( + isNull(schema.creditLedger.expires_at), + gt(schema.creditLedger.expires_at, now), + ), + ), + ) + + const totalUnused = unusedGrants.reduce( + (sum, grant) => sum + grant.balance, + 0, + ) + + if (totalUnused === 0) { + logger.debug({ userId }, 'No unused credits to migrate') + return + } + + // Zero out old grants + for (const grant of unusedGrants) { + await tx + .update(schema.creditLedger) + .set({ balance: 0 }) + .where(eq(schema.creditLedger.operation_id, grant.operation_id)) + } + + // Create a single migration grant preserving the total + const operationId = `migration-${userId}-${Date.now()}` + await tx + .insert(schema.creditLedger) + .values({ + operation_id: operationId, + user_id: userId, + type: 'free', + principal: totalUnused, + balance: totalUnused, + priority: GRANT_PRIORITIES.free, + expires_at: expiresAt, + description: 'Migrated credits from subscription transition', + }) + .onConflictDoNothing({ target: schema.creditLedger.operation_id }) + + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_CREDITS_MIGRATED, + userId, + properties: { + totalMigrated: totalUnused, + grantsZeroed: unusedGrants.length, + operationId, + }, + logger, + }) + + logger.info( + { + userId, + totalMigrated: totalUnused, + grantsZeroed: unusedGrants.length, + operationId, + }, + 'Migrated unused credits for subscription transition', + ) +} diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 14377741c5..d7de8b02d2 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -52,6 +52,12 @@ export const agentStepStatus = pgEnum('agent_step_status', [ 'skipped', ]) +export const subscriptionStatusEnum = pgEnum('subscription_status', [ + 'active', + 'past_due', + 'canceled', +]) + export const user = pgTable('user', { id: text('id') .primaryKey() @@ -77,6 +83,7 @@ export const user = pgTable('user', { auto_topup_threshold: integer('auto_topup_threshold'), auto_topup_amount: integer('auto_topup_amount'), banned: boolean('banned').notNull().default(false), + subscription_count: integer('subscription_count').notNull().default(0), }) export const account = pgTable( @@ -120,6 +127,7 @@ export const creditLedger = pgTable( .notNull() .defaultNow(), org_id: text('org_id').references(() => org.id, { onDelete: 'cascade' }), + stripe_subscription_id: text('stripe_subscription_id'), }, (table) => [ index('idx_credit_ledger_active_balance') @@ -132,6 +140,11 @@ export const creditLedger = pgTable( ) .where(sql`${table.balance} != 0 AND ${table.expires_at} IS NULL`), index('idx_credit_ledger_org').on(table.org_id), + index('idx_credit_ledger_subscription').on( + table.stripe_subscription_id, + table.type, + table.created_at, + ), ], ) @@ -442,6 +455,59 @@ export const adImpression = pgTable( ], ) +// Subscription tables +export const subscription = pgTable( + 'subscription', + { + stripe_subscription_id: text('stripe_subscription_id').primaryKey(), + stripe_customer_id: text('stripe_customer_id').notNull(), + user_id: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + stripe_price_id: text('stripe_price_id').notNull(), + plan_name: text('plan_name').notNull(), + status: subscriptionStatusEnum('status').notNull().default('active'), + billing_period_start: timestamp('billing_period_start', { + mode: 'date', + withTimezone: true, + }).notNull(), + billing_period_end: timestamp('billing_period_end', { + mode: 'date', + withTimezone: true, + }).notNull(), + cancel_at_period_end: boolean('cancel_at_period_end') + .notNull() + .default(false), + canceled_at: timestamp('canceled_at', { mode: 'date', withTimezone: true }), + created_at: timestamp('created_at', { mode: 'date', withTimezone: true }) + .notNull() + .defaultNow(), + updated_at: timestamp('updated_at', { mode: 'date', withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index('idx_subscription_customer').on(table.stripe_customer_id), + index('idx_subscription_user').on(table.user_id), + index('idx_subscription_status') + .on(table.status) + .where(sql`${table.status} = 'active'`), + ], +) + +export const limitOverride = pgTable('limit_override', { + user_id: text('user_id') + .primaryKey() + .references(() => user.id, { onDelete: 'cascade' }), + credits_per_block: integer('credits_per_block').notNull(), + block_duration_hours: integer('block_duration_hours').notNull(), + weekly_credit_limit: integer('weekly_credit_limit').notNull(), + created_at: timestamp('created_at', { mode: 'date', withTimezone: true }) + .notNull() + .defaultNow(), + updated_at: timestamp('updated_at', { mode: 'date', withTimezone: true }) + .notNull() + .defaultNow(), +}) + export type GitEvalMetadata = { numCases?: number // Number of eval cases successfully run (total) avgScore?: number // Average score across all cases diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 54136b3139..2aca742fe5 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -21,6 +21,7 @@ export const serverEnvSchema = clientEnvSchema.extend({ STRIPE_WEBHOOK_SECRET_KEY: z.string().min(1), STRIPE_USAGE_PRICE_ID: z.string().min(1), STRIPE_TEAM_FEE_PRICE_ID: z.string().min(1), + STRIPE_SUBSCRIPTION_200_PRICE_ID: z.string().min(1).optional(), LOOPS_API_KEY: z.string().min(1), DISCORD_PUBLIC_KEY: z.string().min(1), DISCORD_BOT_TOKEN: z.string().min(1), @@ -61,6 +62,7 @@ export const serverProcessEnv: ServerInput = { STRIPE_WEBHOOK_SECRET_KEY: process.env.STRIPE_WEBHOOK_SECRET_KEY, STRIPE_USAGE_PRICE_ID: process.env.STRIPE_USAGE_PRICE_ID, STRIPE_TEAM_FEE_PRICE_ID: process.env.STRIPE_TEAM_FEE_PRICE_ID, + STRIPE_SUBSCRIPTION_200_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_200_PRICE_ID, LOOPS_API_KEY: process.env.LOOPS_API_KEY, DISCORD_PUBLIC_KEY: process.env.DISCORD_PUBLIC_KEY, DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN, diff --git a/web/src/app/api/stripe/webhook/route.ts b/web/src/app/api/stripe/webhook/route.ts index 65cc0bc5f6..d728fa9d7f 100644 --- a/web/src/app/api/stripe/webhook/route.ts +++ b/web/src/app/api/stripe/webhook/route.ts @@ -2,6 +2,10 @@ import { grantOrganizationCredits, processAndGrantCredit, revokeGrantByOperationId, + handleSubscriptionInvoicePaid, + handleSubscriptionInvoicePaymentFailed, + handleSubscriptionUpdated, + handleSubscriptionDeleted, } from '@codebuff/billing' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -22,6 +26,19 @@ import { import { getStripeCustomerId } from '@/lib/stripe-utils' import { logger } from '@/util/logger' +/** + * Checks whether a Stripe subscription ID belongs to an organization. + * Used to guard user-subscription handlers from processing org subscriptions. + */ +async function isOrgSubscription(subscriptionId: string): Promise { + const orgs = await db + .select({ id: schema.org.id }) + .from(schema.org) + .where(eq(schema.org.stripe_subscription_id, subscriptionId)) + .limit(1) + return orgs.length > 0 +} + async function handleCheckoutSessionCompleted( session: Stripe.Checkout.Session, ) { @@ -354,9 +371,22 @@ const webhookHandler = async (req: NextRequest): Promise => { case 'customer.created': break case 'customer.subscription.created': - case 'customer.subscription.updated': + case 'customer.subscription.updated': { + const sub = event.data.object as Stripe.Subscription + // Handle org subscriptions (legacy) + await handleSubscriptionEvent(sub) + // Handle user subscriptions (new) — skip org subscriptions + if (!sub.metadata?.organization_id) { + await handleSubscriptionUpdated({ stripeSubscription: sub, logger }) + } + break + } case 'customer.subscription.deleted': { - await handleSubscriptionEvent(event.data.object as Stripe.Subscription) + const sub = event.data.object as Stripe.Subscription + await handleSubscriptionEvent(sub) + if (!sub.metadata?.organization_id) { + await handleSubscriptionDeleted({ stripeSubscription: sub, logger }) + } break } case 'charge.dispute.created': { @@ -511,11 +541,32 @@ const webhookHandler = async (req: NextRequest): Promise => { break } case 'invoice.paid': { - await handleInvoicePaid(event.data.object as Stripe.Invoice) + const invoice = event.data.object as Stripe.Invoice + await handleInvoicePaid(invoice) + // Handle subscription invoice payments (user subscriptions only) + if (invoice.subscription) { + const subId = + typeof invoice.subscription === 'string' + ? invoice.subscription + : invoice.subscription.id + if (!(await isOrgSubscription(subId))) { + await handleSubscriptionInvoicePaid({ invoice, logger }) + } + } break } case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice + // Handle subscription payment failures (user subscriptions only) + if (invoice.subscription) { + const subId = + typeof invoice.subscription === 'string' + ? invoice.subscription + : invoice.subscription.id + if (!(await isOrgSubscription(subId))) { + await handleSubscriptionInvoicePaymentFailed({ invoice, logger }) + } + } if ( invoice.metadata?.type === 'auto-topup' && invoice.billing_reason === 'manual' diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index dae0f757f8..7048845252 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -53,6 +53,14 @@ const grantTypeInfo: Record< label: 'Monthly Free', description: 'Your monthly allowance', }, + subscription: { + bg: 'bg-indigo-500', + text: 'text-indigo-600 dark:text-indigo-400', + gradient: 'from-indigo-500/70 to-indigo-600/70', + icon: , + label: 'Pro Subscription', + description: 'Credits from your Pro plan', + }, referral: { bg: 'bg-green-500', text: 'text-green-600 dark:text-green-400', @@ -233,6 +241,7 @@ export const UsageDisplay = ({ // Calculate used credits per type (excluding organization) const usedCredits: Record = { free: 0, + subscription: 0, referral: 0, purchase: 0, admin: 0, @@ -252,7 +261,7 @@ export const UsageDisplay = ({ }) // Group credits by expiration type (excluding organization) - const expiringTypes: FilteredGrantType[] = ['free', 'referral'] + const expiringTypes: FilteredGrantType[] = ['subscription', 'free', 'referral'] const nonExpiringTypes: FilteredGrantType[] = ['admin', 'purchase', 'ad'] const expiringTotal = expiringTypes.reduce( From 00af124f1a7f95d7ae31bf82d4744a8666b2d017 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 17:20:43 -0800 Subject: [PATCH 02/28] Review fixes --- packages/billing/src/subscription-webhooks.ts | 19 +++++++++-- packages/billing/src/subscription.ts | 34 +++++++++---------- web/src/app/api/stripe/webhook/route.ts | 4 +-- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index e161ec8cd2..64e2bfc0e5 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -13,6 +13,17 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' import type { PlanConfig } from '@codebuff/common/constants/subscription-plans' import type Stripe from 'stripe' +type SubscriptionStatus = (typeof schema.subscriptionStatusEnum.enumValues)[number] + +/** + * Maps a Stripe subscription status to our local enum. + */ +function mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus { + if (status === 'past_due') return 'past_due' + if (status === 'canceled') return 'canceled' + return 'active' +} + /** * Looks up a user ID by Stripe customer ID. */ @@ -137,7 +148,7 @@ export async function handleSubscriptionInvoicePaid(params: { target: schema.subscription.stripe_subscription_id, set: { status: 'active', - user_id: userId, + ...(userId ? { user_id: userId } : {}), stripe_price_id: priceId, plan_name: plan.name, billing_period_start: new Date( @@ -250,6 +261,8 @@ export async function handleSubscriptionUpdated(params: { : stripeSubscription.customer.id const userId = await getUserIdByCustomerId(customerId) + const status = mapStripeStatus(stripeSubscription.status) + // Upsert — webhook ordering is not guaranteed by Stripe, so this event // may arrive before invoice.paid creates the row. await db @@ -260,6 +273,7 @@ export async function handleSubscriptionUpdated(params: { user_id: userId, stripe_price_id: priceId, plan_name: planName, + status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( stripeSubscription.current_period_start * 1000, @@ -271,9 +285,10 @@ export async function handleSubscriptionUpdated(params: { .onConflictDoUpdate({ target: schema.subscription.stripe_subscription_id, set: { - user_id: userId, + ...(userId ? { user_id: userId } : {}), stripe_price_id: priceId, plan_name: planName, + status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( stripeSubscription.current_period_start * 1000, diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index ec916ec4c0..681409d697 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -537,26 +537,26 @@ export async function handleSubscribe(params: { logger: Logger }): Promise { const { userId, stripeSubscription, logger } = params - - // Idempotency: skip if this subscription was already processed - const existing = await db - .select({ stripe_subscription_id: schema.subscription.stripe_subscription_id }) - .from(schema.subscription) - .where(eq(schema.subscription.stripe_subscription_id, stripeSubscription.id)) - .limit(1) - - if (existing.length > 0) { - logger.info( - { userId, subscriptionId: stripeSubscription.id }, - 'Subscription already processed — skipping handleSubscribe', - ) - return - } - const newResetDate = new Date(stripeSubscription.current_period_end * 1000) await withAdvisoryLockTransaction({ callback: async (tx) => { + // Idempotency: skip if this subscription was already processed + // Must be inside the lock to prevent TOCTOU races on concurrent webhooks + const existing = await tx + .select({ stripe_subscription_id: schema.subscription.stripe_subscription_id }) + .from(schema.subscription) + .where(eq(schema.subscription.stripe_subscription_id, stripeSubscription.id)) + .limit(1) + + if (existing.length > 0) { + logger.info( + { userId, subscriptionId: stripeSubscription.id }, + 'Subscription already processed — skipping handleSubscribe', + ) + return + } + // Move next_quota_reset and bump subscription_count await tx .update(schema.user) @@ -652,7 +652,7 @@ async function migrateUnusedCredits(params: { } // Create a single migration grant preserving the total - const operationId = `migration-${userId}-${Date.now()}` + const operationId = `migration-${userId}-${crypto.randomUUID()}` await tx .insert(schema.creditLedger) .values({ diff --git a/web/src/app/api/stripe/webhook/route.ts b/web/src/app/api/stripe/webhook/route.ts index d728fa9d7f..5c0471e2e1 100644 --- a/web/src/app/api/stripe/webhook/route.ts +++ b/web/src/app/api/stripe/webhook/route.ts @@ -251,9 +251,9 @@ async function handleSubscriptionEvent(subscription: Stripe.Subscription) { ) if (!organizationId) { - logger.warn( + logger.debug( { subscriptionId: subscription.id }, - 'Subscription event received without organization_id in metadata', + 'Subscription event received without organization_id in metadata (user subscription)', ) return } From 8e314697305f8baa2415fb317676dae2948a9a78 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 17:44:09 -0800 Subject: [PATCH 03/28] Plans to tiered subscription. Don't store plan name/tier in db --- common/src/constants/subscription-plans.ts | 21 +++----- packages/billing/src/subscription-webhooks.ts | 48 ------------------- packages/billing/src/subscription.ts | 24 ++++------ packages/internal/src/db/schema.ts | 1 - .../app/profile/components/usage-display.tsx | 4 +- 5 files changed, 17 insertions(+), 81 deletions(-) diff --git a/common/src/constants/subscription-plans.ts b/common/src/constants/subscription-plans.ts index 14e8d1aa7e..e1d39a24a0 100644 --- a/common/src/constants/subscription-plans.ts +++ b/common/src/constants/subscription-plans.ts @@ -1,26 +1,19 @@ -export const PLAN_NAMES = ['pro'] as const -export type PlanName = (typeof PLAN_NAMES)[number] +export const SUBSCRIPTION_DISPLAY_NAME = 'Flex' as const -export interface PlanConfig { - name: PlanName - displayName: string +export interface TierConfig { monthlyPrice: number creditsPerBlock: number blockDurationHours: number weeklyCreditsLimit: number } -export const PLANS = { - pro: { - name: 'pro', - displayName: 'Pro', +export const SUBSCRIPTION_TIERS = { + 200: { monthlyPrice: 200, creditsPerBlock: 1250, blockDurationHours: 5, - weeklyCreditsLimit: 15000, + weeklyCreditsLimit: 12500, }, -} as const satisfies Record +} as const satisfies Record -export function isPlanName(name: string): name is PlanName { - return (PLAN_NAMES as readonly string[]).includes(name) -} +export const DEFAULT_TIER = SUBSCRIPTION_TIERS[200] diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 64e2bfc0e5..0d572768e1 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -1,16 +1,13 @@ import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { PLANS } from '@codebuff/common/constants/subscription-plans' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' -import { env } from '@codebuff/internal/env' import { stripeServer } from '@codebuff/internal/util/stripe' import { eq } from 'drizzle-orm' import { handleSubscribe } from './subscription' import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { PlanConfig } from '@codebuff/common/constants/subscription-plans' import type Stripe from 'stripe' type SubscriptionStatus = (typeof schema.subscriptionStatusEnum.enumValues)[number] @@ -38,22 +35,6 @@ async function getUserIdByCustomerId( return userRecord[0]?.id ?? null } -/** - * Resolves a PlanConfig from a Stripe price ID. - * Compares against the configured env var for each plan. - */ -function getPlanFromPriceId(priceId: string): PlanConfig { - if (!env.STRIPE_SUBSCRIPTION_200_PRICE_ID) { - throw new Error( - 'STRIPE_SUBSCRIPTION_200_PRICE_ID env var is not configured', - ) - } - if (env.STRIPE_SUBSCRIPTION_200_PRICE_ID === priceId) { - return PLANS.pro - } - throw new Error(`Unknown subscription price ID: ${priceId}`) -} - // --------------------------------------------------------------------------- // invoice.paid // --------------------------------------------------------------------------- @@ -100,17 +81,6 @@ export async function handleSubscriptionInvoicePaid(params: { return } - let plan: PlanConfig - try { - plan = getPlanFromPriceId(priceId) - } catch { - logger.warn( - { subscriptionId, priceId }, - 'Subscription invoice for unrecognised price — skipping', - ) - return - } - // Look up the user for this customer const userId = await getUserIdByCustomerId(customerId) @@ -138,7 +108,6 @@ export async function handleSubscriptionInvoicePaid(params: { stripe_customer_id: customerId, user_id: userId, stripe_price_id: priceId, - plan_name: plan.name, status: 'active', billing_period_start: new Date(stripeSub.current_period_start * 1000), billing_period_end: new Date(stripeSub.current_period_end * 1000), @@ -150,7 +119,6 @@ export async function handleSubscriptionInvoicePaid(params: { status: 'active', ...(userId ? { user_id: userId } : {}), stripe_price_id: priceId, - plan_name: plan.name, billing_period_start: new Date( stripeSub.current_period_start * 1000, ), @@ -164,7 +132,6 @@ export async function handleSubscriptionInvoicePaid(params: { { subscriptionId, customerId, - planName: plan.name, billingReason: invoice.billing_reason, }, 'Processed subscription invoice.paid', @@ -243,18 +210,6 @@ export async function handleSubscriptionUpdated(params: { return } - let planName: string - try { - const plan = getPlanFromPriceId(priceId) - planName = plan.name - } catch { - logger.warn( - { subscriptionId, priceId }, - 'Subscription updated with unrecognised price — skipping', - ) - return - } - const customerId = typeof stripeSubscription.customer === 'string' ? stripeSubscription.customer @@ -272,7 +227,6 @@ export async function handleSubscriptionUpdated(params: { stripe_customer_id: customerId, user_id: userId, stripe_price_id: priceId, - plan_name: planName, status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( @@ -287,7 +241,6 @@ export async function handleSubscriptionUpdated(params: { set: { ...(userId ? { user_id: userId } : {}), stripe_price_id: priceId, - plan_name: planName, status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( @@ -303,7 +256,6 @@ export async function handleSubscriptionUpdated(params: { logger.info( { subscriptionId, - planName, cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, }, 'Processed subscription update', diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 681409d697..3dac9af3fc 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -2,8 +2,8 @@ import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { GRANT_PRIORITIES } from '@codebuff/common/constants/grant-priorities' import { - PLANS, - isPlanName, + DEFAULT_TIER, + SUBSCRIPTION_DISPLAY_NAME, } from '@codebuff/common/constants/subscription-plans' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -131,15 +131,14 @@ export function getWeekEnd( /** * Resolves the effective subscription limits for a user. - * Checks `limit_override` first, then falls back to the plan constants. + * Checks `limit_override` first, then falls back to the default tier constants. */ export async function getSubscriptionLimits(params: { userId: string - planName: string logger: Logger conn?: DbConn }): Promise { - const { userId, planName, logger, conn = db } = params + const { userId, logger, conn = db } = params const overrides = await conn .select() @@ -160,15 +159,10 @@ export async function getSubscriptionLimits(params: { } } - if (!isPlanName(planName)) { - throw new Error(`Unknown plan name: ${planName}`) - } - - const plan = PLANS[planName] return { - creditsPerBlock: plan.creditsPerBlock, - blockDurationHours: plan.blockDurationHours, - weeklyCreditsLimit: plan.weeklyCreditsLimit, + creditsPerBlock: DEFAULT_TIER.creditsPerBlock, + blockDurationHours: DEFAULT_TIER.blockDurationHours, + weeklyCreditsLimit: DEFAULT_TIER.weeklyCreditsLimit, } } @@ -282,7 +276,6 @@ export async function ensureActiveBlockGrant(params: { // 2. Resolve limits const limits = await getSubscriptionLimits({ userId, - planName: subscription.plan_name, logger, conn: tx, }) @@ -331,7 +324,7 @@ export async function ensureActiveBlockGrant(params: { balance: limits.creditsPerBlock, priority: GRANT_PRIORITIES.subscription, expires_at: expiresAt, - description: `${subscription.plan_name} block (${limits.blockDurationHours}h)`, + description: `${SUBSCRIPTION_DISPLAY_NAME} block (${limits.blockDurationHours}h)`, }) .onConflictDoNothing({ target: schema.creditLedger.operation_id }) .returning() @@ -404,7 +397,6 @@ export async function checkRateLimit(params: { const limits = await getSubscriptionLimits({ userId, - planName: subscription.plan_name, logger, }) diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index d7de8b02d2..2fe92d0dae 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -463,7 +463,6 @@ export const subscription = pgTable( stripe_customer_id: text('stripe_customer_id').notNull(), user_id: text('user_id').references(() => user.id, { onDelete: 'cascade' }), stripe_price_id: text('stripe_price_id').notNull(), - plan_name: text('plan_name').notNull(), status: subscriptionStatusEnum('status').notNull().default('active'), billing_period_start: timestamp('billing_period_start', { mode: 'date', diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index 7048845252..61d5a95ac4 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -58,8 +58,8 @@ const grantTypeInfo: Record< text: 'text-indigo-600 dark:text-indigo-400', gradient: 'from-indigo-500/70 to-indigo-600/70', icon: , - label: 'Pro Subscription', - description: 'Credits from your Pro plan', + label: 'Flex', + description: 'Credits from your Flex subscription', }, referral: { bg: 'bg-green-500', From 66463e9635b06993fedd93cd7a2d18bc5779c8d2 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 17:53:55 -0800 Subject: [PATCH 04/28] Extract getUserByStripeCustomerId helper --- packages/billing/src/subscription-webhooks.ts | 27 +++++----------- packages/internal/src/util/stripe.ts | 31 +++++++++++++++++-- web/src/lib/ban-conditions.ts | 27 ++-------------- 3 files changed, 39 insertions(+), 46 deletions(-) diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 0d572768e1..9282645631 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -2,7 +2,10 @@ import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' -import { stripeServer } from '@codebuff/internal/util/stripe' +import { + getUserByStripeCustomerId, + stripeServer, +} from '@codebuff/internal/util/stripe' import { eq } from 'drizzle-orm' import { handleSubscribe } from './subscription' @@ -21,20 +24,6 @@ function mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus return 'active' } -/** - * Looks up a user ID by Stripe customer ID. - */ -async function getUserIdByCustomerId( - customerId: string, -): Promise { - const userRecord = await db - .select({ id: schema.user.id }) - .from(schema.user) - .where(eq(schema.user.stripe_customer_id, customerId)) - .limit(1) - return userRecord[0]?.id ?? null -} - // --------------------------------------------------------------------------- // invoice.paid // --------------------------------------------------------------------------- @@ -82,7 +71,7 @@ export async function handleSubscriptionInvoicePaid(params: { } // Look up the user for this customer - const userId = await getUserIdByCustomerId(customerId) + const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null // On first invoice, migrate renewal date & credits (Option B) if (invoice.billing_reason === 'subscription_create') { @@ -163,7 +152,7 @@ export async function handleSubscriptionInvoicePaymentFailed(params: { ? invoice.customer : invoice.customer?.id const userId = customerId - ? await getUserIdByCustomerId(customerId) + ? (await getUserByStripeCustomerId(customerId))?.id ?? null : null await db @@ -214,7 +203,7 @@ export async function handleSubscriptionUpdated(params: { typeof stripeSubscription.customer === 'string' ? stripeSubscription.customer : stripeSubscription.customer.id - const userId = await getUserIdByCustomerId(customerId) + const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null const status = mapStripeStatus(stripeSubscription.status) @@ -280,7 +269,7 @@ export async function handleSubscriptionDeleted(params: { typeof stripeSubscription.customer === 'string' ? stripeSubscription.customer : stripeSubscription.customer.id - const userId = await getUserIdByCustomerId(customerId) + const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null await db .update(schema.subscription) diff --git a/packages/internal/src/util/stripe.ts b/packages/internal/src/util/stripe.ts index f95ebdec28..29517cb64e 100644 --- a/packages/internal/src/util/stripe.ts +++ b/packages/internal/src/util/stripe.ts @@ -1,6 +1,8 @@ -import Stripe from 'stripe' - +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' import { env } from '@codebuff/internal/env' +import { eq } from 'drizzle-orm' +import Stripe from 'stripe' export const stripeServer = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2024-06-20', @@ -15,3 +17,28 @@ export async function getCurrentSubscription(customerId: string) { }) return subscriptions.data[0] } + +/** + * Look up a user by their Stripe customer ID. + */ +export async function getUserByStripeCustomerId( + stripeCustomerId: string, +): Promise<{ + id: string + banned: boolean + email: string + name: string | null +} | null> { + const users = await db + .select({ + id: schema.user.id, + banned: schema.user.banned, + email: schema.user.email, + name: schema.user.name, + }) + .from(schema.user) + .where(eq(schema.user.stripe_customer_id, stripeCustomerId)) + .limit(1) + + return users[0] ?? null +} diff --git a/web/src/lib/ban-conditions.ts b/web/src/lib/ban-conditions.ts index 2be5352c06..9626b54a3d 100644 --- a/web/src/lib/ban-conditions.ts +++ b/web/src/lib/ban-conditions.ts @@ -5,6 +5,8 @@ import { eq } from 'drizzle-orm' import type { Logger } from '@codebuff/common/types/contracts/logger' +export { getUserByStripeCustomerId } from '@codebuff/internal/util/stripe' + // ============================================================================= // CONFIGURATION - Edit these values to adjust ban thresholds // ============================================================================= @@ -102,31 +104,6 @@ const BAN_CONDITIONS: BanCondition[] = [ // PUBLIC API // ============================================================================= -/** - * Look up a user by their Stripe customer ID - */ -export async function getUserByStripeCustomerId( - stripeCustomerId: string, -): Promise<{ - id: string - banned: boolean - email: string - name: string | null -} | null> { - const users = await db - .select({ - id: schema.user.id, - banned: schema.user.banned, - email: schema.user.email, - name: schema.user.name, - }) - .from(schema.user) - .where(eq(schema.user.stripe_customer_id, stripeCustomerId)) - .limit(1) - - return users[0] ?? null -} - /** * Ban a user and log the action */ From b807cfa515138755ecbe53b45b8cf15ea0437db2 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 17:55:16 -0800 Subject: [PATCH 05/28] migrateUnusedCredits: remove filter on free/referral --- packages/billing/src/subscription.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 3dac9af3fc..31f6f9c8b8 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -14,7 +14,6 @@ import { eq, gt, gte, - inArray, isNull, lt, or, @@ -175,14 +174,14 @@ export async function getSubscriptionLimits(params: { * billing-aligned week. */ export async function getWeeklyUsage(params: { - stripeSubscriptionId: string + userId: string billingPeriodStart: Date weeklyCreditsLimit: number logger: Logger conn?: DbConn }): Promise { const { - stripeSubscriptionId, + userId, billingPeriodStart, weeklyCreditsLimit, conn = db, @@ -199,10 +198,7 @@ export async function getWeeklyUsage(params: { .from(schema.creditLedger) .where( and( - eq( - schema.creditLedger.stripe_subscription_id, - stripeSubscriptionId, - ), + eq(schema.creditLedger.user_id, userId), eq(schema.creditLedger.type, 'subscription'), gte(schema.creditLedger.created_at, weekStart), lt(schema.creditLedger.created_at, weekEnd), @@ -255,7 +251,6 @@ export async function ensureActiveBlockGrant(params: { and( eq(schema.creditLedger.user_id, userId), eq(schema.creditLedger.type, 'subscription'), - eq(schema.creditLedger.stripe_subscription_id, subscriptionId), gt(schema.creditLedger.expires_at, now), gt(schema.creditLedger.balance, 0), ), @@ -282,7 +277,7 @@ export async function ensureActiveBlockGrant(params: { // 3. Check weekly limit before creating a new block const weekly = await getWeeklyUsage({ - stripeSubscriptionId: subscriptionId, + userId, billingPeriodStart: subscription.billing_period_start, weeklyCreditsLimit: limits.weeklyCreditsLimit, logger, @@ -392,7 +387,6 @@ export async function checkRateLimit(params: { logger: Logger }): Promise { const { userId, subscription, logger } = params - const subscriptionId = subscription.stripe_subscription_id const now = new Date() const limits = await getSubscriptionLimits({ @@ -401,7 +395,7 @@ export async function checkRateLimit(params: { }) const weekly = await getWeeklyUsage({ - stripeSubscriptionId: subscriptionId, + userId, billingPeriodStart: subscription.billing_period_start, weeklyCreditsLimit: limits.weeklyCreditsLimit, logger, @@ -420,7 +414,7 @@ export async function checkRateLimit(params: { } } - // Find most recent block grant for this subscription + // Find most recent subscription block grant for this user const blocks = await db .select() .from(schema.creditLedger) @@ -428,7 +422,6 @@ export async function checkRateLimit(params: { and( eq(schema.creditLedger.user_id, userId), eq(schema.creditLedger.type, 'subscription'), - eq(schema.creditLedger.stripe_subscription_id, subscriptionId), ), ) .orderBy(desc(schema.creditLedger.created_at)) @@ -616,7 +609,6 @@ async function migrateUnusedCredits(params: { .where( and( eq(schema.creditLedger.user_id, userId), - inArray(schema.creditLedger.type, ['free', 'referral']), gt(schema.creditLedger.balance, 0), or( isNull(schema.creditLedger.expires_at), From 8976298c0ab6be863586f4d3a15324f82a3023ca Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 17:55:31 -0800 Subject: [PATCH 06/28] Add .env.example for stripe price id --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index 2468ef832c..744b796236 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,7 @@ STRIPE_SECRET_KEY=sk_test_dummy_stripe_secret STRIPE_WEBHOOK_SECRET_KEY=whsec_dummy_webhook_secret STRIPE_USAGE_PRICE_ID=price_dummy_usage_id STRIPE_TEAM_FEE_PRICE_ID=price_dummy_team_fee_id +STRIPE_SUBSCRIPTION_200_PRICE_ID=price_dummy_subscription_200_id # External Services LINKUP_API_KEY=dummy_linkup_key From ed2a1d99308c1162b6aa1f00d8d5ccd4f5cf1604 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 17:56:18 -0800 Subject: [PATCH 07/28] Remove subscription_count. Add more stripe status enums --- packages/billing/src/subscription-webhooks.ts | 4 ++-- packages/billing/src/subscription.ts | 12 ++++-------- packages/internal/src/db/schema.ts | 6 +++++- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 9282645631..c1ba49903e 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -19,8 +19,8 @@ type SubscriptionStatus = (typeof schema.subscriptionStatusEnum.enumValues)[numb * Maps a Stripe subscription status to our local enum. */ function mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus { - if (status === 'past_due') return 'past_due' - if (status === 'canceled') return 'canceled' + const validStatuses: readonly string[] = schema.subscriptionStatusEnum.enumValues + if (validStatuses.includes(status)) return status as SubscriptionStatus return 'active' } diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 31f6f9c8b8..59b8b95745 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -510,9 +510,8 @@ export async function isSubscriber(params: { /** * Handles the first-time-subscribe side-effects: * 1. Moves `next_quota_reset` to Stripe's `current_period_end`. - * 2. Increments `subscription_count`. - * 3. Migrates unused free/referral credits into a single grant aligned to - * the new reset date. + * 2. Migrates unused credits into a single grant aligned to the new reset + * date. * * All operations run inside an advisory-locked transaction. */ @@ -542,13 +541,10 @@ export async function handleSubscribe(params: { return } - // Move next_quota_reset and bump subscription_count + // Move next_quota_reset to align with Stripe billing period await tx .update(schema.user) - .set({ - next_quota_reset: newResetDate, - subscription_count: sql`${schema.user.subscription_count} + 1`, - }) + .set({ next_quota_reset: newResetDate }) .where(eq(schema.user.id, userId)) // Migrate unused credits so nothing is lost diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 2fe92d0dae..9a54d70ae3 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -53,9 +53,14 @@ export const agentStepStatus = pgEnum('agent_step_status', [ ]) export const subscriptionStatusEnum = pgEnum('subscription_status', [ + 'incomplete', + 'incomplete_expired', + 'trialing', 'active', 'past_due', 'canceled', + 'unpaid', + 'paused', ]) export const user = pgTable('user', { @@ -83,7 +88,6 @@ export const user = pgTable('user', { auto_topup_threshold: integer('auto_topup_threshold'), auto_topup_amount: integer('auto_topup_amount'), banned: boolean('banned').notNull().default(false), - subscription_count: integer('subscription_count').notNull().default(0), }) export const account = pgTable( From 31db66e4c3aa651cf88341057dac260a3f72a9aa Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 18:17:47 -0800 Subject: [PATCH 08/28] cleanup --- web/src/app/api/stripe/webhook/route.ts | 46 +++++++++++-------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/web/src/app/api/stripe/webhook/route.ts b/web/src/app/api/stripe/webhook/route.ts index 5c0471e2e1..401ba49b73 100644 --- a/web/src/app/api/stripe/webhook/route.ts +++ b/web/src/app/api/stripe/webhook/route.ts @@ -26,9 +26,18 @@ import { import { getStripeCustomerId } from '@/lib/stripe-utils' import { logger } from '@/util/logger' +/** + * Extracts a string ID from a Stripe object that may be a string or an + * expanded object with an `id` field. + */ +function getStripeId(obj: string | { id: string }): string { + return typeof obj === 'string' ? obj : obj.id +} + /** * Checks whether a Stripe subscription ID belongs to an organization. - * Used to guard user-subscription handlers from processing org subscriptions. + * Used to guard user-subscription handlers from processing org subscriptions + * on invoice events (where subscription metadata isn't directly available). */ async function isOrgSubscription(subscriptionId: string): Promise { const orgs = await db @@ -239,6 +248,7 @@ async function handleCheckoutSessionCompleted( async function handleSubscriptionEvent(subscription: Stripe.Subscription) { const organizationId = subscription.metadata?.organization_id + if (!organizationId) return logger.info( { @@ -247,17 +257,9 @@ async function handleSubscriptionEvent(subscription: Stripe.Subscription) { customerId: subscription.customer, organizationId, }, - 'Subscription event received', + 'Organization subscription event received', ) - if (!organizationId) { - logger.debug( - { subscriptionId: subscription.id }, - 'Subscription event received without organization_id in metadata (user subscription)', - ) - return - } - try { // Handle subscription cancellation if (subscription.status === 'canceled') { @@ -373,18 +375,18 @@ const webhookHandler = async (req: NextRequest): Promise => { case 'customer.subscription.created': case 'customer.subscription.updated': { const sub = event.data.object as Stripe.Subscription - // Handle org subscriptions (legacy) - await handleSubscriptionEvent(sub) - // Handle user subscriptions (new) — skip org subscriptions - if (!sub.metadata?.organization_id) { + if (sub.metadata?.organization_id) { + await handleSubscriptionEvent(sub) + } else { await handleSubscriptionUpdated({ stripeSubscription: sub, logger }) } break } case 'customer.subscription.deleted': { const sub = event.data.object as Stripe.Subscription - await handleSubscriptionEvent(sub) - if (!sub.metadata?.organization_id) { + if (sub.metadata?.organization_id) { + await handleSubscriptionEvent(sub) + } else { await handleSubscriptionDeleted({ stripeSubscription: sub, logger }) } break @@ -543,12 +545,8 @@ const webhookHandler = async (req: NextRequest): Promise => { case 'invoice.paid': { const invoice = event.data.object as Stripe.Invoice await handleInvoicePaid(invoice) - // Handle subscription invoice payments (user subscriptions only) if (invoice.subscription) { - const subId = - typeof invoice.subscription === 'string' - ? invoice.subscription - : invoice.subscription.id + const subId = getStripeId(invoice.subscription) if (!(await isOrgSubscription(subId))) { await handleSubscriptionInvoicePaid({ invoice, logger }) } @@ -557,12 +555,8 @@ const webhookHandler = async (req: NextRequest): Promise => { } case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice - // Handle subscription payment failures (user subscriptions only) if (invoice.subscription) { - const subId = - typeof invoice.subscription === 'string' - ? invoice.subscription - : invoice.subscription.id + const subId = getStripeId(invoice.subscription) if (!(await isOrgSubscription(subId))) { await handleSubscriptionInvoicePaymentFailed({ invoice, logger }) } From 458616ac77fe91d524d9c0d09e7d0323a53c4b6a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 18:51:22 -0800 Subject: [PATCH 09/28] Generate migration --- .../db/migrations/0036_handy_silver_sable.sql | 32 + .../src/db/migrations/meta/0036_snapshot.json | 3051 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 9 +- 3 files changed, 3091 insertions(+), 1 deletion(-) create mode 100644 packages/internal/src/db/migrations/0036_handy_silver_sable.sql create mode 100644 packages/internal/src/db/migrations/meta/0036_snapshot.json diff --git a/packages/internal/src/db/migrations/0036_handy_silver_sable.sql b/packages/internal/src/db/migrations/0036_handy_silver_sable.sql new file mode 100644 index 0000000000..6ede124432 --- /dev/null +++ b/packages/internal/src/db/migrations/0036_handy_silver_sable.sql @@ -0,0 +1,32 @@ +CREATE TYPE "public"."subscription_status" AS ENUM('incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused');--> statement-breakpoint +ALTER TYPE "public"."grant_type" ADD VALUE 'subscription' BEFORE 'purchase';--> statement-breakpoint +CREATE TABLE "limit_override" ( + "user_id" text PRIMARY KEY NOT NULL, + "credits_per_block" integer NOT NULL, + "block_duration_hours" integer NOT NULL, + "weekly_credit_limit" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "subscription" ( + "stripe_subscription_id" text PRIMARY KEY NOT NULL, + "stripe_customer_id" text NOT NULL, + "user_id" text, + "stripe_price_id" text NOT NULL, + "status" "subscription_status" DEFAULT 'active' NOT NULL, + "billing_period_start" timestamp with time zone NOT NULL, + "billing_period_end" timestamp with time zone NOT NULL, + "cancel_at_period_end" boolean DEFAULT false NOT NULL, + "canceled_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "credit_ledger" ADD COLUMN "stripe_subscription_id" text;--> statement-breakpoint +ALTER TABLE "limit_override" ADD CONSTRAINT "limit_override_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "subscription" ADD CONSTRAINT "subscription_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_subscription_customer" ON "subscription" USING btree ("stripe_customer_id");--> statement-breakpoint +CREATE INDEX "idx_subscription_user" ON "subscription" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_subscription_status" ON "subscription" USING btree ("status") WHERE "subscription"."status" = 'active';--> statement-breakpoint +CREATE INDEX "idx_credit_ledger_subscription" ON "credit_ledger" USING btree ("stripe_subscription_id","type","created_at"); \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0036_snapshot.json b/packages/internal/src/db/migrations/meta/0036_snapshot.json new file mode 100644 index 0000000000..d2ea086415 --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0036_snapshot.json @@ -0,0 +1,3051 @@ +{ + "id": "14a00b85-f71c-42bf-911c-44fc725de438", + "prevId": "7835ce78-4836-46c4-b91b-5941d93544e9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": [ + "imp_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": [ + "publisher_id", + "id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": [ + "agent_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": [ + "user_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": [ + "org_id", + "feature" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "accepted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": [ + "referrer_id", + "referred_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": [ + "fingerprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": [ + "pending", + "completed" + ] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": [ + "running", + "completed", + "failed", + "cancelled" + ] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": [ + "running", + "completed", + "skipped" + ] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": [ + "anthropic", + "gemini", + "openai" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "subscription", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": [ + "web", + "pat", + "cli" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index be421313ca..c2532c13f9 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -253,6 +253,13 @@ "when": 1768421756993, "tag": "0035_warm_orphan", "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1769568664455, + "tag": "0036_handy_silver_sable", + "breakpoints": true } ] -} +} \ No newline at end of file From c39155b6fbe6839b532b9fcaea3142898d530338 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 23:22:46 -0800 Subject: [PATCH 10/28] More reviewer improvments --- packages/billing/src/subscription-webhooks.ts | 2 +- packages/billing/src/subscription.ts | 124 ++++++++++++------ packages/internal/src/db/schema.ts | 2 +- web/src/app/api/stripe/webhook/route.ts | 61 +++++---- 4 files changed, 121 insertions(+), 68 deletions(-) diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index c1ba49903e..ca6c7328bb 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -21,7 +21,7 @@ type SubscriptionStatus = (typeof schema.subscriptionStatusEnum.enumValues)[numb function mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus { const validStatuses: readonly string[] = schema.subscriptionStatusEnum.enumValues if (validStatuses.includes(status)) return status as SubscriptionStatus - return 'active' + return 'incomplete' } // --------------------------------------------------------------------------- diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 59b8b95745..8fdde53d11 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -14,6 +14,7 @@ import { eq, gt, gte, + inArray, isNull, lt, or, @@ -284,7 +285,7 @@ export async function ensureActiveBlockGrant(params: { conn: tx, }) - if (weekly.used >= weekly.limit) { + if (weekly.remaining <= 0) { trackEvent({ event: AnalyticsEvent.SUBSCRIPTION_WEEKLY_LIMIT_HIT, userId, @@ -304,7 +305,8 @@ export async function ensureActiveBlockGrant(params: { } satisfies WeeklyLimitError } - // 4. Create new block grant + // 4. Create new block grant (capped to weekly remaining) + const blockCredits = Math.min(limits.creditsPerBlock, weekly.remaining) const expiresAt = addHours(now, limits.blockDurationHours) const operationId = `block-${subscriptionId}-${now.getTime()}` @@ -315,8 +317,8 @@ export async function ensureActiveBlockGrant(params: { user_id: userId, stripe_subscription_id: subscriptionId, type: 'subscription', - principal: limits.creditsPerBlock, - balance: limits.creditsPerBlock, + principal: blockCredits, + balance: blockCredits, priority: GRANT_PRIORITIES.subscription, expires_at: expiresAt, description: `${SUBSCRIPTION_DISPLAY_NAME} block (${limits.blockDurationHours}h)`, @@ -336,7 +338,7 @@ export async function ensureActiveBlockGrant(params: { properties: { subscriptionId, operationId, - credits: limits.creditsPerBlock, + credits: blockCredits, expiresAt: expiresAt.toISOString(), weeklyUsed: weekly.used, weeklyLimit: weekly.limit, @@ -349,7 +351,7 @@ export async function ensureActiveBlockGrant(params: { userId, subscriptionId, operationId, - credits: limits.creditsPerBlock, + credits: blockCredits, expiresAt, }, 'Created new subscription block grant', @@ -357,7 +359,7 @@ export async function ensureActiveBlockGrant(params: { return { grantId: newGrant.operation_id, - credits: limits.creditsPerBlock, + credits: blockCredits, expiresAt, isNew: true, } satisfies BlockGrant @@ -414,7 +416,7 @@ export async function checkRateLimit(params: { } } - // Find most recent subscription block grant for this user + // Find most recent active subscription block grant for this user const blocks = await db .select() .from(schema.creditLedger) @@ -422,6 +424,7 @@ export async function checkRateLimit(params: { and( eq(schema.creditLedger.user_id, userId), eq(schema.creditLedger.type, 'subscription'), + gt(schema.creditLedger.expires_at, now), ), ) .orderBy(desc(schema.creditLedger.created_at)) @@ -429,8 +432,8 @@ export async function checkRateLimit(params: { const currentBlock = blocks[0] - // No block yet or block expired → can start a new one - if (!currentBlock || !currentBlock.expires_at || currentBlock.expires_at <= now) { + // No active block → can start a new one + if (!currentBlock) { return { limited: false, canStartNewBlock: true, @@ -449,7 +452,7 @@ export async function checkRateLimit(params: { canStartNewBlock: false, blockUsed: currentBlock.principal, blockLimit: currentBlock.principal, - blockResetsAt: currentBlock.expires_at, + blockResetsAt: currentBlock.expires_at!, weeklyUsed: weekly.used, weeklyLimit: weekly.limit, weeklyResetsAt: weekly.resetsAt, @@ -463,7 +466,7 @@ export async function checkRateLimit(params: { canStartNewBlock: false, blockUsed: currentBlock.principal - currentBlock.balance, blockLimit: currentBlock.principal, - blockResetsAt: currentBlock.expires_at, + blockResetsAt: currentBlock.expires_at!, weeklyUsed: weekly.used, weeklyLimit: weekly.limit, weeklyResetsAt: weekly.resetsAt, @@ -523,22 +526,25 @@ export async function handleSubscribe(params: { const { userId, stripeSubscription, logger } = params const newResetDate = new Date(stripeSubscription.current_period_end * 1000) - await withAdvisoryLockTransaction({ + const { result: didMigrate } = await withAdvisoryLockTransaction({ callback: async (tx) => { - // Idempotency: skip if this subscription was already processed - // Must be inside the lock to prevent TOCTOU races on concurrent webhooks - const existing = await tx - .select({ stripe_subscription_id: schema.subscription.stripe_subscription_id }) - .from(schema.subscription) - .where(eq(schema.subscription.stripe_subscription_id, stripeSubscription.id)) + // Idempotency: check if credits were already migrated for this subscription. + // We use the credit_ledger instead of the subscription table because + // handleSubscriptionUpdated may upsert the subscription row before + // invoice.paid fires, which would cause this check to skip migration. + const migrationOpId = `subscribe-migrate-${stripeSubscription.id}` + const existingMigration = await tx + .select({ operation_id: schema.creditLedger.operation_id }) + .from(schema.creditLedger) + .where(eq(schema.creditLedger.operation_id, migrationOpId)) .limit(1) - if (existing.length > 0) { + if (existingMigration.length > 0) { logger.info( { userId, subscriptionId: stripeSubscription.id }, - 'Subscription already processed — skipping handleSubscribe', + 'Credits already migrated — skipping handleSubscribe', ) - return + return false } // Move next_quota_reset to align with Stripe billing period @@ -548,31 +554,41 @@ export async function handleSubscribe(params: { .where(eq(schema.user.id, userId)) // Migrate unused credits so nothing is lost - await migrateUnusedCredits({ tx, userId, expiresAt: newResetDate, logger }) + await migrateUnusedCredits({ + tx, + userId, + subscriptionId: stripeSubscription.id, + expiresAt: newResetDate, + logger, + }) + + return true }, lockKey: `user:${userId}`, context: { userId, subscriptionId: stripeSubscription.id }, logger, }) - trackEvent({ - event: AnalyticsEvent.SUBSCRIPTION_CREATED, - userId, - properties: { - subscriptionId: stripeSubscription.id, - newResetDate: newResetDate.toISOString(), - }, - logger, - }) - - logger.info( - { + if (didMigrate) { + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_CREATED, userId, - subscriptionId: stripeSubscription.id, - newResetDate, - }, - 'Processed subscribe: reset date moved and credits migrated', - ) + properties: { + subscriptionId: stripeSubscription.id, + newResetDate: newResetDate.toISOString(), + }, + logger, + }) + + logger.info( + { + userId, + subscriptionId: stripeSubscription.id, + newResetDate, + }, + 'Processed subscribe: reset date moved and credits migrated', + ) + } } // --------------------------------------------------------------------------- @@ -592,13 +608,14 @@ type DbTransaction = Parameters[0] extends ( async function migrateUnusedCredits(params: { tx: DbTransaction userId: string + subscriptionId: string expiresAt: Date logger: Logger }): Promise { - const { tx, userId, expiresAt, logger } = params + const { tx, userId, subscriptionId, expiresAt, logger } = params const now = new Date() - // Find all free/referral grants with remaining balance + // Find all free/referral grants with remaining balance (excluding org grants) const unusedGrants = await tx .select() .from(schema.creditLedger) @@ -606,6 +623,8 @@ async function migrateUnusedCredits(params: { and( eq(schema.creditLedger.user_id, userId), gt(schema.creditLedger.balance, 0), + inArray(schema.creditLedger.type, ['free', 'referral']), + isNull(schema.creditLedger.org_id), or( isNull(schema.creditLedger.expires_at), gt(schema.creditLedger.expires_at, now), @@ -618,7 +637,27 @@ async function migrateUnusedCredits(params: { 0, ) + // Deterministic ID ensures idempotency — duplicate webhook deliveries + // will hit onConflictDoNothing and the handleSubscribe caller checks + // for this operation_id before running. + const operationId = `subscribe-migrate-${subscriptionId}` + if (totalUnused === 0) { + // Still insert the marker for idempotency so handleSubscribe's check + // short-circuits on duplicate webhook deliveries. + await tx + .insert(schema.creditLedger) + .values({ + operation_id: operationId, + user_id: userId, + type: 'free', + principal: 0, + balance: 0, + priority: GRANT_PRIORITIES.free, + expires_at: expiresAt, + description: 'Migrated credits from subscription transition', + }) + .onConflictDoNothing({ target: schema.creditLedger.operation_id }) logger.debug({ userId }, 'No unused credits to migrate') return } @@ -632,7 +671,6 @@ async function migrateUnusedCredits(params: { } // Create a single migration grant preserving the total - const operationId = `migration-${userId}-${crypto.randomUUID()}` await tx .insert(schema.creditLedger) .values({ diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 9a54d70ae3..d4e60187c6 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -145,7 +145,7 @@ export const creditLedger = pgTable( .where(sql`${table.balance} != 0 AND ${table.expires_at} IS NULL`), index('idx_credit_ledger_org').on(table.org_id), index('idx_credit_ledger_subscription').on( - table.stripe_subscription_id, + table.user_id, table.type, table.created_at, ), diff --git a/web/src/app/api/stripe/webhook/route.ts b/web/src/app/api/stripe/webhook/route.ts index 401ba49b73..da0b4a7700 100644 --- a/web/src/app/api/stripe/webhook/route.ts +++ b/web/src/app/api/stripe/webhook/route.ts @@ -27,23 +27,17 @@ import { getStripeCustomerId } from '@/lib/stripe-utils' import { logger } from '@/util/logger' /** - * Extracts a string ID from a Stripe object that may be a string or an - * expanded object with an `id` field. + * Checks whether a Stripe customer ID belongs to an organization. + * + * Uses `org.stripe_customer_id` which is set at org creation time, making it + * reliable regardless of webhook ordering (unlike `stripe_subscription_id` + * which may not be populated yet when early invoice events arrive). */ -function getStripeId(obj: string | { id: string }): string { - return typeof obj === 'string' ? obj : obj.id -} - -/** - * Checks whether a Stripe subscription ID belongs to an organization. - * Used to guard user-subscription handlers from processing org subscriptions - * on invoice events (where subscription metadata isn't directly available). - */ -async function isOrgSubscription(subscriptionId: string): Promise { +async function isOrgCustomer(stripeCustomerId: string): Promise { const orgs = await db .select({ id: schema.org.id }) .from(schema.org) - .where(eq(schema.org.stripe_subscription_id, subscriptionId)) + .where(eq(schema.org.stripe_customer_id, stripeCustomerId)) .limit(1) return orgs.length > 0 } @@ -246,9 +240,15 @@ async function handleCheckoutSessionCompleted( } } -async function handleSubscriptionEvent(subscription: Stripe.Subscription) { +async function handleOrganizationSubscriptionEvent(subscription: Stripe.Subscription) { const organizationId = subscription.metadata?.organization_id - if (!organizationId) return + if (!organizationId) { + logger.warn( + { subscriptionId: subscription.id }, + 'Organization subscription event missing organization_id metadata', + ) + return + } logger.info( { @@ -376,7 +376,7 @@ const webhookHandler = async (req: NextRequest): Promise => { case 'customer.subscription.updated': { const sub = event.data.object as Stripe.Subscription if (sub.metadata?.organization_id) { - await handleSubscriptionEvent(sub) + await handleOrganizationSubscriptionEvent(sub) } else { await handleSubscriptionUpdated({ stripeSubscription: sub, logger }) } @@ -385,7 +385,7 @@ const webhookHandler = async (req: NextRequest): Promise => { case 'customer.subscription.deleted': { const sub = event.data.object as Stripe.Subscription if (sub.metadata?.organization_id) { - await handleSubscriptionEvent(sub) + await handleOrganizationSubscriptionEvent(sub) } else { await handleSubscriptionDeleted({ stripeSubscription: sub, logger }) } @@ -544,20 +544,35 @@ const webhookHandler = async (req: NextRequest): Promise => { } case 'invoice.paid': { const invoice = event.data.object as Stripe.Invoice - await handleInvoicePaid(invoice) if (invoice.subscription) { - const subId = getStripeId(invoice.subscription) - if (!(await isOrgSubscription(subId))) { + const customerId = invoice.customer + ? getStripeCustomerId(invoice.customer) + : null + if (!customerId) { + logger.warn( + { invoiceId: invoice.id }, + 'Subscription invoice has no customer — skipping', + ) + } else if (!(await isOrgCustomer(customerId))) { await handleSubscriptionInvoicePaid({ invoice, logger }) } + } else { + await handleInvoicePaid(invoice) } break } case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice if (invoice.subscription) { - const subId = getStripeId(invoice.subscription) - if (!(await isOrgSubscription(subId))) { + const customerId = invoice.customer + ? getStripeCustomerId(invoice.customer) + : null + if (!customerId) { + logger.warn( + { invoiceId: invoice.id }, + 'Subscription invoice has no customer — skipping', + ) + } else if (!(await isOrgCustomer(customerId))) { await handleSubscriptionInvoicePaymentFailed({ invoice, logger }) } } @@ -591,7 +606,7 @@ const webhookHandler = async (req: NextRequest): Promise => { break } default: - console.log(`Unhandled event type ${event.type}`) + logger.debug({ type: event.type }, 'Unhandled Stripe event type') } return NextResponse.json({ received: true }) } catch (err) { From cba210d14e2de4c1d3ef4d5728c6c9fe0a2bf094 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 11:57:40 -0800 Subject: [PATCH 11/28] Update migrateUnusedCredits query --- packages/billing/src/subscription.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 8fdde53d11..5f488b1e89 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -14,10 +14,10 @@ import { eq, gt, gte, - inArray, isNull, lt, - or, + lte, + ne, sql, } from 'drizzle-orm' @@ -602,8 +602,9 @@ type DbTransaction = Parameters[0] extends ( : never /** - * Migrates unused free & referral credits into a single grant that expires - * at `expiresAt`. The old grants have their balance zeroed. + * Migrates unused credits (any type with a non-null expires_at in the future) + * into a single grant that expires at `expiresAt`. The old grants have their + * balance zeroed. */ async function migrateUnusedCredits(params: { tx: DbTransaction @@ -615,7 +616,6 @@ async function migrateUnusedCredits(params: { const { tx, userId, subscriptionId, expiresAt, logger } = params const now = new Date() - // Find all free/referral grants with remaining balance (excluding org grants) const unusedGrants = await tx .select() .from(schema.creditLedger) @@ -623,12 +623,10 @@ async function migrateUnusedCredits(params: { and( eq(schema.creditLedger.user_id, userId), gt(schema.creditLedger.balance, 0), - inArray(schema.creditLedger.type, ['free', 'referral']), + ne(schema.creditLedger.type, 'subscription'), isNull(schema.creditLedger.org_id), - or( - isNull(schema.creditLedger.expires_at), - gt(schema.creditLedger.expires_at, now), - ), + gt(schema.creditLedger.expires_at, now), + lte(schema.creditLedger.expires_at, expiresAt), ), ) From 40a0b2e9f8a856849e799bef9036520e7edd58ac Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 12:02:13 -0800 Subject: [PATCH 12/28] Rename Flex to Strong --- common/src/constants/subscription-plans.ts | 2 +- web/src/app/profile/components/usage-display.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/constants/subscription-plans.ts b/common/src/constants/subscription-plans.ts index e1d39a24a0..71a9489cba 100644 --- a/common/src/constants/subscription-plans.ts +++ b/common/src/constants/subscription-plans.ts @@ -1,4 +1,4 @@ -export const SUBSCRIPTION_DISPLAY_NAME = 'Flex' as const +export const SUBSCRIPTION_DISPLAY_NAME = 'Strong' as const export interface TierConfig { monthlyPrice: number diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index 61d5a95ac4..772a3c45c5 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -58,8 +58,8 @@ const grantTypeInfo: Record< text: 'text-indigo-600 dark:text-indigo-400', gradient: 'from-indigo-500/70 to-indigo-600/70', icon: , - label: 'Flex', - description: 'Credits from your Flex subscription', + label: 'Strong', + description: 'Credits from your Strong subscription', }, referral: { bg: 'bg-green-500', From 76f71c4e7f89da624b7f94b5f51800c04bed24f8 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 13:59:14 -0800 Subject: [PATCH 13/28] Add subscription tiers. Extract util getStripeId --- .env.example | 2 + common/src/constants/subscription-plans.ts | 14 + packages/billing/src/subscription-webhooks.ts | 48 +- packages/billing/src/subscription.ts | 19 +- .../migrations/0037_many_millenium_guard.sql | 3 + .../src/db/migrations/meta/0037_snapshot.json | 3057 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + packages/internal/src/db/schema.ts | 1 + packages/internal/src/env-schema.ts | 4 + packages/internal/src/util/stripe.ts | 10 + web/src/app/api/stripe/webhook/route.ts | 22 +- web/src/lib/stripe-utils.ts | 6 - 12 files changed, 3142 insertions(+), 51 deletions(-) create mode 100644 packages/internal/src/db/migrations/0037_many_millenium_guard.sql create mode 100644 packages/internal/src/db/migrations/meta/0037_snapshot.json diff --git a/.env.example b/.env.example index 744b796236..8f81f4a5ff 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,9 @@ STRIPE_SECRET_KEY=sk_test_dummy_stripe_secret STRIPE_WEBHOOK_SECRET_KEY=whsec_dummy_webhook_secret STRIPE_USAGE_PRICE_ID=price_dummy_usage_id STRIPE_TEAM_FEE_PRICE_ID=price_dummy_team_fee_id +STRIPE_SUBSCRIPTION_100_PRICE_ID=price_dummy_subscription_100_id STRIPE_SUBSCRIPTION_200_PRICE_ID=price_dummy_subscription_200_id +STRIPE_SUBSCRIPTION_500_PRICE_ID=price_dummy_subscription_500_id # External Services LINKUP_API_KEY=dummy_linkup_key diff --git a/common/src/constants/subscription-plans.ts b/common/src/constants/subscription-plans.ts index 71a9489cba..16c7555236 100644 --- a/common/src/constants/subscription-plans.ts +++ b/common/src/constants/subscription-plans.ts @@ -8,12 +8,26 @@ export interface TierConfig { } export const SUBSCRIPTION_TIERS = { + 100: { + monthlyPrice: 100, + creditsPerBlock: 400, + blockDurationHours: 5, + weeklyCreditsLimit: 4000, + }, 200: { monthlyPrice: 200, creditsPerBlock: 1250, blockDurationHours: 5, weeklyCreditsLimit: 12500, }, + 500: { + monthlyPrice: 500, + creditsPerBlock: 3125, + blockDurationHours: 5, + weeklyCreditsLimit: 31250, + }, } as const satisfies Record +export type SubscriptionTierPrice = keyof typeof SUBSCRIPTION_TIERS + export const DEFAULT_TIER = SUBSCRIPTION_TIERS[200] diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index ca6c7328bb..0640215ae3 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -2,7 +2,9 @@ import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' +import { env } from '@codebuff/internal/env' import { + getStripeId, getUserByStripeCustomerId, stripeServer, } from '@codebuff/internal/util/stripe' @@ -10,6 +12,7 @@ import { eq } from 'drizzle-orm' import { handleSubscribe } from './subscription' +import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans' import type { Logger } from '@codebuff/common/types/contracts/logger' import type Stripe from 'stripe' @@ -24,6 +27,16 @@ function mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus return 'incomplete' } +const priceToTier: Record = { + ...(env.STRIPE_SUBSCRIPTION_100_PRICE_ID && { [env.STRIPE_SUBSCRIPTION_100_PRICE_ID]: 100 as const }), + ...(env.STRIPE_SUBSCRIPTION_200_PRICE_ID && { [env.STRIPE_SUBSCRIPTION_200_PRICE_ID]: 200 as const }), + ...(env.STRIPE_SUBSCRIPTION_500_PRICE_ID && { [env.STRIPE_SUBSCRIPTION_500_PRICE_ID]: 500 as const }), +} + +function getTierFromPriceId(priceId: string): SubscriptionTierPrice | null { + return priceToTier[priceId] ?? null +} + // --------------------------------------------------------------------------- // invoice.paid // --------------------------------------------------------------------------- @@ -43,14 +56,8 @@ export async function handleSubscriptionInvoicePaid(params: { const { invoice, logger } = params if (!invoice.subscription) return - const subscriptionId = - typeof invoice.subscription === 'string' - ? invoice.subscription - : invoice.subscription.id - const customerId = - typeof invoice.customer === 'string' - ? invoice.customer - : invoice.customer?.id + const subscriptionId = getStripeId(invoice.subscription) + const customerId = getStripeId(invoice.customer) if (!customerId) { logger.warn( @@ -97,6 +104,7 @@ export async function handleSubscriptionInvoicePaid(params: { stripe_customer_id: customerId, user_id: userId, stripe_price_id: priceId, + tier: getTierFromPriceId(priceId), status: 'active', billing_period_start: new Date(stripeSub.current_period_start * 1000), billing_period_end: new Date(stripeSub.current_period_end * 1000), @@ -108,6 +116,7 @@ export async function handleSubscriptionInvoicePaid(params: { status: 'active', ...(userId ? { user_id: userId } : {}), stripe_price_id: priceId, + tier: getTierFromPriceId(priceId), billing_period_start: new Date( stripeSub.current_period_start * 1000, ), @@ -142,15 +151,8 @@ export async function handleSubscriptionInvoicePaymentFailed(params: { const { invoice, logger } = params if (!invoice.subscription) return - const subscriptionId = - typeof invoice.subscription === 'string' - ? invoice.subscription - : invoice.subscription.id - - const customerId = - typeof invoice.customer === 'string' - ? invoice.customer - : invoice.customer?.id + const subscriptionId = getStripeId(invoice.subscription) + const customerId = getStripeId(invoice.customer) const userId = customerId ? (await getUserByStripeCustomerId(customerId))?.id ?? null : null @@ -199,10 +201,7 @@ export async function handleSubscriptionUpdated(params: { return } - const customerId = - typeof stripeSubscription.customer === 'string' - ? stripeSubscription.customer - : stripeSubscription.customer.id + const customerId = getStripeId(stripeSubscription.customer) const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null const status = mapStripeStatus(stripeSubscription.status) @@ -216,6 +215,7 @@ export async function handleSubscriptionUpdated(params: { stripe_customer_id: customerId, user_id: userId, stripe_price_id: priceId, + tier: getTierFromPriceId(priceId), status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( @@ -230,6 +230,7 @@ export async function handleSubscriptionUpdated(params: { set: { ...(userId ? { user_id: userId } : {}), stripe_price_id: priceId, + tier: getTierFromPriceId(priceId), status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( @@ -265,10 +266,7 @@ export async function handleSubscriptionDeleted(params: { const { stripeSubscription, logger } = params const subscriptionId = stripeSubscription.id - const customerId = - typeof stripeSubscription.customer === 'string' - ? stripeSubscription.customer - : stripeSubscription.customer.id + const customerId = getStripeId(stripeSubscription.customer) const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null await db diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 5f488b1e89..483f5258bb 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -4,7 +4,10 @@ import { GRANT_PRIORITIES } from '@codebuff/common/constants/grant-priorities' import { DEFAULT_TIER, SUBSCRIPTION_DISPLAY_NAME, + SUBSCRIPTION_TIERS, } from '@codebuff/common/constants/subscription-plans' + +import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' import { withAdvisoryLockTransaction } from '@codebuff/internal/db/transaction' @@ -137,8 +140,9 @@ export async function getSubscriptionLimits(params: { userId: string logger: Logger conn?: DbConn + tier?: number | null }): Promise { - const { userId, logger, conn = db } = params + const { userId, logger, conn = db, tier } = params const overrides = await conn .select() @@ -159,10 +163,15 @@ export async function getSubscriptionLimits(params: { } } + const tierConfig = + tier != null && tier in SUBSCRIPTION_TIERS + ? SUBSCRIPTION_TIERS[tier as SubscriptionTierPrice] + : DEFAULT_TIER + return { - creditsPerBlock: DEFAULT_TIER.creditsPerBlock, - blockDurationHours: DEFAULT_TIER.blockDurationHours, - weeklyCreditsLimit: DEFAULT_TIER.weeklyCreditsLimit, + creditsPerBlock: tierConfig.creditsPerBlock, + blockDurationHours: tierConfig.blockDurationHours, + weeklyCreditsLimit: tierConfig.weeklyCreditsLimit, } } @@ -274,6 +283,7 @@ export async function ensureActiveBlockGrant(params: { userId, logger, conn: tx, + tier: subscription.tier, }) // 3. Check weekly limit before creating a new block @@ -394,6 +404,7 @@ export async function checkRateLimit(params: { const limits = await getSubscriptionLimits({ userId, logger, + tier: subscription.tier, }) const weekly = await getWeeklyUsage({ diff --git a/packages/internal/src/db/migrations/0037_many_millenium_guard.sql b/packages/internal/src/db/migrations/0037_many_millenium_guard.sql new file mode 100644 index 0000000000..ff1bbcd012 --- /dev/null +++ b/packages/internal/src/db/migrations/0037_many_millenium_guard.sql @@ -0,0 +1,3 @@ +DROP INDEX "idx_credit_ledger_subscription";--> statement-breakpoint +ALTER TABLE "subscription" ADD COLUMN "tier" integer;--> statement-breakpoint +CREATE INDEX "idx_credit_ledger_subscription" ON "credit_ledger" USING btree ("user_id","type","created_at"); \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0037_snapshot.json b/packages/internal/src/db/migrations/meta/0037_snapshot.json new file mode 100644 index 0000000000..c208096683 --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0037_snapshot.json @@ -0,0 +1,3057 @@ +{ + "id": "98d944a6-d8c5-41c6-a491-dc70211eca98", + "prevId": "14a00b85-f71c-42bf-911c-44fc725de438", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": [ + "imp_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": [ + "publisher_id", + "id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": [ + "agent_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": [ + "user_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": [ + "org_id", + "feature" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "accepted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": [ + "referrer_id", + "referred_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": [ + "fingerprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": [ + "pending", + "completed" + ] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": [ + "running", + "completed", + "failed", + "cancelled" + ] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": [ + "running", + "completed", + "skipped" + ] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": [ + "anthropic", + "gemini", + "openai" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "subscription", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": [ + "web", + "pat", + "cli" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index c2532c13f9..9bc22ce110 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1769568664455, "tag": "0036_handy_silver_sable", "breakpoints": true + }, + { + "idx": 37, + "version": "7", + "when": 1769637004165, + "tag": "0037_many_millenium_guard", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index d4e60187c6..693ff37be6 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -467,6 +467,7 @@ export const subscription = pgTable( stripe_customer_id: text('stripe_customer_id').notNull(), user_id: text('user_id').references(() => user.id, { onDelete: 'cascade' }), stripe_price_id: text('stripe_price_id').notNull(), + tier: integer('tier'), status: subscriptionStatusEnum('status').notNull().default('active'), billing_period_start: timestamp('billing_period_start', { mode: 'date', diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 2aca742fe5..0ab554af7e 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -21,7 +21,9 @@ export const serverEnvSchema = clientEnvSchema.extend({ STRIPE_WEBHOOK_SECRET_KEY: z.string().min(1), STRIPE_USAGE_PRICE_ID: z.string().min(1), STRIPE_TEAM_FEE_PRICE_ID: z.string().min(1), + STRIPE_SUBSCRIPTION_100_PRICE_ID: z.string().min(1).optional(), STRIPE_SUBSCRIPTION_200_PRICE_ID: z.string().min(1).optional(), + STRIPE_SUBSCRIPTION_500_PRICE_ID: z.string().min(1).optional(), LOOPS_API_KEY: z.string().min(1), DISCORD_PUBLIC_KEY: z.string().min(1), DISCORD_BOT_TOKEN: z.string().min(1), @@ -62,7 +64,9 @@ export const serverProcessEnv: ServerInput = { STRIPE_WEBHOOK_SECRET_KEY: process.env.STRIPE_WEBHOOK_SECRET_KEY, STRIPE_USAGE_PRICE_ID: process.env.STRIPE_USAGE_PRICE_ID, STRIPE_TEAM_FEE_PRICE_ID: process.env.STRIPE_TEAM_FEE_PRICE_ID, + STRIPE_SUBSCRIPTION_100_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_100_PRICE_ID, STRIPE_SUBSCRIPTION_200_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_200_PRICE_ID, + STRIPE_SUBSCRIPTION_500_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_500_PRICE_ID, LOOPS_API_KEY: process.env.LOOPS_API_KEY, DISCORD_PUBLIC_KEY: process.env.DISCORD_PUBLIC_KEY, DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN, diff --git a/packages/internal/src/util/stripe.ts b/packages/internal/src/util/stripe.ts index 29517cb64e..971d1bb1f5 100644 --- a/packages/internal/src/util/stripe.ts +++ b/packages/internal/src/util/stripe.ts @@ -4,6 +4,16 @@ import { env } from '@codebuff/internal/env' import { eq } from 'drizzle-orm' import Stripe from 'stripe' +/** + * Extracts the ID string from a Stripe expandable field. + */ +export function getStripeId(expandable: string | { id: string }): string +export function getStripeId(expandable: string | { id: string } | null | undefined): string | undefined +export function getStripeId(expandable: string | { id: string } | null | undefined): string | undefined { + if (expandable == null) return undefined + return typeof expandable === 'string' ? expandable : expandable.id +} + export const stripeServer = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2024-06-20', typescript: true, diff --git a/web/src/app/api/stripe/webhook/route.ts b/web/src/app/api/stripe/webhook/route.ts index da0b4a7700..edb8208d5f 100644 --- a/web/src/app/api/stripe/webhook/route.ts +++ b/web/src/app/api/stripe/webhook/route.ts @@ -11,7 +11,7 @@ import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' import { env } from '@codebuff/internal/env' import { sendDisputeNotificationEmail } from '@codebuff/internal/loops' -import { stripeServer } from '@codebuff/internal/util/stripe' +import { getStripeId, stripeServer } from '@codebuff/internal/util/stripe' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' @@ -23,7 +23,6 @@ import { evaluateBanConditions, getUserByStripeCustomerId, } from '@/lib/ban-conditions' -import { getStripeCustomerId } from '@/lib/stripe-utils' import { logger } from '@/util/logger' /** @@ -320,7 +319,7 @@ async function handleInvoicePaid(invoice: Stripe.Invoice) { let customerId: string | null = null if (invoice.customer) { - customerId = getStripeCustomerId(invoice.customer) + customerId = getStripeId(invoice.customer) } if (creditNotes.data.length > 0) { @@ -393,10 +392,7 @@ const webhookHandler = async (req: NextRequest): Promise => { } case 'charge.dispute.created': { const dispute = event.data.object as Stripe.Dispute - const chargeId = - typeof dispute.charge === 'string' - ? dispute.charge - : dispute.charge?.id + const chargeId = getStripeId(dispute.charge) if (!chargeId) { logger.warn( @@ -416,9 +412,7 @@ const webhookHandler = async (req: NextRequest): Promise => { break } - const customerId = getStripeCustomerId( - charge.customer as string | Stripe.Customer | Stripe.DeletedCustomer, - ) + const customerId = getStripeId(charge.customer) if (!customerId) { logger.warn( @@ -545,9 +539,7 @@ const webhookHandler = async (req: NextRequest): Promise => { case 'invoice.paid': { const invoice = event.data.object as Stripe.Invoice if (invoice.subscription) { - const customerId = invoice.customer - ? getStripeCustomerId(invoice.customer) - : null + const customerId = getStripeId(invoice.customer) if (!customerId) { logger.warn( { invoiceId: invoice.id }, @@ -564,9 +556,7 @@ const webhookHandler = async (req: NextRequest): Promise => { case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice if (invoice.subscription) { - const customerId = invoice.customer - ? getStripeCustomerId(invoice.customer) - : null + const customerId = getStripeId(invoice.customer) if (!customerId) { logger.warn( { invoiceId: invoice.id }, diff --git a/web/src/lib/stripe-utils.ts b/web/src/lib/stripe-utils.ts index b3cf9ecb77..319e848da8 100644 --- a/web/src/lib/stripe-utils.ts +++ b/web/src/lib/stripe-utils.ts @@ -4,12 +4,6 @@ import { eq, or, sql } from 'drizzle-orm' import type Stripe from 'stripe' -export function getStripeCustomerId( - customer: string | Stripe.Customer | Stripe.DeletedCustomer, -): string { - return typeof customer === 'string' ? customer : customer.id -} - export function getSubscriptionItemByType( subscription: Stripe.Subscription, usageType: 'licensed' | 'metered', From 9184aa289c5ed4d18bf09be3e7c0271016d5f500 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 14:44:11 -0800 Subject: [PATCH 14/28] Web routes to cancel, change tier, create subscription, or get subscription info --- common/src/constants/analytics-events.ts | 1 + packages/billing/src/subscription-webhooks.ts | 8 + packages/billing/src/subscription.ts | 35 ++++ packages/billing/src/usage-service.ts | 20 +++ packages/internal/src/env-schema.ts | 6 +- .../api/stripe/cancel-subscription/route.ts | 60 +++++++ .../stripe/change-subscription-tier/route.ts | 161 ++++++++++++++++++ .../api/stripe/create-subscription/route.ts | 111 ++++++++++++ web/src/app/api/user/subscription/route.ts | 55 ++++++ 9 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 web/src/app/api/stripe/cancel-subscription/route.ts create mode 100644 web/src/app/api/stripe/change-subscription-tier/route.ts create mode 100644 web/src/app/api/stripe/create-subscription/route.ts create mode 100644 web/src/app/api/user/subscription/route.ts diff --git a/common/src/constants/analytics-events.ts b/common/src/constants/analytics-events.ts index 6f3bfe856a..a3d05e2ae0 100644 --- a/common/src/constants/analytics-events.ts +++ b/common/src/constants/analytics-events.ts @@ -38,6 +38,7 @@ export enum AnalyticsEvent { SUBSCRIPTION_BLOCK_LIMIT_HIT = 'backend.subscription_block_limit_hit', SUBSCRIPTION_WEEKLY_LIMIT_HIT = 'backend.subscription_weekly_limit_hit', SUBSCRIPTION_CREDITS_MIGRATED = 'backend.subscription_credits_migrated', + SUBSCRIPTION_TIER_CHANGED = 'backend.subscription_tier_changed', // Web SIGNUP = 'web.signup', diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 0640215ae3..8450a4c47c 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -37,6 +37,14 @@ function getTierFromPriceId(priceId: string): SubscriptionTierPrice | null { return priceToTier[priceId] ?? null } +const tierToPrice = Object.fromEntries( + Object.entries(priceToTier).map(([priceId, tier]) => [tier, priceId]), +) as Partial> + +export function getTierPriceId(tier: SubscriptionTierPrice): string | null { + return tierToPrice[tier] ?? null +} + // --------------------------------------------------------------------------- // invoice.paid // --------------------------------------------------------------------------- diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 483f5258bb..d78e2eebf3 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -485,6 +485,41 @@ export async function checkRateLimit(params: { } } +// --------------------------------------------------------------------------- +// Block grant expiration +// --------------------------------------------------------------------------- + +export async function expireActiveBlockGrants(params: { + userId: string + subscriptionId: string + logger: Logger +}): Promise { + const { userId, subscriptionId, logger } = params + const now = new Date() + + const expired = await db + .update(schema.creditLedger) + .set({ balance: 0, expires_at: now }) + .where( + and( + eq(schema.creditLedger.user_id, userId), + eq(schema.creditLedger.type, 'subscription'), + gt(schema.creditLedger.expires_at, now), + gt(schema.creditLedger.balance, 0), + ), + ) + .returning({ operation_id: schema.creditLedger.operation_id }) + + if (expired.length > 0) { + logger.info( + { userId, subscriptionId, expiredCount: expired.length }, + 'Expired active block grants for tier change', + ) + } + + return expired.length +} + // --------------------------------------------------------------------------- // Subscription lookup // --------------------------------------------------------------------------- diff --git a/packages/billing/src/usage-service.ts b/packages/billing/src/usage-service.ts index 04bc659a6d..80b6f41fe8 100644 --- a/packages/billing/src/usage-service.ts +++ b/packages/billing/src/usage-service.ts @@ -9,16 +9,24 @@ import { calculateOrganizationUsageAndBalance, syncOrganizationBillingCycle, } from './org-billing' +import { getActiveSubscription } from './subscription' import type { CreditBalance } from './balance-calculator' import type { Logger } from '@codebuff/common/types/contracts/logger' +export interface SubscriptionInfo { + status: string + billingPeriodEnd: string + cancelAtPeriodEnd: boolean +} + export interface UserUsageData { usageThisCycle: number balance: CreditBalance nextQuotaReset: string autoTopupTriggered?: boolean autoTopupEnabled?: boolean + subscription?: SubscriptionInfo } export interface OrganizationUsageData { @@ -79,12 +87,24 @@ export async function getUserUsageData(params: { isPersonalContext: true, // isPersonalContext: true to exclude organization credits }) + // Check for active subscription + let subscription: SubscriptionInfo | undefined + const activeSub = await getActiveSubscription({ userId, logger }) + if (activeSub) { + subscription = { + status: activeSub.status, + billingPeriodEnd: activeSub.billing_period_end.toISOString(), + cancelAtPeriodEnd: activeSub.cancel_at_period_end, + } + } + return { usageThisCycle, balance, nextQuotaReset: quotaResetDate.toISOString(), autoTopupTriggered, autoTopupEnabled, + subscription, } } catch (error) { logger.error({ userId, error }, 'Error fetching user usage data') diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 0ab554af7e..042b7e4d24 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -21,9 +21,9 @@ export const serverEnvSchema = clientEnvSchema.extend({ STRIPE_WEBHOOK_SECRET_KEY: z.string().min(1), STRIPE_USAGE_PRICE_ID: z.string().min(1), STRIPE_TEAM_FEE_PRICE_ID: z.string().min(1), - STRIPE_SUBSCRIPTION_100_PRICE_ID: z.string().min(1).optional(), - STRIPE_SUBSCRIPTION_200_PRICE_ID: z.string().min(1).optional(), - STRIPE_SUBSCRIPTION_500_PRICE_ID: z.string().min(1).optional(), + STRIPE_SUBSCRIPTION_100_PRICE_ID: z.string().min(1), + STRIPE_SUBSCRIPTION_200_PRICE_ID: z.string().min(1), + STRIPE_SUBSCRIPTION_500_PRICE_ID: z.string().min(1), LOOPS_API_KEY: z.string().min(1), DISCORD_PUBLIC_KEY: z.string().min(1), DISCORD_BOT_TOKEN: z.string().min(1), diff --git a/web/src/app/api/stripe/cancel-subscription/route.ts b/web/src/app/api/stripe/cancel-subscription/route.ts new file mode 100644 index 0000000000..a34f5312a9 --- /dev/null +++ b/web/src/app/api/stripe/cancel-subscription/route.ts @@ -0,0 +1,60 @@ +import { getActiveSubscription } from '@codebuff/billing' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +export async function POST() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const subscription = await getActiveSubscription({ userId, logger }) + if (!subscription) { + return NextResponse.json( + { error: 'No active subscription found.' }, + { status: 404 }, + ) + } + + try { + await stripeServer.subscriptions.update( + subscription.stripe_subscription_id, + { cancel_at_period_end: true }, + ) + + await db + .update(schema.subscription) + .set({ cancel_at_period_end: true, updated_at: new Date() }) + .where( + eq( + schema.subscription.stripe_subscription_id, + subscription.stripe_subscription_id, + ), + ) + + logger.info( + { userId, subscriptionId: subscription.stripe_subscription_id }, + 'Subscription set to cancel at period end', + ) + + return NextResponse.json({ success: true }) + } catch (error: unknown) { + const message = + (error as { raw?: { message?: string } })?.raw?.message || + 'Internal server error canceling subscription.' + logger.error( + { error: message, userId, subscriptionId: subscription.stripe_subscription_id }, + 'Failed to cancel subscription', + ) + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/web/src/app/api/stripe/change-subscription-tier/route.ts b/web/src/app/api/stripe/change-subscription-tier/route.ts new file mode 100644 index 0000000000..77b73ebf4c --- /dev/null +++ b/web/src/app/api/stripe/change-subscription-tier/route.ts @@ -0,0 +1,161 @@ +import { + expireActiveBlockGrants, + getActiveSubscription, + getTierPriceId, +} from '@codebuff/billing' +import { trackEvent } from '@codebuff/common/analytics' +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { SUBSCRIPTION_TIERS } from '@codebuff/common/constants/subscription-plans' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans' +import type { NextRequest } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const user = await db.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { banned: true }, + }) + + if (user?.banned) { + logger.warn({ userId }, 'Banned user attempted to change subscription tier') + return NextResponse.json( + { error: 'Your account has been suspended. Please contact support.' }, + { status: 403 }, + ) + } + + const body = await req.json().catch(() => null) + const rawTier = Number(body?.tier) + if (!rawTier || !(rawTier in SUBSCRIPTION_TIERS)) { + return NextResponse.json( + { error: 'Invalid tier. Must be 100, 200, or 500.' }, + { status: 400 }, + ) + } + const tier = rawTier as SubscriptionTierPrice + + const subscription = await getActiveSubscription({ userId, logger }) + if (!subscription) { + return NextResponse.json( + { error: 'No active subscription found.' }, + { status: 404 }, + ) + } + + const previousTier = subscription.tier + if (previousTier === tier) { + return NextResponse.json( + { error: 'Already on the requested tier.' }, + { status: 400 }, + ) + } + + const newPriceId = getTierPriceId(tier) + if (!newPriceId) { + return NextResponse.json( + { error: 'Subscription tier not available' }, + { status: 503 }, + ) + } + + try { + const stripeSub = await stripeServer.subscriptions.retrieve( + subscription.stripe_subscription_id, + ) + const itemId = stripeSub.items.data[0]?.id + if (!itemId) { + logger.error( + { userId, subscriptionId: subscription.stripe_subscription_id }, + 'Stripe subscription has no items', + ) + return NextResponse.json( + { error: 'Subscription configuration error.' }, + { status: 500 }, + ) + } + + await stripeServer.subscriptions.update( + subscription.stripe_subscription_id, + { + items: [{ id: itemId, price: newPriceId }], + proration_behavior: 'create_prorations', + }, + ) + + try { + await Promise.all([ + db + .update(schema.subscription) + .set({ tier, stripe_price_id: newPriceId, updated_at: new Date() }) + .where( + eq( + schema.subscription.stripe_subscription_id, + subscription.stripe_subscription_id, + ), + ), + expireActiveBlockGrants({ + userId, + subscriptionId: subscription.stripe_subscription_id, + logger, + }), + ]) + } catch (dbError) { + logger.error( + { error: dbError, userId, subscriptionId: subscription.stripe_subscription_id }, + 'DB update failed after Stripe tier change — webhook will reconcile', + ) + } + + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_TIER_CHANGED, + userId, + properties: { + subscriptionId: subscription.stripe_subscription_id, + previousTier, + newTier: tier, + }, + logger, + }) + + logger.info( + { + userId, + subscriptionId: subscription.stripe_subscription_id, + previousTier, + newTier: tier, + }, + 'Subscription tier changed', + ) + + return NextResponse.json({ success: true, previousTier, newTier: tier }) + } catch (error: unknown) { + const message = error instanceof Error + ? error.message + : 'Internal server error changing subscription tier.' + logger.error( + { + error, + userId, + subscriptionId: subscription.stripe_subscription_id, + }, + 'Failed to change subscription tier', + ) + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts new file mode 100644 index 0000000000..2db8748a48 --- /dev/null +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -0,0 +1,111 @@ +import { getActiveSubscription, getTierPriceId } from '@codebuff/billing' +import { SUBSCRIPTION_TIERS } from '@codebuff/common/constants/subscription-plans' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans' +import type { NextRequest } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const body = await req.json().catch(() => ({})) + const rawTier = Number(body.tier) + const tier = (rawTier && rawTier in SUBSCRIPTION_TIERS + ? rawTier + : 200) as SubscriptionTierPrice + + const priceId = getTierPriceId(tier) + if (!priceId) { + return NextResponse.json( + { error: 'Subscription tier not available' }, + { status: 503 }, + ) + } + + const user = await db.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { stripe_customer_id: true, banned: true }, + }) + + if (user?.banned) { + logger.warn({ userId }, 'Banned user attempted to create subscription') + return NextResponse.json( + { error: 'Your account has been suspended. Please contact support.' }, + { status: 403 }, + ) + } + + if (!user?.stripe_customer_id) { + return NextResponse.json( + { error: 'Stripe customer not found.' }, + { status: 400 }, + ) + } + + const existing = await getActiveSubscription({ userId, logger }) + if (existing) { + return NextResponse.json( + { error: 'You already have an active subscription.' }, + { status: 409 }, + ) + } + + try { + const checkoutSession = await stripeServer.checkout.sessions.create({ + customer: user.stripe_customer_id, + mode: 'subscription', + line_items: [{ price: priceId, quantity: 1 }], + allow_promotion_codes: true, + success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile?tab=usage&subscription_success=true`, + cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/strong?canceled=true`, + metadata: { + userId, + type: 'strong_subscription', + }, + subscription_data: { + description: `Codebuff Strong — $${tier}/mo unlimited coding sessions`, + metadata: { + userId, + }, + }, + }) + + if (!checkoutSession.url) { + logger.error({ userId }, 'Stripe checkout session created without a URL') + return NextResponse.json( + { error: 'Could not create checkout session.' }, + { status: 500 }, + ) + } + + logger.info( + { userId, sessionId: checkoutSession.id, tier }, + 'Created Strong subscription checkout session', + ) + + return NextResponse.json({ sessionId: checkoutSession.id }) + } catch (error: unknown) { + const message = + (error as { raw?: { message?: string } })?.raw?.message || + 'Internal server error creating subscription.' + logger.error( + { error: message, userId }, + 'Failed to create subscription checkout', + ) + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/web/src/app/api/user/subscription/route.ts b/web/src/app/api/user/subscription/route.ts new file mode 100644 index 0000000000..34faa02c17 --- /dev/null +++ b/web/src/app/api/user/subscription/route.ts @@ -0,0 +1,55 @@ +import { + checkRateLimit, + getActiveSubscription, + getSubscriptionLimits, +} from '@codebuff/billing' +import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +export async function GET() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + const subscription = await getActiveSubscription({ userId, logger }) + + if (!subscription) { + return NextResponse.json({ hasSubscription: false }) + } + + const [rateLimit, limits] = await Promise.all([ + checkRateLimit({ userId, subscription, logger }), + getSubscriptionLimits({ userId, logger, tier: subscription.tier }), + ]) + + return NextResponse.json({ + hasSubscription: true, + displayName: SUBSCRIPTION_DISPLAY_NAME, + subscription: { + status: subscription.status, + billingPeriodEnd: subscription.billing_period_end.toISOString(), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + canceledAt: subscription.canceled_at?.toISOString() ?? null, + tier: subscription.tier, + }, + rateLimit: { + limited: rateLimit.limited, + reason: rateLimit.reason, + canStartNewBlock: rateLimit.canStartNewBlock, + blockUsed: rateLimit.blockUsed, + blockLimit: rateLimit.blockLimit, + blockResetsAt: rateLimit.blockResetsAt?.toISOString(), + weeklyUsed: rateLimit.weeklyUsed, + weeklyLimit: rateLimit.weeklyLimit, + weeklyResetsAt: rateLimit.weeklyResetsAt.toISOString(), + weeklyPercentUsed: rateLimit.weeklyPercentUsed, + }, + limits, + }) +} From 3f81504ebd085cb6f77cccc589541962fddf2dd8 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 14:53:00 -0800 Subject: [PATCH 15/28] Web subscription UI --- .../components/subscription-section.tsx | 504 ++++++++++++++++++ .../app/profile/components/usage-section.tsx | 2 + web/src/app/strong/page.tsx | 39 ++ web/src/app/strong/strong-client.tsx | 244 +++++++++ 4 files changed, 789 insertions(+) create mode 100644 web/src/app/profile/components/subscription-section.tsx create mode 100644 web/src/app/strong/page.tsx create mode 100644 web/src/app/strong/strong-client.tsx diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx new file mode 100644 index 0000000000..c3f31643f2 --- /dev/null +++ b/web/src/app/profile/components/subscription-section.tsx @@ -0,0 +1,504 @@ +'use client' + +import { + SUBSCRIPTION_DISPLAY_NAME, + SUBSCRIPTION_TIERS, +} from '@codebuff/common/constants/subscription-plans' + +import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Zap, + Clock, + CalendarDays, + Loader2, + AlertTriangle, + ArrowRightLeft, +} from 'lucide-react' +import Link from 'next/link' +import { useSession } from 'next-auth/react' +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { toast } from '@/components/ui/use-toast' +import { cn } from '@/lib/utils' + +interface SubscriptionApiResponse { + hasSubscription: boolean + displayName?: string + subscription?: { + status: string + billingPeriodEnd: string + cancelAtPeriodEnd: boolean + canceledAt: string | null + tier?: number | null + } + rateLimit?: { + limited: boolean + reason?: 'block_exhausted' | 'weekly_limit' + canStartNewBlock: boolean + blockUsed?: number + blockLimit?: number + blockResetsAt?: string + weeklyUsed: number + weeklyLimit: number + weeklyResetsAt: string + weeklyPercentUsed: number + } + limits?: { + creditsPerBlock: number + blockDurationHours: number + weeklyCreditsLimit: number + } +} + +function formatRelativeTime(dateStr: string): string { + const target = new Date(dateStr) + const now = new Date() + const diffMs = target.getTime() - now.getTime() + if (diffMs <= 0) return 'now' + const hours = Math.floor(diffMs / (1000 * 60 * 60)) + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +function formatShortDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }) +} + +function ProgressBar({ + value, + max, + label, + className, +}: { + value: number + max: number + label: string + className?: string +}) { + const percent = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0 + return ( +
+
= 100 + ? 'bg-red-500' + : percent >= 75 + ? 'bg-yellow-500' + : 'bg-indigo-500', + )} + style={{ width: `${percent}%` }} + /> +
+ ) +} + +function SubscriptionActive({ + data, +}: { + data: SubscriptionApiResponse +}) { + const queryClient = useQueryClient() + const [showCancelDialog, setShowCancelDialog] = useState(false) + const [showChangePlanDialog, setShowChangePlanDialog] = useState(false) + + const cancelMutation = useMutation({ + mutationFn: async () => { + const response = await fetch('/api/stripe/cancel-subscription', { + method: 'POST', + }) + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error || 'Failed to cancel subscription') + } + return response.json() + }, + onSuccess: () => { + setShowCancelDialog(false) + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + toast({ + title: 'Subscription canceled', + description: `Your ${SUBSCRIPTION_DISPLAY_NAME} subscription will remain active until the end of your billing period.`, + }) + }, + onError: (error: Error) => { + setShowCancelDialog(false) + toast({ + title: 'Error', + description: error.message, + variant: 'destructive', + }) + }, + }) + + const changeTierMutation = useMutation({ + mutationFn: async (selectedTier: SubscriptionTierPrice) => { + const response = await fetch('/api/stripe/change-subscription-tier', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tier: selectedTier }), + }) + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.error || 'Failed to change plan') + } + return response.json() + }, + onSuccess: () => { + setShowChangePlanDialog(false) + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + toast({ + title: 'Plan changed', + description: 'Your subscription plan has been updated.', + }) + }, + onError: (error: Error) => { + setShowChangePlanDialog(false) + toast({ + title: 'Error', + description: error.message, + variant: 'destructive', + }) + }, + }) + + const { subscription, rateLimit } = data + + const isCanceling = subscription?.cancelAtPeriodEnd + const currentTier = (subscription?.tier ?? 200) as SubscriptionTierPrice + + return ( + + +
+ + + {SUBSCRIPTION_DISPLAY_NAME} · ${subscription?.tier ?? 200}/mo + + + {isCanceling ? 'Canceling' : 'Active'} + +
+
+ + {/* Block usage */} + {rateLimit && ( + <> +
+
+ + + Current Block + + {rateLimit.blockResetsAt ? ( + + Resets in {formatRelativeTime(rateLimit.blockResetsAt)} + + ) : rateLimit.canStartNewBlock ? ( + + Ready for new session + + ) : null} +
+ {rateLimit.blockLimit != null && + rateLimit.blockUsed != null ? ( + <> + +

+ {rateLimit.blockLimit > 0 + ? `${Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100)}% used` + : '0% used'} +

+ + ) : ( + <> + +

+ No active block — a new session will start when you use + Codebuff +

+ + )} +
+ + {/* Weekly usage */} +
+
+ + + Weekly Usage + + + Resets {formatShortDate(rateLimit.weeklyResetsAt)} + +
+ +

+ {rateLimit.weeklyPercentUsed}% used +

+
+ + {/* Rate limit warning */} + {rateLimit.limited && ( +
+ +

+ {rateLimit.reason === 'weekly_limit' + ? `Weekly limit reached. Resets ${formatShortDate(rateLimit.weeklyResetsAt)}. You can still use a-la-carte credits.` + : `Block exhausted. New block in ${rateLimit.blockResetsAt ? formatRelativeTime(rateLimit.blockResetsAt) : 'soon'}. You can still use a-la-carte credits.`} +

+
+ )} + + )} + + {/* Billing info & cancel */} +
+

+ {isCanceling + ? `Cancels ${subscription ? formatDate(subscription.billingPeriodEnd) : ''}` + : `Renews ${subscription ? formatDate(subscription.billingPeriodEnd) : ''}`} +

+ {!isCanceling && ( +
+ + +
+ )} +
+
+ + + + + Cancel subscription? + + Your {SUBSCRIPTION_DISPLAY_NAME} subscription will remain active + until{' '} + {subscription + ? formatDate(subscription.billingPeriodEnd) + : 'the end of your billing period'} + . After that, you'll return to the free tier. + + + + + + + + + + + + + Change Plan + + Select a new plan for your {SUBSCRIPTION_DISPLAY_NAME} subscription. The change takes effect immediately with a prorated charge. + + +
+ {Object.entries(SUBSCRIPTION_TIERS).map( + ([key, tier]) => { + const tierPrice = Number(key) as SubscriptionTierPrice + const isCurrent = tierPrice === currentTier + const tierName = + tierPrice === 100 + ? 'Starter' + : tierPrice === 200 + ? 'Pro' + : 'Team' + const tierDescription = + tierPrice === 100 + ? 'Great for individuals getting started.' + : tierPrice === 200 + ? 'For professionals who need more capacity.' + : 'For power users and teams with heavy workloads.' + return ( + + ) + }, + )} +
+ + + +
+
+
+ ) +} + +function SubscriptionCta() { + return ( + + +
+
+ +
+
+

+ Upgrade to {SUBSCRIPTION_DISPLAY_NAME} +

+

+ From $100/mo · Work in focused 5-hour sessions with no + interruptions. +

+
+
+ + + +
+
+ ) +} + +export function SubscriptionSection() { + const { status } = useSession() + + const { data, isLoading } = useQuery({ + queryKey: ['subscription'], + queryFn: async () => { + const res = await fetch('/api/user/subscription') + if (!res.ok) throw new Error('Failed to fetch subscription') + return res.json() + }, + enabled: status === 'authenticated', + refetchInterval: 60_000, + }) + + if (status !== 'authenticated') return null + if (isLoading) { + return ( + + +
+ + Loading subscription... +
+
+
+ ) + } + + if (!data || !data.hasSubscription) { + return + } + + return +} diff --git a/web/src/app/profile/components/usage-section.tsx b/web/src/app/profile/components/usage-section.tsx index eaa8beab80..9f62d01341 100644 --- a/web/src/app/profile/components/usage-section.tsx +++ b/web/src/app/profile/components/usage-section.tsx @@ -6,6 +6,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useSession } from 'next-auth/react' import { useState } from 'react' +import { SubscriptionSection } from './subscription-section' import { UsageDisplay } from './usage-display' import { CreditManagementSection } from '@/components/credits/CreditManagementSection' @@ -127,6 +128,7 @@ export function UsageSection() { Track your credit usage and purchase additional credits as needed.

+ {status === 'authenticated' && } {isUsageError && ( diff --git a/web/src/app/strong/page.tsx b/web/src/app/strong/page.tsx new file mode 100644 index 0000000000..3b4948cff7 --- /dev/null +++ b/web/src/app/strong/page.tsx @@ -0,0 +1,39 @@ +import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' +import { env } from '@codebuff/common/env' + +import StrongClient from './strong-client' + +import type { Metadata } from 'next' + +export async function generateMetadata(): Promise { + const canonicalUrl = `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/strong` + const title = `Codebuff ${SUBSCRIPTION_DISPLAY_NAME} — The Strongest Coding Agent` + const description = + 'Deep thinking, multi-agent orchestration, and the strongest coding agent. Plans from $100/mo.' + + return { + title, + description, + alternates: { canonical: canonicalUrl }, + openGraph: { + title, + description, + url: canonicalUrl, + type: 'website', + siteName: 'Codebuff', + images: '/opengraph-image.png', + }, + twitter: { + card: 'summary_large_image', + title, + description, + images: '/opengraph-image.png', + }, + } +} + +export const dynamic = 'force-static' + +export default function StrongPage() { + return +} diff --git a/web/src/app/strong/strong-client.tsx b/web/src/app/strong/strong-client.tsx new file mode 100644 index 0000000000..49e3c567b8 --- /dev/null +++ b/web/src/app/strong/strong-client.tsx @@ -0,0 +1,244 @@ +'use client' + +import { + SUBSCRIPTION_TIERS, + SUBSCRIPTION_DISPLAY_NAME, + type SubscriptionTierPrice, +} from '@codebuff/common/constants/subscription-plans' +import { env } from '@codebuff/common/env' +import { loadStripe } from '@stripe/stripe-js' +import { motion } from 'framer-motion' +import { Loader2 } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useSession } from 'next-auth/react' +import { useState } from 'react' + +import { toast } from '@/components/ui/use-toast' +import { cn } from '@/lib/utils' + +const USAGE_MULTIPLIER: Record = { + 100: '1×', + 200: '2.5×', + 500: '7×', +} + +function SubscribeButton({ + className, + tier, +}: { + className?: string + tier?: number +}) { + const { status } = useSession() + const router = useRouter() + const [isLoading, setIsLoading] = useState(false) + + const handleSubscribe = async () => { + if (status !== 'authenticated') { + router.push('/login?callbackUrl=/strong') + return + } + + setIsLoading(true) + try { + const res = await fetch('/api/stripe/create-subscription', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tier }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error || 'Failed to start checkout') + } + const { sessionId } = await res.json() + const stripe = await loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) + if (!stripe) throw new Error('Stripe failed to load') + const { error } = await stripe.redirectToCheckout({ sessionId }) + if (error) throw new Error(error.message) + } catch (err) { + toast({ + title: 'Error', + description: + err instanceof Error ? err.message : 'Something went wrong', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + return ( + + ) +} + +export default function StrongClient() { + return ( +
+ {/* Subtle radial glow behind content */} +
+ + {/* Animated gradient blobs */} + + + {/* Giant background text */} + + + {/* Foreground content */} +
+
+ + codebuff + + + + The strongest coding agent + + + + Deep thinking. Multi-agent orchestration. Ship faster. + +
+ + {/* Pricing cards grid */} + + {Object.entries(SUBSCRIPTION_TIERS).map(([key, tier]) => { + const price = Number(key) as SubscriptionTierPrice + const isHighlighted = price === 200 + + return ( +
+
+ + ${tier.monthlyPrice} + + /mo +
+ +

+ {USAGE_MULTIPLIER[price]} usage +

+ + +
+ ) + })} +
+ + + Cancel anytime + +
+
+ ) +} From 5e9b314db21c0a5e0232129346b0d5f60e4892d4 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 15:19:24 -0800 Subject: [PATCH 16/28] Fix billing test to mock subscription endpoint --- .../src/__tests__/usage-service.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/billing/src/__tests__/usage-service.test.ts b/packages/billing/src/__tests__/usage-service.test.ts index ebf617b014..c037b60310 100644 --- a/packages/billing/src/__tests__/usage-service.test.ts +++ b/packages/billing/src/__tests__/usage-service.test.ts @@ -49,6 +49,10 @@ describe('usage-service', () => { }), })) + await mockModule('@codebuff/billing/subscription', () => ({ + getActiveSubscription: async () => null, + })) + const { getUserUsageData } = await import('@codebuff/billing/usage-service') const result = await getUserUsageData({ @@ -81,6 +85,10 @@ describe('usage-service', () => { }), })) + await mockModule('@codebuff/billing/subscription', () => ({ + getActiveSubscription: async () => null, + })) + const { getUserUsageData } = await import('@codebuff/billing/usage-service') const result = await getUserUsageData({ @@ -110,6 +118,10 @@ describe('usage-service', () => { }), })) + await mockModule('@codebuff/billing/subscription', () => ({ + getActiveSubscription: async () => null, + })) + const { getUserUsageData } = await import('@codebuff/billing/usage-service') const result = await getUserUsageData({ @@ -140,6 +152,10 @@ describe('usage-service', () => { }), })) + await mockModule('@codebuff/billing/subscription', () => ({ + getActiveSubscription: async () => null, + })) + const { getUserUsageData } = await import('@codebuff/billing/usage-service') const result = await getUserUsageData({ @@ -171,6 +187,10 @@ describe('usage-service', () => { }), })) + await mockModule('@codebuff/billing/subscription', () => ({ + getActiveSubscription: async () => null, + })) + const { getUserUsageData } = await import('@codebuff/billing/usage-service') // Should not throw From 7f5e13570bbcaa7fb296c3ca2e687c0a72203edc Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 15:39:01 -0800 Subject: [PATCH 17/28] Remove subscription client UI (moved to subscription-client branch) --- .../components/subscription-section.tsx | 504 ------------------ .../app/profile/components/usage-display.tsx | 11 +- .../app/profile/components/usage-section.tsx | 2 - web/src/app/strong/page.tsx | 39 -- web/src/app/strong/strong-client.tsx | 244 --------- 5 files changed, 1 insertion(+), 799 deletions(-) delete mode 100644 web/src/app/profile/components/subscription-section.tsx delete mode 100644 web/src/app/strong/page.tsx delete mode 100644 web/src/app/strong/strong-client.tsx diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx deleted file mode 100644 index c3f31643f2..0000000000 --- a/web/src/app/profile/components/subscription-section.tsx +++ /dev/null @@ -1,504 +0,0 @@ -'use client' - -import { - SUBSCRIPTION_DISPLAY_NAME, - SUBSCRIPTION_TIERS, -} from '@codebuff/common/constants/subscription-plans' - -import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { - Zap, - Clock, - CalendarDays, - Loader2, - AlertTriangle, - ArrowRightLeft, -} from 'lucide-react' -import Link from 'next/link' -import { useSession } from 'next-auth/react' -import { useState } from 'react' - -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { toast } from '@/components/ui/use-toast' -import { cn } from '@/lib/utils' - -interface SubscriptionApiResponse { - hasSubscription: boolean - displayName?: string - subscription?: { - status: string - billingPeriodEnd: string - cancelAtPeriodEnd: boolean - canceledAt: string | null - tier?: number | null - } - rateLimit?: { - limited: boolean - reason?: 'block_exhausted' | 'weekly_limit' - canStartNewBlock: boolean - blockUsed?: number - blockLimit?: number - blockResetsAt?: string - weeklyUsed: number - weeklyLimit: number - weeklyResetsAt: string - weeklyPercentUsed: number - } - limits?: { - creditsPerBlock: number - blockDurationHours: number - weeklyCreditsLimit: number - } -} - -function formatRelativeTime(dateStr: string): string { - const target = new Date(dateStr) - const now = new Date() - const diffMs = target.getTime() - now.getTime() - if (diffMs <= 0) return 'now' - const hours = Math.floor(diffMs / (1000 * 60 * 60)) - const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) - if (hours > 0) return `${hours}h ${minutes}m` - return `${minutes}m` -} - -function formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) -} - -function formatShortDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - }) -} - -function ProgressBar({ - value, - max, - label, - className, -}: { - value: number - max: number - label: string - className?: string -}) { - const percent = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0 - return ( -
-
= 100 - ? 'bg-red-500' - : percent >= 75 - ? 'bg-yellow-500' - : 'bg-indigo-500', - )} - style={{ width: `${percent}%` }} - /> -
- ) -} - -function SubscriptionActive({ - data, -}: { - data: SubscriptionApiResponse -}) { - const queryClient = useQueryClient() - const [showCancelDialog, setShowCancelDialog] = useState(false) - const [showChangePlanDialog, setShowChangePlanDialog] = useState(false) - - const cancelMutation = useMutation({ - mutationFn: async () => { - const response = await fetch('/api/stripe/cancel-subscription', { - method: 'POST', - }) - if (!response.ok) { - const err = await response.json().catch(() => ({})) - throw new Error(err.error || 'Failed to cancel subscription') - } - return response.json() - }, - onSuccess: () => { - setShowCancelDialog(false) - queryClient.invalidateQueries({ queryKey: ['subscription'] }) - toast({ - title: 'Subscription canceled', - description: `Your ${SUBSCRIPTION_DISPLAY_NAME} subscription will remain active until the end of your billing period.`, - }) - }, - onError: (error: Error) => { - setShowCancelDialog(false) - toast({ - title: 'Error', - description: error.message, - variant: 'destructive', - }) - }, - }) - - const changeTierMutation = useMutation({ - mutationFn: async (selectedTier: SubscriptionTierPrice) => { - const response = await fetch('/api/stripe/change-subscription-tier', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tier: selectedTier }), - }) - if (!response.ok) { - const err = await response.json().catch(() => ({})) - throw new Error(err.error || 'Failed to change plan') - } - return response.json() - }, - onSuccess: () => { - setShowChangePlanDialog(false) - queryClient.invalidateQueries({ queryKey: ['subscription'] }) - toast({ - title: 'Plan changed', - description: 'Your subscription plan has been updated.', - }) - }, - onError: (error: Error) => { - setShowChangePlanDialog(false) - toast({ - title: 'Error', - description: error.message, - variant: 'destructive', - }) - }, - }) - - const { subscription, rateLimit } = data - - const isCanceling = subscription?.cancelAtPeriodEnd - const currentTier = (subscription?.tier ?? 200) as SubscriptionTierPrice - - return ( - - -
- - - {SUBSCRIPTION_DISPLAY_NAME} · ${subscription?.tier ?? 200}/mo - - - {isCanceling ? 'Canceling' : 'Active'} - -
-
- - {/* Block usage */} - {rateLimit && ( - <> -
-
- - - Current Block - - {rateLimit.blockResetsAt ? ( - - Resets in {formatRelativeTime(rateLimit.blockResetsAt)} - - ) : rateLimit.canStartNewBlock ? ( - - Ready for new session - - ) : null} -
- {rateLimit.blockLimit != null && - rateLimit.blockUsed != null ? ( - <> - -

- {rateLimit.blockLimit > 0 - ? `${Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100)}% used` - : '0% used'} -

- - ) : ( - <> - -

- No active block — a new session will start when you use - Codebuff -

- - )} -
- - {/* Weekly usage */} -
-
- - - Weekly Usage - - - Resets {formatShortDate(rateLimit.weeklyResetsAt)} - -
- -

- {rateLimit.weeklyPercentUsed}% used -

-
- - {/* Rate limit warning */} - {rateLimit.limited && ( -
- -

- {rateLimit.reason === 'weekly_limit' - ? `Weekly limit reached. Resets ${formatShortDate(rateLimit.weeklyResetsAt)}. You can still use a-la-carte credits.` - : `Block exhausted. New block in ${rateLimit.blockResetsAt ? formatRelativeTime(rateLimit.blockResetsAt) : 'soon'}. You can still use a-la-carte credits.`} -

-
- )} - - )} - - {/* Billing info & cancel */} -
-

- {isCanceling - ? `Cancels ${subscription ? formatDate(subscription.billingPeriodEnd) : ''}` - : `Renews ${subscription ? formatDate(subscription.billingPeriodEnd) : ''}`} -

- {!isCanceling && ( -
- - -
- )} -
-
- - - - - Cancel subscription? - - Your {SUBSCRIPTION_DISPLAY_NAME} subscription will remain active - until{' '} - {subscription - ? formatDate(subscription.billingPeriodEnd) - : 'the end of your billing period'} - . After that, you'll return to the free tier. - - - - - - - - - - - - - Change Plan - - Select a new plan for your {SUBSCRIPTION_DISPLAY_NAME} subscription. The change takes effect immediately with a prorated charge. - - -
- {Object.entries(SUBSCRIPTION_TIERS).map( - ([key, tier]) => { - const tierPrice = Number(key) as SubscriptionTierPrice - const isCurrent = tierPrice === currentTier - const tierName = - tierPrice === 100 - ? 'Starter' - : tierPrice === 200 - ? 'Pro' - : 'Team' - const tierDescription = - tierPrice === 100 - ? 'Great for individuals getting started.' - : tierPrice === 200 - ? 'For professionals who need more capacity.' - : 'For power users and teams with heavy workloads.' - return ( - - ) - }, - )} -
- - - -
-
-
- ) -} - -function SubscriptionCta() { - return ( - - -
-
- -
-
-

- Upgrade to {SUBSCRIPTION_DISPLAY_NAME} -

-

- From $100/mo · Work in focused 5-hour sessions with no - interruptions. -

-
-
- - - -
-
- ) -} - -export function SubscriptionSection() { - const { status } = useSession() - - const { data, isLoading } = useQuery({ - queryKey: ['subscription'], - queryFn: async () => { - const res = await fetch('/api/user/subscription') - if (!res.ok) throw new Error('Failed to fetch subscription') - return res.json() - }, - enabled: status === 'authenticated', - refetchInterval: 60_000, - }) - - if (status !== 'authenticated') return null - if (isLoading) { - return ( - - -
- - Loading subscription... -
-
-
- ) - } - - if (!data || !data.hasSubscription) { - return - } - - return -} diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index 772a3c45c5..dae0f757f8 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -53,14 +53,6 @@ const grantTypeInfo: Record< label: 'Monthly Free', description: 'Your monthly allowance', }, - subscription: { - bg: 'bg-indigo-500', - text: 'text-indigo-600 dark:text-indigo-400', - gradient: 'from-indigo-500/70 to-indigo-600/70', - icon: , - label: 'Strong', - description: 'Credits from your Strong subscription', - }, referral: { bg: 'bg-green-500', text: 'text-green-600 dark:text-green-400', @@ -241,7 +233,6 @@ export const UsageDisplay = ({ // Calculate used credits per type (excluding organization) const usedCredits: Record = { free: 0, - subscription: 0, referral: 0, purchase: 0, admin: 0, @@ -261,7 +252,7 @@ export const UsageDisplay = ({ }) // Group credits by expiration type (excluding organization) - const expiringTypes: FilteredGrantType[] = ['subscription', 'free', 'referral'] + const expiringTypes: FilteredGrantType[] = ['free', 'referral'] const nonExpiringTypes: FilteredGrantType[] = ['admin', 'purchase', 'ad'] const expiringTotal = expiringTypes.reduce( diff --git a/web/src/app/profile/components/usage-section.tsx b/web/src/app/profile/components/usage-section.tsx index 9f62d01341..eaa8beab80 100644 --- a/web/src/app/profile/components/usage-section.tsx +++ b/web/src/app/profile/components/usage-section.tsx @@ -6,7 +6,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useSession } from 'next-auth/react' import { useState } from 'react' -import { SubscriptionSection } from './subscription-section' import { UsageDisplay } from './usage-display' import { CreditManagementSection } from '@/components/credits/CreditManagementSection' @@ -128,7 +127,6 @@ export function UsageSection() { Track your credit usage and purchase additional credits as needed.

- {status === 'authenticated' && } {isUsageError && ( diff --git a/web/src/app/strong/page.tsx b/web/src/app/strong/page.tsx deleted file mode 100644 index 3b4948cff7..0000000000 --- a/web/src/app/strong/page.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' -import { env } from '@codebuff/common/env' - -import StrongClient from './strong-client' - -import type { Metadata } from 'next' - -export async function generateMetadata(): Promise { - const canonicalUrl = `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/strong` - const title = `Codebuff ${SUBSCRIPTION_DISPLAY_NAME} — The Strongest Coding Agent` - const description = - 'Deep thinking, multi-agent orchestration, and the strongest coding agent. Plans from $100/mo.' - - return { - title, - description, - alternates: { canonical: canonicalUrl }, - openGraph: { - title, - description, - url: canonicalUrl, - type: 'website', - siteName: 'Codebuff', - images: '/opengraph-image.png', - }, - twitter: { - card: 'summary_large_image', - title, - description, - images: '/opengraph-image.png', - }, - } -} - -export const dynamic = 'force-static' - -export default function StrongPage() { - return -} diff --git a/web/src/app/strong/strong-client.tsx b/web/src/app/strong/strong-client.tsx deleted file mode 100644 index 49e3c567b8..0000000000 --- a/web/src/app/strong/strong-client.tsx +++ /dev/null @@ -1,244 +0,0 @@ -'use client' - -import { - SUBSCRIPTION_TIERS, - SUBSCRIPTION_DISPLAY_NAME, - type SubscriptionTierPrice, -} from '@codebuff/common/constants/subscription-plans' -import { env } from '@codebuff/common/env' -import { loadStripe } from '@stripe/stripe-js' -import { motion } from 'framer-motion' -import { Loader2 } from 'lucide-react' -import { useRouter } from 'next/navigation' -import { useSession } from 'next-auth/react' -import { useState } from 'react' - -import { toast } from '@/components/ui/use-toast' -import { cn } from '@/lib/utils' - -const USAGE_MULTIPLIER: Record = { - 100: '1×', - 200: '2.5×', - 500: '7×', -} - -function SubscribeButton({ - className, - tier, -}: { - className?: string - tier?: number -}) { - const { status } = useSession() - const router = useRouter() - const [isLoading, setIsLoading] = useState(false) - - const handleSubscribe = async () => { - if (status !== 'authenticated') { - router.push('/login?callbackUrl=/strong') - return - } - - setIsLoading(true) - try { - const res = await fetch('/api/stripe/create-subscription', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tier }), - }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.error || 'Failed to start checkout') - } - const { sessionId } = await res.json() - const stripe = await loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) - if (!stripe) throw new Error('Stripe failed to load') - const { error } = await stripe.redirectToCheckout({ sessionId }) - if (error) throw new Error(error.message) - } catch (err) { - toast({ - title: 'Error', - description: - err instanceof Error ? err.message : 'Something went wrong', - variant: 'destructive', - }) - } finally { - setIsLoading(false) - } - } - - return ( - - ) -} - -export default function StrongClient() { - return ( -
- {/* Subtle radial glow behind content */} -
- - {/* Animated gradient blobs */} - - - {/* Giant background text */} - - - {/* Foreground content */} -
-
- - codebuff - - - - The strongest coding agent - - - - Deep thinking. Multi-agent orchestration. Ship faster. - -
- - {/* Pricing cards grid */} - - {Object.entries(SUBSCRIPTION_TIERS).map(([key, tier]) => { - const price = Number(key) as SubscriptionTierPrice - const isHighlighted = price === 200 - - return ( -
-
- - ${tier.monthlyPrice} - - /mo -
- -

- {USAGE_MULTIPLIER[price]} usage -

- - -
- ) - })} -
- - - Cancel anytime - -
-
- ) -} From 7059836c58525fd163e4c2ddc7da0d9c5cdef5f3 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 15:50:18 -0800 Subject: [PATCH 18/28] Add subscription grant type to usage-display for typecheck compatibility --- web/src/app/profile/components/usage-display.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index dae0f757f8..48f90d1a78 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -10,6 +10,7 @@ import { CreditCard, Star, Megaphone, + Zap, } from 'lucide-react' import React from 'react' @@ -85,6 +86,14 @@ const grantTypeInfo: Record< label: 'Ad Credits', description: 'Earned from viewing ads', }, + subscription: { + bg: 'bg-teal-500', + text: 'text-teal-600 dark:text-teal-400', + gradient: 'from-teal-500/70 to-teal-600/70', + icon: , + label: 'Subscription', + description: 'Credits from your subscription', + }, } interface CreditLeafProps { @@ -234,6 +243,7 @@ export const UsageDisplay = ({ const usedCredits: Record = { free: 0, referral: 0, + subscription: 0, purchase: 0, admin: 0, ad: 0, @@ -252,7 +262,7 @@ export const UsageDisplay = ({ }) // Group credits by expiration type (excluding organization) - const expiringTypes: FilteredGrantType[] = ['free', 'referral'] + const expiringTypes: FilteredGrantType[] = ['free', 'referral', 'subscription'] const nonExpiringTypes: FilteredGrantType[] = ['admin', 'purchase', 'ad'] const expiringTotal = expiringTypes.reduce( From 1509a09f7ad26d7c70d444bd4251fd3a563c6800 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 16:09:01 -0800 Subject: [PATCH 19/28] Update tier usage limits (1x, 3x, 8x) --- common/src/constants/subscription-plans.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/src/constants/subscription-plans.ts b/common/src/constants/subscription-plans.ts index 16c7555236..632727a3bb 100644 --- a/common/src/constants/subscription-plans.ts +++ b/common/src/constants/subscription-plans.ts @@ -16,15 +16,15 @@ export const SUBSCRIPTION_TIERS = { }, 200: { monthlyPrice: 200, - creditsPerBlock: 1250, + creditsPerBlock: 1200, blockDurationHours: 5, - weeklyCreditsLimit: 12500, + weeklyCreditsLimit: 12000, }, 500: { monthlyPrice: 500, - creditsPerBlock: 3125, + creditsPerBlock: 3200, blockDurationHours: 5, - weeklyCreditsLimit: 31250, + weeklyCreditsLimit: 32000, }, } as const satisfies Record From 1b1176c1dad37fcd43174280d968ed61721e367a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 16:14:03 -0800 Subject: [PATCH 20/28] Fixes from reviewer --- packages/billing/src/subscription-webhooks.ts | 38 +++++++++++++++---- packages/billing/src/subscription.ts | 5 ++- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 8450a4c47c..b2f14a1df6 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -10,7 +10,7 @@ import { } from '@codebuff/internal/util/stripe' import { eq } from 'drizzle-orm' -import { handleSubscribe } from './subscription' +import { expireActiveBlockGrants, handleSubscribe } from './subscription' import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans' import type { Logger } from '@codebuff/common/types/contracts/logger' @@ -85,6 +85,15 @@ export async function handleSubscriptionInvoicePaid(params: { return } + const tier = getTierFromPriceId(priceId) + if (!tier) { + logger.debug( + { subscriptionId, priceId }, + 'Price ID does not match a Strong tier — skipping', + ) + return + } + // Look up the user for this customer const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null @@ -104,6 +113,8 @@ export async function handleSubscriptionInvoicePaid(params: { } } + const status = mapStripeStatus(stripeSub.status) + // Upsert subscription row await db .insert(schema.subscription) @@ -112,8 +123,8 @@ export async function handleSubscriptionInvoicePaid(params: { stripe_customer_id: customerId, user_id: userId, stripe_price_id: priceId, - tier: getTierFromPriceId(priceId), - status: 'active', + tier, + status, billing_period_start: new Date(stripeSub.current_period_start * 1000), billing_period_end: new Date(stripeSub.current_period_end * 1000), cancel_at_period_end: stripeSub.cancel_at_period_end, @@ -121,10 +132,10 @@ export async function handleSubscriptionInvoicePaid(params: { .onConflictDoUpdate({ target: schema.subscription.stripe_subscription_id, set: { - status: 'active', + status, ...(userId ? { user_id: userId } : {}), stripe_price_id: priceId, - tier: getTierFromPriceId(priceId), + tier, billing_period_start: new Date( stripeSub.current_period_start * 1000, ), @@ -209,6 +220,15 @@ export async function handleSubscriptionUpdated(params: { return } + const tier = getTierFromPriceId(priceId) + if (!tier) { + logger.debug( + { subscriptionId, priceId }, + 'Price ID does not match a Strong tier — skipping', + ) + return + } + const customerId = getStripeId(stripeSubscription.customer) const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null @@ -223,7 +243,7 @@ export async function handleSubscriptionUpdated(params: { stripe_customer_id: customerId, user_id: userId, stripe_price_id: priceId, - tier: getTierFromPriceId(priceId), + tier, status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( @@ -238,7 +258,7 @@ export async function handleSubscriptionUpdated(params: { set: { ...(userId ? { user_id: userId } : {}), stripe_price_id: priceId, - tier: getTierFromPriceId(priceId), + tier, status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( @@ -286,6 +306,10 @@ export async function handleSubscriptionDeleted(params: { }) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) + if (userId) { + await expireActiveBlockGrants({ userId, subscriptionId, logger }) + } + trackEvent({ event: AnalyticsEvent.SUBSCRIPTION_CANCELED, userId: userId ?? 'system', diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index d78e2eebf3..80c795af29 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -499,7 +499,7 @@ export async function expireActiveBlockGrants(params: { const expired = await db .update(schema.creditLedger) - .set({ balance: 0, expires_at: now }) + .set({ expires_at: now }) .where( and( eq(schema.creditLedger.user_id, userId), @@ -513,7 +513,7 @@ export async function expireActiveBlockGrants(params: { if (expired.length > 0) { logger.info( { userId, subscriptionId, expiredCount: expired.length }, - 'Expired active block grants for tier change', + 'Expired active block grants', ) } @@ -539,6 +539,7 @@ export async function getActiveSubscription(params: { eq(schema.subscription.status, 'active'), ), ) + .orderBy(desc(schema.subscription.updated_at)) .limit(1) return subs[0] ?? null From f95faaa958f4bdd2ad4dceb7c0ace8cc845b4188 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 16:36:53 -0800 Subject: [PATCH 21/28] Cleanup tier mapping code --- common/src/constants/subscription-plans.ts | 16 +++++++++++++ packages/billing/src/subscription-webhooks.ts | 24 +++++-------------- .../stripe/change-subscription-tier/route.ts | 4 ++-- .../api/stripe/create-subscription/route.ts | 4 ++-- 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/common/src/constants/subscription-plans.ts b/common/src/constants/subscription-plans.ts index 632727a3bb..23309e2f43 100644 --- a/common/src/constants/subscription-plans.ts +++ b/common/src/constants/subscription-plans.ts @@ -31,3 +31,19 @@ export const SUBSCRIPTION_TIERS = { export type SubscriptionTierPrice = keyof typeof SUBSCRIPTION_TIERS export const DEFAULT_TIER = SUBSCRIPTION_TIERS[200] + +export function createSubscriptionPriceMappings(priceIds: Record) { + const priceToTier = Object.fromEntries( + Object.entries(priceIds).map(([tier, priceId]) => [priceId, Number(tier) as SubscriptionTierPrice]), + ) as Record + + function getTierFromPriceId(priceId: string): SubscriptionTierPrice | null { + return priceToTier[priceId] ?? null + } + + function getPriceIdFromTier(tier: SubscriptionTierPrice): string | null { + return priceIds[tier] ?? null + } + + return { getTierFromPriceId, getPriceIdFromTier } +} diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index b2f14a1df6..9311512359 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -1,5 +1,6 @@ import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { createSubscriptionPriceMappings } from '@codebuff/common/constants/subscription-plans' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' import { env } from '@codebuff/internal/env' @@ -12,7 +13,6 @@ import { eq } from 'drizzle-orm' import { expireActiveBlockGrants, handleSubscribe } from './subscription' -import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans' import type { Logger } from '@codebuff/common/types/contracts/logger' import type Stripe from 'stripe' @@ -27,23 +27,11 @@ function mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus return 'incomplete' } -const priceToTier: Record = { - ...(env.STRIPE_SUBSCRIPTION_100_PRICE_ID && { [env.STRIPE_SUBSCRIPTION_100_PRICE_ID]: 100 as const }), - ...(env.STRIPE_SUBSCRIPTION_200_PRICE_ID && { [env.STRIPE_SUBSCRIPTION_200_PRICE_ID]: 200 as const }), - ...(env.STRIPE_SUBSCRIPTION_500_PRICE_ID && { [env.STRIPE_SUBSCRIPTION_500_PRICE_ID]: 500 as const }), -} - -function getTierFromPriceId(priceId: string): SubscriptionTierPrice | null { - return priceToTier[priceId] ?? null -} - -const tierToPrice = Object.fromEntries( - Object.entries(priceToTier).map(([priceId, tier]) => [tier, priceId]), -) as Partial> - -export function getTierPriceId(tier: SubscriptionTierPrice): string | null { - return tierToPrice[tier] ?? null -} +export const { getTierFromPriceId, getPriceIdFromTier } = createSubscriptionPriceMappings({ + 100: env.STRIPE_SUBSCRIPTION_100_PRICE_ID, + 200: env.STRIPE_SUBSCRIPTION_200_PRICE_ID, + 500: env.STRIPE_SUBSCRIPTION_500_PRICE_ID, +}) // --------------------------------------------------------------------------- // invoice.paid diff --git a/web/src/app/api/stripe/change-subscription-tier/route.ts b/web/src/app/api/stripe/change-subscription-tier/route.ts index 77b73ebf4c..5521cfedb0 100644 --- a/web/src/app/api/stripe/change-subscription-tier/route.ts +++ b/web/src/app/api/stripe/change-subscription-tier/route.ts @@ -1,7 +1,7 @@ import { expireActiveBlockGrants, getActiveSubscription, - getTierPriceId, + getPriceIdFromTier, } from '@codebuff/billing' import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' @@ -66,7 +66,7 @@ export async function POST(req: NextRequest) { ) } - const newPriceId = getTierPriceId(tier) + const newPriceId = getPriceIdFromTier(tier) if (!newPriceId) { return NextResponse.json( { error: 'Subscription tier not available' }, diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts index 2db8748a48..a7641385c8 100644 --- a/web/src/app/api/stripe/create-subscription/route.ts +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -1,4 +1,4 @@ -import { getActiveSubscription, getTierPriceId } from '@codebuff/billing' +import { getActiveSubscription, getPriceIdFromTier } from '@codebuff/billing' import { SUBSCRIPTION_TIERS } from '@codebuff/common/constants/subscription-plans' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -28,7 +28,7 @@ export async function POST(req: NextRequest) { ? rawTier : 200) as SubscriptionTierPrice - const priceId = getTierPriceId(tier) + const priceId = getPriceIdFromTier(tier) if (!priceId) { return NextResponse.json( { error: 'Subscription tier not available' }, From f114adfc6cf8f63592935a6385b362b8bf29151b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 16:55:32 -0800 Subject: [PATCH 22/28] Tweaks and don't downgrade tier immediately if switch plans --- packages/billing/src/subscription-webhooks.ts | 74 +++++++++++++------ packages/billing/src/subscription.ts | 2 +- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 9311512359..5c86dccd14 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -41,7 +41,7 @@ export const { getTierFromPriceId, getPriceIdFromTier } = createSubscriptionPric * Handles a paid invoice for a subscription. * * - On first payment (`subscription_create`): calls `handleSubscribe` to - * migrate the user's renewal date and unused credits (Option B). + * migrate the user's renewal date and unused credits. * - On every payment: upserts the `subscription` row with fresh billing * period dates from Stripe. */ @@ -83,22 +83,23 @@ export async function handleSubscriptionInvoicePaid(params: { } // Look up the user for this customer - const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null + const user = await getUserByStripeCustomerId(customerId) + if (!user) { + logger.warn( + { customerId, subscriptionId }, + 'No user found for customer — skipping handleSubscribe', + ) + return + } + const userId = user.id - // On first invoice, migrate renewal date & credits (Option B) + // On first invoice, migrate renewal date & credits if (invoice.billing_reason === 'subscription_create') { - if (userId) { - await handleSubscribe({ - userId, - stripeSubscription: stripeSub, - logger, - }) - } else { - logger.warn( - { customerId, subscriptionId }, - 'No user found for customer — skipping handleSubscribe', - ) - } + await handleSubscribe({ + userId, + stripeSubscription: stripeSub, + logger, + }) } const status = mapStripeStatus(stripeSub.status) @@ -121,7 +122,7 @@ export async function handleSubscriptionInvoicePaid(params: { target: schema.subscription.stripe_subscription_id, set: { status, - ...(userId ? { user_id: userId } : {}), + user_id: userId, stripe_price_id: priceId, tier, billing_period_start: new Date( @@ -160,9 +161,11 @@ export async function handleSubscriptionInvoicePaymentFailed(params: { if (!invoice.subscription) return const subscriptionId = getStripeId(invoice.subscription) const customerId = getStripeId(invoice.customer) - const userId = customerId - ? (await getUserByStripeCustomerId(customerId))?.id ?? null - : null + let userId = null + if (customerId) { + const user = await getUserByStripeCustomerId(customerId) + userId = user?.id + } await db .update(schema.subscription) @@ -218,10 +221,29 @@ export async function handleSubscriptionUpdated(params: { } const customerId = getStripeId(stripeSubscription.customer) - const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null + const user = await getUserByStripeCustomerId(customerId) + if (!user) { + logger.warn( + { customerId, subscriptionId }, + 'No user found for customer — skipping', + ) + return + } + const userId = user.id const status = mapStripeStatus(stripeSubscription.status) + // Check existing tier to detect downgrades — downgrades preserve the + // current tier until the next billing period (invoice.paid updates it). + const existingSub = await db + .select({ tier: schema.subscription.tier }) + .from(schema.subscription) + .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) + .limit(1) + + const existingTier = existingSub[0]?.tier + const isDowngrade = existingTier != null && existingTier > tier + // Upsert — webhook ordering is not guaranteed by Stripe, so this event // may arrive before invoice.paid creates the row. await db @@ -244,9 +266,9 @@ export async function handleSubscriptionUpdated(params: { .onConflictDoUpdate({ target: schema.subscription.stripe_subscription_id, set: { - ...(userId ? { user_id: userId } : {}), + user_id: userId, stripe_price_id: priceId, - tier, + ...(isDowngrade ? {} : { tier }), status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( @@ -263,8 +285,11 @@ export async function handleSubscriptionUpdated(params: { { subscriptionId, cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, + isDowngrade, }, - 'Processed subscription update', + isDowngrade + ? 'Processed subscription update — downgrade deferred to next billing period' + : 'Processed subscription update', ) } @@ -283,7 +308,8 @@ export async function handleSubscriptionDeleted(params: { const subscriptionId = stripeSubscription.id const customerId = getStripeId(stripeSubscription.customer) - const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null + const user = await getUserByStripeCustomerId(customerId) + const userId = user?.id ?? null await db .update(schema.subscription) diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 80c795af29..98619a100e 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -554,7 +554,7 @@ export async function isSubscriber(params: { } // --------------------------------------------------------------------------- -// Subscribe flow (Option B — unify renewal dates) +// Subscribe flow // --------------------------------------------------------------------------- /** From 8eafcfb7cacaab17d1d585a243c97aa74259b7b7 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 21:34:23 -0800 Subject: [PATCH 23/28] scheduled_tier used for downgrade --- packages/billing/src/subscription-webhooks.ts | 56 +- packages/billing/src/subscription.ts | 1 - .../db/migrations/0038_legal_jimmy_woo.sql | 1 + .../src/db/migrations/meta/0038_snapshot.json | 3063 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + packages/internal/src/db/schema.ts | 1 + .../api/stripe/cancel-subscription/route.ts | 2 +- .../stripe/change-subscription-tier/route.ts | 96 +- web/src/app/api/user/subscription/route.ts | 1 + 9 files changed, 3202 insertions(+), 26 deletions(-) create mode 100644 packages/internal/src/db/migrations/0038_legal_jimmy_woo.sql create mode 100644 packages/internal/src/db/migrations/meta/0038_snapshot.json diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 5c86dccd14..4be7c5a6db 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -104,7 +104,21 @@ export async function handleSubscriptionInvoicePaid(params: { const status = mapStripeStatus(stripeSub.status) - // Upsert subscription row + // Check for a pending scheduled tier change (downgrade) + const existingSub = await db + .select({ + tier: schema.subscription.tier, + scheduled_tier: schema.subscription.scheduled_tier, + }) + .from(schema.subscription) + .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) + .limit(1) + + const previousTier = existingSub[0]?.tier + const hadScheduledTier = existingSub[0]?.scheduled_tier != null + + // Upsert subscription row — always apply the Stripe tier and clear + // scheduled_tier so pending downgrades take effect on renewal. await db .insert(schema.subscription) .values({ @@ -113,6 +127,7 @@ export async function handleSubscriptionInvoicePaid(params: { user_id: userId, stripe_price_id: priceId, tier, + scheduled_tier: null, status, billing_period_start: new Date(stripeSub.current_period_start * 1000), billing_period_end: new Date(stripeSub.current_period_end * 1000), @@ -125,6 +140,7 @@ export async function handleSubscriptionInvoicePaid(params: { user_id: userId, stripe_price_id: priceId, tier, + scheduled_tier: null, billing_period_start: new Date( stripeSub.current_period_start * 1000, ), @@ -134,6 +150,16 @@ export async function handleSubscriptionInvoicePaid(params: { }, }) + // If a scheduled downgrade was applied, expire block grants so the user + // gets new grants at the lower tier's limits. + if (hadScheduledTier) { + await expireActiveBlockGrants({ userId, subscriptionId, logger }) + logger.info( + { userId, subscriptionId, previousTier, tier }, + 'Applied scheduled tier change and expired block grants', + ) + } + logger.info( { subscriptionId, @@ -233,10 +259,13 @@ export async function handleSubscriptionUpdated(params: { const status = mapStripeStatus(stripeSubscription.status) - // Check existing tier to detect downgrades — downgrades preserve the - // current tier until the next billing period (invoice.paid updates it). + // Check existing tier to detect downgrades. During a downgrade the old + // higher tier is kept in `scheduled_tier` so limits remain until renewal. const existingSub = await db - .select({ tier: schema.subscription.tier }) + .select({ + tier: schema.subscription.tier, + scheduled_tier: schema.subscription.scheduled_tier, + }) .from(schema.subscription) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) .limit(1) @@ -267,8 +296,11 @@ export async function handleSubscriptionUpdated(params: { target: schema.subscription.stripe_subscription_id, set: { user_id: userId, - stripe_price_id: priceId, - ...(isDowngrade ? {} : { tier }), + // Downgrade: preserve current tier & stripe_price_id, schedule the + // new tier for the next billing period. + ...(isDowngrade + ? { scheduled_tier: tier } + : { tier, stripe_price_id: priceId, scheduled_tier: null }), status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( @@ -281,14 +313,23 @@ export async function handleSubscriptionUpdated(params: { }, }) + // If this is an upgrade, expire old block grants so the user gets new + // grants at the higher tier's limits. Also serves as a fallback if the + // route handler's DB update failed. + const isUpgrade = existingTier != null && tier > existingTier + if (isUpgrade) { + await expireActiveBlockGrants({ userId, subscriptionId, logger }) + } + logger.info( { subscriptionId, cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, isDowngrade, + isUpgrade, }, isDowngrade - ? 'Processed subscription update — downgrade deferred to next billing period' + ? 'Processed subscription update — downgrade scheduled for next billing period' : 'Processed subscription update', ) } @@ -315,6 +356,7 @@ export async function handleSubscriptionDeleted(params: { .update(schema.subscription) .set({ status: 'canceled', + scheduled_tier: null, canceled_at: new Date(), updated_at: new Date(), }) diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 98619a100e..1e57515188 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -505,7 +505,6 @@ export async function expireActiveBlockGrants(params: { eq(schema.creditLedger.user_id, userId), eq(schema.creditLedger.type, 'subscription'), gt(schema.creditLedger.expires_at, now), - gt(schema.creditLedger.balance, 0), ), ) .returning({ operation_id: schema.creditLedger.operation_id }) diff --git a/packages/internal/src/db/migrations/0038_legal_jimmy_woo.sql b/packages/internal/src/db/migrations/0038_legal_jimmy_woo.sql new file mode 100644 index 0000000000..e774d01927 --- /dev/null +++ b/packages/internal/src/db/migrations/0038_legal_jimmy_woo.sql @@ -0,0 +1 @@ +ALTER TABLE "subscription" ADD COLUMN "scheduled_tier" integer; \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0038_snapshot.json b/packages/internal/src/db/migrations/meta/0038_snapshot.json new file mode 100644 index 0000000000..60ed1a864a --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0038_snapshot.json @@ -0,0 +1,3063 @@ +{ + "id": "43f3712d-1692-4c3f-a029-54a9c66d293c", + "prevId": "98d944a6-d8c5-41c6-a491-dc70211eca98", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": [ + "imp_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": [ + "publisher_id", + "id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": [ + "agent_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": [ + "user_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": [ + "org_id", + "feature" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "accepted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": [ + "referrer_id", + "referred_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": [ + "fingerprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_tier": { + "name": "scheduled_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": [ + "pending", + "completed" + ] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": [ + "running", + "completed", + "failed", + "cancelled" + ] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": [ + "running", + "completed", + "skipped" + ] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": [ + "anthropic", + "gemini", + "openai" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "subscription", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": [ + "web", + "pat", + "cli" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index 9bc22ce110..067c221944 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -267,6 +267,13 @@ "when": 1769637004165, "tag": "0037_many_millenium_guard", "breakpoints": true + }, + { + "idx": 38, + "version": "7", + "when": 1769649819008, + "tag": "0038_legal_jimmy_woo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 693ff37be6..24ec326fe3 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -468,6 +468,7 @@ export const subscription = pgTable( user_id: text('user_id').references(() => user.id, { onDelete: 'cascade' }), stripe_price_id: text('stripe_price_id').notNull(), tier: integer('tier'), + scheduled_tier: integer('scheduled_tier'), status: subscriptionStatusEnum('status').notNull().default('active'), billing_period_start: timestamp('billing_period_start', { mode: 'date', diff --git a/web/src/app/api/stripe/cancel-subscription/route.ts b/web/src/app/api/stripe/cancel-subscription/route.ts index a34f5312a9..6a95c130f3 100644 --- a/web/src/app/api/stripe/cancel-subscription/route.ts +++ b/web/src/app/api/stripe/cancel-subscription/route.ts @@ -33,7 +33,7 @@ export async function POST() { await db .update(schema.subscription) - .set({ cancel_at_period_end: true, updated_at: new Date() }) + .set({ cancel_at_period_end: true, scheduled_tier: null, updated_at: new Date() }) .where( eq( schema.subscription.stripe_subscription_id, diff --git a/web/src/app/api/stripe/change-subscription-tier/route.ts b/web/src/app/api/stripe/change-subscription-tier/route.ts index 5521cfedb0..a0a17aff0c 100644 --- a/web/src/app/api/stripe/change-subscription-tier/route.ts +++ b/web/src/app/api/stripe/change-subscription-tier/route.ts @@ -58,14 +58,34 @@ export async function POST(req: NextRequest) { ) } - const previousTier = subscription.tier - if (previousTier === tier) { + if (subscription.tier == null) { + logger.error( + { userId, subscriptionId: subscription.stripe_subscription_id }, + 'Subscription has no tier configured', + ) + return NextResponse.json( + { error: 'Subscription has no tier configured.' }, + { status: 400 }, + ) + } + + if (tier === subscription.tier && subscription.scheduled_tier == null) { return NextResponse.json( { error: 'Already on the requested tier.' }, { status: 400 }, ) } + if (subscription.scheduled_tier === tier) { + return NextResponse.json( + { error: 'Already scheduled for that tier.' }, + { status: 400 }, + ) + } + + const isCancelDowngrade = tier === subscription.tier && subscription.scheduled_tier != null + const isUpgrade = !isCancelDowngrade && tier > subscription.tier + const newPriceId = getPriceIdFromTier(tier) if (!newPriceId) { return NextResponse.json( @@ -94,27 +114,59 @@ export async function POST(req: NextRequest) { subscription.stripe_subscription_id, { items: [{ id: itemId, price: newPriceId }], - proration_behavior: 'create_prorations', + proration_behavior: isUpgrade ? 'always_invoice' : 'none', }, ) try { - await Promise.all([ - db + if (isCancelDowngrade) { + await db + .update(schema.subscription) + .set({ scheduled_tier: null, updated_at: new Date() }) + .where( + eq( + schema.subscription.stripe_subscription_id, + subscription.stripe_subscription_id, + ), + ) + } else if (isUpgrade) { + await Promise.all([ + db + .update(schema.subscription) + .set({ + tier, + stripe_price_id: newPriceId, + scheduled_tier: null, + updated_at: new Date(), + }) + .where( + eq( + schema.subscription.stripe_subscription_id, + subscription.stripe_subscription_id, + ), + ), + expireActiveBlockGrants({ + userId, + subscriptionId: subscription.stripe_subscription_id, + logger, + }), + ]) + } else { + // Downgrade — only schedule the new lower tier for next billing period. + // Keep current tier and stripe_price_id unchanged so limits stay. + await db .update(schema.subscription) - .set({ tier, stripe_price_id: newPriceId, updated_at: new Date() }) + .set({ + scheduled_tier: tier, + updated_at: new Date(), + }) .where( eq( schema.subscription.stripe_subscription_id, subscription.stripe_subscription_id, ), - ), - expireActiveBlockGrants({ - userId, - subscriptionId: subscription.stripe_subscription_id, - logger, - }), - ]) + ) + } } catch (dbError) { logger.error( { error: dbError, userId, subscriptionId: subscription.stripe_subscription_id }, @@ -127,23 +179,33 @@ export async function POST(req: NextRequest) { userId, properties: { subscriptionId: subscription.stripe_subscription_id, - previousTier, + previousTier: subscription.tier, newTier: tier, + isUpgrade, + isCancelDowngrade, }, logger, }) + const logMessage = isCancelDowngrade + ? 'Pending downgrade canceled' + : isUpgrade + ? 'Subscription upgraded — billed immediately' + : 'Subscription downgraded — scheduled for next billing period' + logger.info( { userId, subscriptionId: subscription.stripe_subscription_id, - previousTier, + previousTier: subscription.tier, newTier: tier, + isUpgrade, + isCancelDowngrade, }, - 'Subscription tier changed', + logMessage, ) - return NextResponse.json({ success: true, previousTier, newTier: tier }) + return NextResponse.json({ success: true, previousTier: subscription.tier, newTier: tier }) } catch (error: unknown) { const message = error instanceof Error ? error.message diff --git a/web/src/app/api/user/subscription/route.ts b/web/src/app/api/user/subscription/route.ts index 34faa02c17..c8d53b8dbd 100644 --- a/web/src/app/api/user/subscription/route.ts +++ b/web/src/app/api/user/subscription/route.ts @@ -37,6 +37,7 @@ export async function GET() { cancelAtPeriodEnd: subscription.cancel_at_period_end, canceledAt: subscription.canceled_at?.toISOString() ?? null, tier: subscription.tier, + scheduledTier: subscription.scheduled_tier, }, rateLimit: { limited: rateLimit.limited, From a6f9ba161dfadc46efe6c29bf7ae509ec4ef27e3 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 22:39:51 -0800 Subject: [PATCH 24/28] Don't default to a tier when creating a subscription --- .../api/stripe/change-subscription-tier/route.ts | 2 +- .../app/api/stripe/create-subscription/route.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/web/src/app/api/stripe/change-subscription-tier/route.ts b/web/src/app/api/stripe/change-subscription-tier/route.ts index a0a17aff0c..ac5b9f245d 100644 --- a/web/src/app/api/stripe/change-subscription-tier/route.ts +++ b/web/src/app/api/stripe/change-subscription-tier/route.ts @@ -44,7 +44,7 @@ export async function POST(req: NextRequest) { const rawTier = Number(body?.tier) if (!rawTier || !(rawTier in SUBSCRIPTION_TIERS)) { return NextResponse.json( - { error: 'Invalid tier. Must be 100, 200, or 500.' }, + { error: `Invalid tier. Must be one of: ${Object.keys(SUBSCRIPTION_TIERS).join(', ')}.` }, { status: 400 }, ) } diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts index a7641385c8..202228e70c 100644 --- a/web/src/app/api/stripe/create-subscription/route.ts +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -22,11 +22,15 @@ export async function POST(req: NextRequest) { const userId = session.user.id - const body = await req.json().catch(() => ({})) - const rawTier = Number(body.tier) - const tier = (rawTier && rawTier in SUBSCRIPTION_TIERS - ? rawTier - : 200) as SubscriptionTierPrice + const body = await req.json().catch(() => null) + const rawTier = Number(body?.tier) + if (!rawTier || !(rawTier in SUBSCRIPTION_TIERS)) { + return NextResponse.json( + { error: `Invalid tier. Must be one of: ${Object.keys(SUBSCRIPTION_TIERS).join(', ')}.` }, + { status: 400 }, + ) + } + const tier = rawTier as SubscriptionTierPrice const priceId = getPriceIdFromTier(tier) if (!priceId) { @@ -77,7 +81,7 @@ export async function POST(req: NextRequest) { type: 'strong_subscription', }, subscription_data: { - description: `Codebuff Strong — $${tier}/mo unlimited coding sessions`, + description: `Codebuff Strong — $${tier}/mo`, metadata: { userId, }, From 40921f78ddcc3092a1e3c84c2b1a12868e958fa2 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 23:03:30 -0800 Subject: [PATCH 25/28] Unit subscription.ts with DI patterns and unit tests --- .../src/__tests__/subscription.test.ts | 633 ++++++++++++++++++ packages/billing/src/subscription.ts | 266 ++++---- 2 files changed, 776 insertions(+), 123 deletions(-) create mode 100644 packages/billing/src/__tests__/subscription.test.ts diff --git a/packages/billing/src/__tests__/subscription.test.ts b/packages/billing/src/__tests__/subscription.test.ts new file mode 100644 index 0000000000..d4dc662dd2 --- /dev/null +++ b/packages/billing/src/__tests__/subscription.test.ts @@ -0,0 +1,633 @@ +import { describe, expect, it } from 'bun:test' + +import { + DEFAULT_TIER, + SUBSCRIPTION_TIERS, +} from '@codebuff/common/constants/subscription-plans' +import type { Logger } from '@codebuff/common/types/contracts/logger' + +import { + checkRateLimit, + ensureActiveBlockGrantCallback, + expireActiveBlockGrants, + getWeekEnd, + getWeekStart, + getSubscriptionLimits, + isWeeklyLimitError, + migrateUnusedCredits, +} from '../subscription' + +import type { BlockGrant, SubscriptionRow, WeeklyLimitError } from '../subscription' + +const logger: Logger = { + debug: () => {}, + error: () => {}, + info: () => {}, + warn: () => {}, +} + +// Helper to create a UTC date on a specific day-of-week +// dayOfWeek: 0=Sun, 1=Mon, ..., 6=Sat +function utcDate(year: number, month: number, day: number): Date { + const d = new Date(Date.UTC(year, month - 1, day)) + return d +} + +function createMockSubscription(overrides?: Partial<{ + stripe_subscription_id: string + tier: number + billing_period_start: Date +}>) { + return { + stripe_subscription_id: 'sub-test-123', + tier: 200, + billing_period_start: utcDate(2025, 1, 8), // Wednesday + user_id: 'user-123', + status: 'active', + ...overrides, + } as SubscriptionRow +} + +interface MockCaptures { + insertValues: Record[] + updateSets: Record[] +} + +function createSequentialMock(options: { + selectResults?: unknown[][] + updateResults?: unknown[][] + insertResults?: unknown[][] +}): { conn: any; captures: MockCaptures } { + let selectIdx = 0 + let updateIdx = 0 + let insertIdx = 0 + const captures: MockCaptures = { insertValues: [], updateSets: [] } + + function makeChain(result: unknown, type?: 'insert' | 'update'): Record { + const chain: Record = {} + for (const m of ['from', 'where', 'orderBy', 'limit', 'returning', 'onConflictDoNothing']) { + chain[m] = () => chain + } + chain.values = (data: Record) => { + if (type === 'insert') captures.insertValues.push(data) + return chain + } + chain.set = (data: Record) => { + if (type === 'update') captures.updateSets.push(data) + return chain + } + chain.then = (resolve: (v: unknown) => void, reject?: (e: unknown) => void) => + Promise.resolve(result).then(resolve, reject) + return chain + } + + const conn = { + select: () => { + const result = (options.selectResults ?? [])[selectIdx] ?? [] + selectIdx++ + return makeChain(result) + }, + update: () => { + const result = (options.updateResults ?? [])[updateIdx] ?? [] + updateIdx++ + return makeChain(result, 'update') + }, + insert: () => { + const result = (options.insertResults ?? [])[insertIdx] ?? [] + insertIdx++ + return makeChain(result, 'insert') + }, + } + + return { conn, captures } +} + +describe('subscription', () => { + describe('getWeekStart', () => { + it('should return start of today when now is the same day-of-week as billing start', () => { + // 2025-01-08 is a Wednesday (3) + const billingStart = utcDate(2025, 1, 8) + // 2025-01-15 is also a Wednesday (3) + const now = utcDate(2025, 1, 15) + + const result = getWeekStart(billingStart, now) + + expect(result).toEqual(utcDate(2025, 1, 15)) + }) + + it('should go back to the billing day-of-week when now is later in the week', () => { + // 2025-01-08 is a Wednesday (3) + const billingStart = utcDate(2025, 1, 8) + // 2025-01-17 is a Friday (5) — 2 days after Wednesday + const now = utcDate(2025, 1, 17) + + const result = getWeekStart(billingStart, now) + + // Should go back to Wednesday 2025-01-15 + expect(result).toEqual(utcDate(2025, 1, 15)) + }) + + it('should go back to previous week billing day when now is earlier in the week', () => { + // 2025-01-08 is a Wednesday (3) + const billingStart = utcDate(2025, 1, 8) + // 2025-01-13 is a Monday (1) — before Wednesday + const now = utcDate(2025, 1, 13) + + const result = getWeekStart(billingStart, now) + + // Should go back 5 days to Wednesday 2025-01-08 + expect(result).toEqual(utcDate(2025, 1, 8)) + }) + + it('should handle billing start on Sunday with now on Saturday', () => { + // 2025-01-05 is a Sunday (0) + const billingStart = utcDate(2025, 1, 5) + // 2025-01-18 is a Saturday (6) — 6 days after Sunday + const now = utcDate(2025, 1, 18) + + const result = getWeekStart(billingStart, now) + + // Should go back 6 days to Sunday 2025-01-12 + expect(result).toEqual(utcDate(2025, 1, 12)) + }) + + it('should handle billing start on Saturday with now on Sunday', () => { + // 2025-01-04 is a Saturday (6) + const billingStart = utcDate(2025, 1, 4) + // 2025-01-12 is a Sunday (0) — 1 day after Saturday + const now = utcDate(2025, 1, 12) + + const result = getWeekStart(billingStart, now) + + // Should go back 1 day to Saturday 2025-01-11 + expect(result).toEqual(utcDate(2025, 1, 11)) + }) + + it('should zero out hours/minutes/seconds', () => { + const billingStart = utcDate(2025, 1, 8) // Wednesday + const now = new Date(Date.UTC(2025, 0, 17, 14, 30, 45, 123)) // Friday with time + + const result = getWeekStart(billingStart, now) + + expect(result.getUTCHours()).toBe(0) + expect(result.getUTCMinutes()).toBe(0) + expect(result.getUTCSeconds()).toBe(0) + expect(result.getUTCMilliseconds()).toBe(0) + }) + }) + + describe('getWeekEnd', () => { + it('should return exactly 7 days after week start', () => { + const billingStart = utcDate(2025, 1, 8) // Wednesday + const now = utcDate(2025, 1, 17) // Friday + + const weekStart = getWeekStart(billingStart, now) + const weekEnd = getWeekEnd(billingStart, now) + + const diffMs = weekEnd.getTime() - weekStart.getTime() + const diffDays = diffMs / (24 * 60 * 60 * 1000) + + expect(diffDays).toBe(7) + }) + + it('should return start of next billing-aligned week', () => { + // 2025-01-08 is a Wednesday + const billingStart = utcDate(2025, 1, 8) + // 2025-01-17 is a Friday → week start is Wed 2025-01-15 + const now = utcDate(2025, 1, 17) + + const result = getWeekEnd(billingStart, now) + + // Next Wednesday: 2025-01-22 + expect(result).toEqual(utcDate(2025, 1, 22)) + }) + }) + + describe('isWeeklyLimitError', () => { + it('should return true for WeeklyLimitError', () => { + const error: WeeklyLimitError = { + error: 'weekly_limit_reached', + used: 1000, + limit: 1000, + resetsAt: new Date(), + } + + expect(isWeeklyLimitError(error)).toBe(true) + }) + + it('should return false for BlockGrant', () => { + const grant: BlockGrant = { + grantId: 'grant-1', + credits: 500, + expiresAt: new Date(), + isNew: true, + } + + expect(isWeeklyLimitError(grant)).toBe(false) + }) + }) + + describe('getSubscriptionLimits', () => { + function createConnMock(overrides: Array<{ + credits_per_block: number + block_duration_hours: number + weekly_credit_limit: number + }>) { + return { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => overrides, + }), + }), + }), + update: () => ({}), + insert: () => ({}), + } as any + } + + it('should use limit override when one exists', async () => { + const conn = createConnMock([{ + credits_per_block: 9999, + block_duration_hours: 10, + weekly_credit_limit: 50000, + }]) + + const result = await getSubscriptionLimits({ + userId: 'user-123', + logger, + conn, + tier: 200, + }) + + expect(result).toEqual({ + creditsPerBlock: 9999, + blockDurationHours: 10, + weeklyCreditsLimit: 50000, + }) + }) + + it('should use tier config when no override exists and tier is valid', async () => { + const conn = createConnMock([]) + + const result = await getSubscriptionLimits({ + userId: 'user-123', + logger, + conn, + tier: 100, + }) + + expect(result).toEqual({ + creditsPerBlock: SUBSCRIPTION_TIERS[100].creditsPerBlock, + blockDurationHours: SUBSCRIPTION_TIERS[100].blockDurationHours, + weeklyCreditsLimit: SUBSCRIPTION_TIERS[100].weeklyCreditsLimit, + }) + }) + + it('should fall back to DEFAULT_TIER when tier is null', async () => { + const conn = createConnMock([]) + + const result = await getSubscriptionLimits({ + userId: 'user-123', + logger, + conn, + tier: null, + }) + + expect(result).toEqual({ + creditsPerBlock: DEFAULT_TIER.creditsPerBlock, + blockDurationHours: DEFAULT_TIER.blockDurationHours, + weeklyCreditsLimit: DEFAULT_TIER.weeklyCreditsLimit, + }) + }) + + it('should fall back to DEFAULT_TIER when tier is invalid', async () => { + const conn = createConnMock([]) + + const result = await getSubscriptionLimits({ + userId: 'user-123', + logger, + conn, + tier: 999, + }) + + expect(result).toEqual({ + creditsPerBlock: DEFAULT_TIER.creditsPerBlock, + blockDurationHours: DEFAULT_TIER.blockDurationHours, + weeklyCreditsLimit: DEFAULT_TIER.weeklyCreditsLimit, + }) + }) + + }) + + describe('migrateUnusedCredits', () => { + const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + + it('should insert idempotency marker when no unused grants exist', async () => { + const { conn, captures } = createSequentialMock({ + selectResults: [[]], // no unused grants + }) + + await migrateUnusedCredits({ + tx: conn, + userId: 'user-123', + subscriptionId: 'sub-123', + expiresAt: futureDate, + logger, + }) + + expect(captures.insertValues).toHaveLength(1) + expect(captures.insertValues[0].operation_id).toBe('subscribe-migrate-sub-123') + expect(captures.insertValues[0].principal).toBe(0) + expect(captures.insertValues[0].balance).toBe(0) + }) + + it('should zero old grants and create migration grant with correct total', async () => { + const { conn, captures } = createSequentialMock({ + selectResults: [[ + { operation_id: 'g1', balance: 300 }, + { operation_id: 'g2', balance: 200 }, + ]], + }) + + await migrateUnusedCredits({ + tx: conn, + userId: 'user-123', + subscriptionId: 'sub-123', + expiresAt: futureDate, + logger, + }) + + expect(captures.updateSets).toHaveLength(2) + expect(captures.updateSets[0]).toEqual({ balance: 0 }) + expect(captures.updateSets[1]).toEqual({ balance: 0 }) + + expect(captures.insertValues).toHaveLength(1) + expect(captures.insertValues[0].principal).toBe(500) + expect(captures.insertValues[0].balance).toBe(500) + expect(captures.insertValues[0].operation_id).toBe('subscribe-migrate-sub-123') + expect(captures.insertValues[0].type).toBe('free') + }) + }) + + describe('expireActiveBlockGrants', () => { + it('should return count of expired grants', async () => { + const { conn } = createSequentialMock({ + updateResults: [[{ operation_id: 'op1' }, { operation_id: 'op2' }]], + }) + + const count = await expireActiveBlockGrants({ + userId: 'user-123', + subscriptionId: 'sub-123', + logger, + conn, + }) + + expect(count).toBe(2) + }) + + it('should return 0 when no active grants exist', async () => { + const { conn } = createSequentialMock({ + updateResults: [[]], + }) + + const count = await expireActiveBlockGrants({ + userId: 'user-123', + subscriptionId: 'sub-123', + logger, + conn, + }) + + expect(count).toBe(0) + }) + }) + + describe('checkRateLimit', () => { + const subscription = createMockSubscription() + + it('should report weekly_limit when usage reaches limit', async () => { + // tier 200 → weeklyCreditsLimit: 12000 + const { conn } = createSequentialMock({ + selectResults: [ + [], // no limit overrides + [{ total: 12000 }], // weekly usage at limit + ], + }) + + const result = await checkRateLimit({ + userId: 'user-123', + subscription, + logger, + conn, + }) + + expect(result.limited).toBe(true) + expect(result.reason).toBe('weekly_limit') + expect(result.canStartNewBlock).toBe(false) + expect(result.weeklyUsed).toBe(12000) + expect(result.weeklyLimit).toBe(SUBSCRIPTION_TIERS[200].weeklyCreditsLimit) + }) + + it('should allow new block when no active block exists', async () => { + const { conn } = createSequentialMock({ + selectResults: [ + [], // no limit overrides + [{ total: 5000 }], // under weekly limit + [], // no active blocks + ], + }) + + const result = await checkRateLimit({ + userId: 'user-123', + subscription, + logger, + conn, + }) + + expect(result.limited).toBe(false) + expect(result.canStartNewBlock).toBe(true) + expect(result.weeklyUsed).toBe(5000) + }) + + it('should report block_exhausted when block has no balance', async () => { + const futureExpiry = new Date(Date.now() + 3 * 60 * 60 * 1000) + const { conn } = createSequentialMock({ + selectResults: [ + [], // no limit overrides + [{ total: 5000 }], // under weekly limit + [{ balance: 0, principal: 1200, expires_at: futureExpiry }], + ], + }) + + const result = await checkRateLimit({ + userId: 'user-123', + subscription, + logger, + conn, + }) + + expect(result.limited).toBe(true) + expect(result.reason).toBe('block_exhausted') + expect(result.blockUsed).toBe(1200) + expect(result.blockLimit).toBe(1200) + }) + + it('should report not limited when block has remaining credits', async () => { + const futureExpiry = new Date(Date.now() + 3 * 60 * 60 * 1000) + const { conn } = createSequentialMock({ + selectResults: [ + [], // no limit overrides + [{ total: 5000 }], // under weekly limit + [{ balance: 800, principal: 1200, expires_at: futureExpiry }], + ], + }) + + const result = await checkRateLimit({ + userId: 'user-123', + subscription, + logger, + conn, + }) + + expect(result.limited).toBe(false) + expect(result.canStartNewBlock).toBe(false) + expect(result.blockUsed).toBe(400) + expect(result.blockLimit).toBe(1200) + }) + }) + + describe('ensureActiveBlockGrantCallback', () => { + const subscription = createMockSubscription() + + it('should return existing active grant', async () => { + const futureExpiry = new Date(Date.now() + 3 * 60 * 60 * 1000) + const { conn } = createSequentialMock({ + selectResults: [ + [{ operation_id: 'existing-grant', balance: 500, expires_at: futureExpiry }], + ], + }) + + const result = await ensureActiveBlockGrantCallback({ + conn, + userId: 'user-123', + subscription, + logger, + }) + + expect(isWeeklyLimitError(result)).toBe(false) + const grant = result as BlockGrant + expect(grant.grantId).toBe('existing-grant') + expect(grant.credits).toBe(500) + expect(grant.isNew).toBe(false) + }) + + it('should return weekly limit error when limit is reached', async () => { + // tier 200 → weeklyCreditsLimit: 12000 + const { conn } = createSequentialMock({ + selectResults: [ + [], // no existing grants + [], // no limit overrides + [{ total: 12000 }], // weekly limit reached + ], + }) + + const result = await ensureActiveBlockGrantCallback({ + conn, + userId: 'user-123', + subscription, + logger, + }) + + expect(isWeeklyLimitError(result)).toBe(true) + const error = result as WeeklyLimitError + expect(error.error).toBe('weekly_limit_reached') + expect(error.used).toBe(12000) + expect(error.limit).toBe(SUBSCRIPTION_TIERS[200].weeklyCreditsLimit) + }) + + it('should create new block grant when none exists', async () => { + const now = new Date('2025-01-15T10:00:00Z') + const { conn } = createSequentialMock({ + selectResults: [ + [], // no existing grants + [], // no limit overrides + [{ total: 0 }], // no weekly usage + ], + insertResults: [ + [{ operation_id: 'new-block-grant' }], + ], + }) + + const result = await ensureActiveBlockGrantCallback({ + conn, + userId: 'user-123', + subscription, + logger, + now, + }) + + expect(isWeeklyLimitError(result)).toBe(false) + const grant = result as BlockGrant + expect(grant.isNew).toBe(true) + expect(grant.grantId).toBe('new-block-grant') + expect(grant.credits).toBe(SUBSCRIPTION_TIERS[200].creditsPerBlock) + expect(grant.expiresAt.getTime()).toBe( + now.getTime() + SUBSCRIPTION_TIERS[200].blockDurationHours * 60 * 60 * 1000, + ) + }) + + it('should cap block credits to weekly remaining', async () => { + // tier 200: creditsPerBlock=1200, weeklyCreditsLimit=12000 + // weekly used=11500 → remaining=500, block capped to 500 + const now = new Date('2025-01-15T10:00:00Z') + const { conn, captures } = createSequentialMock({ + selectResults: [ + [], // no existing grants + [], // no limit overrides + [{ total: 11500 }], // 500 remaining + ], + insertResults: [ + [{ operation_id: 'capped-block' }], + ], + }) + + const result = await ensureActiveBlockGrantCallback({ + conn, + userId: 'user-123', + subscription, + logger, + now, + }) + + expect(isWeeklyLimitError(result)).toBe(false) + const grant = result as BlockGrant + expect(grant.credits).toBe(500) + expect(captures.insertValues[0].principal).toBe(500) + expect(captures.insertValues[0].balance).toBe(500) + }) + + it('should throw when insert returns no grant (duplicate operation)', async () => { + const now = new Date('2025-01-15T10:00:00Z') + const { conn } = createSequentialMock({ + selectResults: [ + [], // no existing grants + [], // no limit overrides + [{ total: 0 }], // no weekly usage + ], + insertResults: [ + [], // empty — simulates onConflictDoNothing + ], + }) + + await expect( + ensureActiveBlockGrantCallback({ + conn, + userId: 'user-123', + subscription, + logger, + now, + }), + ).rejects.toThrow('Failed to create block grant') + }) + }) +}) diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 1e57515188..a267fbc921 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -33,7 +33,7 @@ import type Stripe from 'stripe' export type SubscriptionRow = typeof schema.subscription.$inferSelect -type DbConn = Pick +export type DbConn = Pick export interface SubscriptionLimits { creditsPerBlock: number @@ -241,138 +241,154 @@ export async function getWeeklyUsage(params: { * * All operations are serialised under an advisory lock for the user. */ -export async function ensureActiveBlockGrant(params: { +export async function ensureActiveBlockGrantCallback(params: { + conn: DbConn userId: string subscription: SubscriptionRow logger: Logger + now?: Date }): Promise { - const { userId, subscription, logger } = params + const { conn, userId, subscription, logger, now = new Date() } = params const subscriptionId = subscription.stripe_subscription_id - const { result } = await withAdvisoryLockTransaction({ - callback: async (tx) => { - const now = new Date() + // 1. Check for an existing active block grant + const existingGrants = await conn + .select() + .from(schema.creditLedger) + .where( + and( + eq(schema.creditLedger.user_id, userId), + eq(schema.creditLedger.type, 'subscription'), + gt(schema.creditLedger.expires_at, now), + gt(schema.creditLedger.balance, 0), + ), + ) + .orderBy(desc(schema.creditLedger.expires_at)) + .limit(1) - // 1. Check for an existing active block grant - const existingGrants = await tx - .select() - .from(schema.creditLedger) - .where( - and( - eq(schema.creditLedger.user_id, userId), - eq(schema.creditLedger.type, 'subscription'), - gt(schema.creditLedger.expires_at, now), - gt(schema.creditLedger.balance, 0), - ), - ) - .orderBy(desc(schema.creditLedger.expires_at)) - .limit(1) + if (existingGrants.length > 0) { + const g = existingGrants[0] + return { + grantId: g.operation_id, + credits: g.balance, + expiresAt: g.expires_at!, + isNew: false, + } satisfies BlockGrant + } - if (existingGrants.length > 0) { - const g = existingGrants[0] - return { - grantId: g.operation_id, - credits: g.balance, - expiresAt: g.expires_at!, - isNew: false, - } satisfies BlockGrant - } + // 2. Resolve limits + const limits = await getSubscriptionLimits({ + userId, + logger, + conn, + tier: subscription.tier, + }) - // 2. Resolve limits - const limits = await getSubscriptionLimits({ - userId, - logger, - conn: tx, - tier: subscription.tier, - }) + // 3. Check weekly limit before creating a new block + const weekly = await getWeeklyUsage({ + userId, + billingPeriodStart: subscription.billing_period_start, + weeklyCreditsLimit: limits.weeklyCreditsLimit, + logger, + conn, + }) - // 3. Check weekly limit before creating a new block - const weekly = await getWeeklyUsage({ - userId, - billingPeriodStart: subscription.billing_period_start, - weeklyCreditsLimit: limits.weeklyCreditsLimit, - logger, - conn: tx, - }) + if (weekly.remaining <= 0) { + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_WEEKLY_LIMIT_HIT, + userId, + properties: { + subscriptionId, + weeklyUsed: weekly.used, + weeklyLimit: weekly.limit, + }, + logger, + }) - if (weekly.remaining <= 0) { - trackEvent({ - event: AnalyticsEvent.SUBSCRIPTION_WEEKLY_LIMIT_HIT, - userId, - properties: { - subscriptionId, - weeklyUsed: weekly.used, - weeklyLimit: weekly.limit, - }, - logger, - }) - - return { - error: 'weekly_limit_reached', - used: weekly.used, - limit: weekly.limit, - resetsAt: weekly.resetsAt, - } satisfies WeeklyLimitError - } + return { + error: 'weekly_limit_reached', + used: weekly.used, + limit: weekly.limit, + resetsAt: weekly.resetsAt, + } satisfies WeeklyLimitError + } - // 4. Create new block grant (capped to weekly remaining) - const blockCredits = Math.min(limits.creditsPerBlock, weekly.remaining) - const expiresAt = addHours(now, limits.blockDurationHours) - const operationId = `block-${subscriptionId}-${now.getTime()}` - - const [newGrant] = await tx - .insert(schema.creditLedger) - .values({ - operation_id: operationId, - user_id: userId, - stripe_subscription_id: subscriptionId, - type: 'subscription', - principal: blockCredits, - balance: blockCredits, - priority: GRANT_PRIORITIES.subscription, - expires_at: expiresAt, - description: `${SUBSCRIPTION_DISPLAY_NAME} block (${limits.blockDurationHours}h)`, - }) - .onConflictDoNothing({ target: schema.creditLedger.operation_id }) - .returning() - - if (!newGrant) { - throw new Error( - 'Failed to create block grant — possible duplicate operation', - ) - } + // 4. Create new block grant (capped to weekly remaining) + const blockCredits = Math.min(limits.creditsPerBlock, weekly.remaining) + const expiresAt = addHours(now, limits.blockDurationHours) + const operationId = `block-${subscriptionId}-${now.getTime()}` + + const [newGrant] = await conn + .insert(schema.creditLedger) + .values({ + operation_id: operationId, + user_id: userId, + stripe_subscription_id: subscriptionId, + type: 'subscription', + principal: blockCredits, + balance: blockCredits, + priority: GRANT_PRIORITIES.subscription, + expires_at: expiresAt, + description: `${SUBSCRIPTION_DISPLAY_NAME} block (${limits.blockDurationHours}h)`, + }) + .onConflictDoNothing({ target: schema.creditLedger.operation_id }) + .returning() + + if (!newGrant) { + throw new Error( + 'Failed to create block grant — possible duplicate operation', + ) + } + + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_BLOCK_CREATED, + userId, + properties: { + subscriptionId, + operationId, + credits: blockCredits, + expiresAt: expiresAt.toISOString(), + weeklyUsed: weekly.used, + weeklyLimit: weekly.limit, + }, + logger, + }) + + logger.info( + { + userId, + subscriptionId, + operationId, + credits: blockCredits, + expiresAt, + }, + 'Created new subscription block grant', + ) + + return { + grantId: newGrant.operation_id, + credits: blockCredits, + expiresAt, + isNew: true, + } satisfies BlockGrant +} + +export async function ensureActiveBlockGrant(params: { + userId: string + subscription: SubscriptionRow + logger: Logger +}): Promise { + const { userId, subscription, logger } = params + const subscriptionId = subscription.stripe_subscription_id - trackEvent({ - event: AnalyticsEvent.SUBSCRIPTION_BLOCK_CREATED, + const { result } = await withAdvisoryLockTransaction({ + callback: async (tx) => { + return ensureActiveBlockGrantCallback({ + conn: tx, userId, - properties: { - subscriptionId, - operationId, - credits: blockCredits, - expiresAt: expiresAt.toISOString(), - weeklyUsed: weekly.used, - weeklyLimit: weekly.limit, - }, + subscription, logger, }) - - logger.info( - { - userId, - subscriptionId, - operationId, - credits: blockCredits, - expiresAt, - }, - 'Created new subscription block grant', - ) - - return { - grantId: newGrant.operation_id, - credits: blockCredits, - expiresAt, - isNew: true, - } satisfies BlockGrant }, lockKey: `user:${userId}`, context: { userId, subscriptionId }, @@ -397,13 +413,15 @@ export async function checkRateLimit(params: { userId: string subscription: SubscriptionRow logger: Logger + conn?: DbConn }): Promise { - const { userId, subscription, logger } = params + const { userId, subscription, logger, conn = db } = params const now = new Date() const limits = await getSubscriptionLimits({ userId, logger, + conn, tier: subscription.tier, }) @@ -412,6 +430,7 @@ export async function checkRateLimit(params: { billingPeriodStart: subscription.billing_period_start, weeklyCreditsLimit: limits.weeklyCreditsLimit, logger, + conn, }) // Weekly limit takes precedence @@ -428,7 +447,7 @@ export async function checkRateLimit(params: { } // Find most recent active subscription block grant for this user - const blocks = await db + const blocks = await conn .select() .from(schema.creditLedger) .where( @@ -493,11 +512,12 @@ export async function expireActiveBlockGrants(params: { userId: string subscriptionId: string logger: Logger + conn?: DbConn }): Promise { - const { userId, subscriptionId, logger } = params + const { userId, subscriptionId, logger, conn = db } = params const now = new Date() - const expired = await db + const expired = await conn .update(schema.creditLedger) .set({ expires_at: now }) .where( @@ -641,7 +661,7 @@ export async function handleSubscribe(params: { // Internal: credit migration // --------------------------------------------------------------------------- -type DbTransaction = Parameters[0] extends ( +export type DbTransaction = Parameters[0] extends ( tx: infer T, ) => unknown ? T @@ -652,7 +672,7 @@ type DbTransaction = Parameters[0] extends ( * into a single grant that expires at `expiresAt`. The old grants have their * balance zeroed. */ -async function migrateUnusedCredits(params: { +export async function migrateUnusedCredits(params: { tx: DbTransaction userId: string subscriptionId: string From 1fe1966a57318f6e059bed39517af68215820470 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 23:22:43 -0800 Subject: [PATCH 26/28] Update description of row of migrated credits --- packages/billing/src/__tests__/subscription.test.ts | 10 ++++++++-- packages/billing/src/subscription.ts | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/billing/src/__tests__/subscription.test.ts b/packages/billing/src/__tests__/subscription.test.ts index d4dc662dd2..b563eaf943 100644 --- a/packages/billing/src/__tests__/subscription.test.ts +++ b/packages/billing/src/__tests__/subscription.test.ts @@ -359,8 +359,14 @@ describe('subscription', () => { }) expect(captures.updateSets).toHaveLength(2) - expect(captures.updateSets[0]).toEqual({ balance: 0 }) - expect(captures.updateSets[1]).toEqual({ balance: 0 }) + expect(captures.updateSets[0]).toEqual({ + balance: 0, + description: 'Migrated 300 credits to subscribe-migrate-sub-123', + }) + expect(captures.updateSets[1]).toEqual({ + balance: 0, + description: 'Migrated 200 credits to subscribe-migrate-sub-123', + }) expect(captures.insertValues).toHaveLength(1) expect(captures.insertValues[0].principal).toBe(500) diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index a267fbc921..d83c998b81 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -730,7 +730,10 @@ export async function migrateUnusedCredits(params: { for (const grant of unusedGrants) { await tx .update(schema.creditLedger) - .set({ balance: 0 }) + .set({ + balance: 0, + description: `Migrated ${grant.balance} credits to ${operationId}`, + }) .where(eq(schema.creditLedger.operation_id, grant.operation_id)) } From 4a6d7c014092b0373349b3139cd0d4c026a0b60a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 23:34:20 -0800 Subject: [PATCH 27/28] Simplify getStripeId. Move null checks into callers --- packages/billing/src/subscription-webhooks.ts | 8 +++---- packages/internal/src/util/stripe.ts | 5 +--- web/src/app/api/stripe/webhook/route.ts | 24 +++++++++++-------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 4be7c5a6db..cda205d008 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -53,15 +53,15 @@ export async function handleSubscriptionInvoicePaid(params: { if (!invoice.subscription) return const subscriptionId = getStripeId(invoice.subscription) - const customerId = getStripeId(invoice.customer) - if (!customerId) { + if (!invoice.customer) { logger.warn( { invoiceId: invoice.id }, 'Subscription invoice has no customer ID', ) return } + const customerId = getStripeId(invoice.customer) const stripeSub = await stripeServer.subscriptions.retrieve(subscriptionId) const priceId = stripeSub.items.data[0]?.price.id @@ -186,9 +186,9 @@ export async function handleSubscriptionInvoicePaymentFailed(params: { if (!invoice.subscription) return const subscriptionId = getStripeId(invoice.subscription) - const customerId = getStripeId(invoice.customer) let userId = null - if (customerId) { + if (invoice.customer) { + const customerId = getStripeId(invoice.customer) const user = await getUserByStripeCustomerId(customerId) userId = user?.id } diff --git a/packages/internal/src/util/stripe.ts b/packages/internal/src/util/stripe.ts index 971d1bb1f5..263df4d131 100644 --- a/packages/internal/src/util/stripe.ts +++ b/packages/internal/src/util/stripe.ts @@ -7,10 +7,7 @@ import Stripe from 'stripe' /** * Extracts the ID string from a Stripe expandable field. */ -export function getStripeId(expandable: string | { id: string }): string -export function getStripeId(expandable: string | { id: string } | null | undefined): string | undefined -export function getStripeId(expandable: string | { id: string } | null | undefined): string | undefined { - if (expandable == null) return undefined +export function getStripeId(expandable: string | { id: string }): string { return typeof expandable === 'string' ? expandable : expandable.id } diff --git a/web/src/app/api/stripe/webhook/route.ts b/web/src/app/api/stripe/webhook/route.ts index edb8208d5f..5d127b8be9 100644 --- a/web/src/app/api/stripe/webhook/route.ts +++ b/web/src/app/api/stripe/webhook/route.ts @@ -392,15 +392,15 @@ const webhookHandler = async (req: NextRequest): Promise => { } case 'charge.dispute.created': { const dispute = event.data.object as Stripe.Dispute - const chargeId = getStripeId(dispute.charge) - if (!chargeId) { + if (!dispute.charge) { logger.warn( { disputeId: dispute.id }, 'Dispute received without charge ID', ) break } + const chargeId = getStripeId(dispute.charge) // Get the charge to find the customer const charge = await stripeServer.charges.retrieve(chargeId) @@ -539,14 +539,16 @@ const webhookHandler = async (req: NextRequest): Promise => { case 'invoice.paid': { const invoice = event.data.object as Stripe.Invoice if (invoice.subscription) { - const customerId = getStripeId(invoice.customer) - if (!customerId) { + if (!invoice.customer) { logger.warn( { invoiceId: invoice.id }, 'Subscription invoice has no customer — skipping', ) - } else if (!(await isOrgCustomer(customerId))) { - await handleSubscriptionInvoicePaid({ invoice, logger }) + } else { + const customerId = getStripeId(invoice.customer) + if (!(await isOrgCustomer(customerId))) { + await handleSubscriptionInvoicePaid({ invoice, logger }) + } } } else { await handleInvoicePaid(invoice) @@ -556,14 +558,16 @@ const webhookHandler = async (req: NextRequest): Promise => { case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice if (invoice.subscription) { - const customerId = getStripeId(invoice.customer) - if (!customerId) { + if (!invoice.customer) { logger.warn( { invoiceId: invoice.id }, 'Subscription invoice has no customer — skipping', ) - } else if (!(await isOrgCustomer(customerId))) { - await handleSubscriptionInvoicePaymentFailed({ invoice, logger }) + } else { + const customerId = getStripeId(invoice.customer) + if (!(await isOrgCustomer(customerId))) { + await handleSubscriptionInvoicePaymentFailed({ invoice, logger }) + } } } if ( From f00eeb4f0acc11d77e0b34398c5c564c3c4b40b6 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 23:35:50 -0800 Subject: [PATCH 28/28] Split try catch in two --- .../api/stripe/cancel-subscription/route.ts | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/web/src/app/api/stripe/cancel-subscription/route.ts b/web/src/app/api/stripe/cancel-subscription/route.ts index 6a95c130f3..d7075802c6 100644 --- a/web/src/app/api/stripe/cancel-subscription/route.ts +++ b/web/src/app/api/stripe/cancel-subscription/route.ts @@ -30,7 +30,18 @@ export async function POST() { subscription.stripe_subscription_id, { cancel_at_period_end: true }, ) + } catch (error: unknown) { + const message = + (error as { raw?: { message?: string } })?.raw?.message || + 'Failed to cancel subscription in Stripe.' + logger.error( + { error: message, userId, subscriptionId: subscription.stripe_subscription_id }, + 'Stripe subscription cancellation failed', + ) + return NextResponse.json({ error: message }, { status: 500 }) + } + try { await db .update(schema.subscription) .set({ cancel_at_period_end: true, scheduled_tier: null, updated_at: new Date() }) @@ -40,21 +51,22 @@ export async function POST() { subscription.stripe_subscription_id, ), ) - - logger.info( - { userId, subscriptionId: subscription.stripe_subscription_id }, - 'Subscription set to cancel at period end', - ) - - return NextResponse.json({ success: true }) } catch (error: unknown) { - const message = - (error as { raw?: { message?: string } })?.raw?.message || - 'Internal server error canceling subscription.' + const message = error instanceof Error ? error.message : String(error) logger.error( { error: message, userId, subscriptionId: subscription.stripe_subscription_id }, - 'Failed to cancel subscription', + 'Stripe subscription set to cancel but failed to update local DB — data is inconsistent', + ) + return NextResponse.json( + { error: 'Subscription canceled but failed to update records. Please contact support.' }, + { status: 500 }, ) - return NextResponse.json({ error: message }, { status: 500 }) } + + logger.info( + { userId, subscriptionId: subscription.stripe_subscription_id }, + 'Subscription set to cancel at period end', + ) + + return NextResponse.json({ success: true }) }