From 75a228f29210d2ea9d76583ac50344ce2732299c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 27 Jan 2026 16:45:08 -0800 Subject: [PATCH 01/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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 a7c6823d892a846501e53c9d6d68a1b63d0853c8 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 28 Jan 2026 15:40:01 -0800 Subject: [PATCH 17/83] cli subscription changes --- cli/src/chat.tsx | 27 +++ cli/src/commands/command-registry.ts | 8 + cli/src/components/bottom-status-line.tsx | 128 +++++++++--- cli/src/components/chat-input-bar.tsx | 6 + cli/src/components/input-mode-banner.tsx | 2 + .../components/subscription-limit-banner.tsx | 190 ++++++++++++++++++ cli/src/components/usage-banner.tsx | 64 +++++- cli/src/data/slash-commands.ts | 6 + cli/src/hooks/use-subscription-query.ts | 102 ++++++++++ cli/src/utils/block-operations.ts | 17 +- cli/src/utils/input-modes.ts | 9 + cli/src/utils/message-block-helpers.ts | 5 +- cli/src/utils/settings.ts | 21 ++ 13 files changed, 554 insertions(+), 31 deletions(-) create mode 100644 cli/src/components/subscription-limit-banner.tsx create mode 100644 cli/src/hooks/use-subscription-query.ts diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 58970c2695..9f53a7c61c 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -34,6 +34,7 @@ import { useChatState } from './hooks/use-chat-state' import { useChatStreaming } from './hooks/use-chat-streaming' import { useChatUI } from './hooks/use-chat-ui' import { useClaudeQuotaQuery } from './hooks/use-claude-quota-query' +import { useSubscriptionQuery } from './hooks/use-subscription-query' import { useClipboard } from './hooks/use-clipboard' import { useEvent } from './hooks/use-event' import { useGravityAd } from './hooks/use-gravity-ad' @@ -55,6 +56,7 @@ import { getClaudeOAuthStatus } from './utils/claude-oauth' import { showClipboardMessage } from './utils/clipboard' import { readClipboardImage } from './utils/clipboard-image' import { getInputModeConfig } from './utils/input-modes' +import { getAlwaysUseALaCarte } from './utils/settings' import { type ChatKeyboardState, createDefaultChatKeyboardState, @@ -1236,6 +1238,29 @@ export const Chat = ({ refetchInterval: 60 * 1000, // Refetch every 60 seconds }) + // Fetch subscription data + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: 60 * 1000, + }) + + // Auto-show subscription limit banner when rate limit becomes active + const subscriptionLimitShownRef = useRef(false) + useEffect(() => { + const isLimited = subscriptionData?.rateLimit?.limited === true + if (isLimited && !subscriptionLimitShownRef.current) { + subscriptionLimitShownRef.current = true + // Skip showing the banner if user prefers to always fall back to a-la-carte + if (!getAlwaysUseALaCarte()) { + useChatStore.getState().setInputMode('subscriptionLimit') + } + } else if (!isLimited) { + subscriptionLimitShownRef.current = false + if (useChatStore.getState().inputMode === 'subscriptionLimit') { + useChatStore.getState().setInputMode('default') + } + } + }, [subscriptionData?.rateLimit?.limited]) + const inputBoxTitle = useMemo(() => { const segments: string[] = [] @@ -1427,6 +1452,8 @@ export const Chat = ({ isClaudeConnected={isClaudeOAuthActive} isClaudeActive={isClaudeActive} claudeQuota={claudeQuota} + hasSubscription={subscriptionData?.hasSubscription ?? false} + subscriptionRateLimit={subscriptionData?.rateLimit} /> diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index f2f6ca815a..9f655cdfef 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -380,6 +380,14 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ clearInput(params) }, }), + defineCommand({ + name: 'subscribe', + aliases: ['strong'], + handler: (params) => { + open(WEBSITE_URL + '/strong') + clearInput(params) + }, + }), defineCommand({ name: 'buy-credits', handler: (params) => { diff --git a/cli/src/components/bottom-status-line.tsx b/cli/src/components/bottom-status-line.tsx index a16c934379..c9fe7b4c9a 100644 --- a/cli/src/components/bottom-status-line.tsx +++ b/cli/src/components/bottom-status-line.tsx @@ -4,6 +4,7 @@ import { useTheme } from '../hooks/use-theme' import { formatResetTime } from '../utils/time-format' import type { ClaudeQuotaData } from '../hooks/use-claude-quota-query' +import type { SubscriptionRateLimit } from '../hooks/use-subscription-query' interface BottomStatusLineProps { /** Whether Claude OAuth is connected */ @@ -12,46 +13,79 @@ interface BottomStatusLineProps { isClaudeActive: boolean /** Quota data from Anthropic API */ claudeQuota?: ClaudeQuotaData | null + /** Whether the user has an active Codebuff Strong subscription */ + hasSubscription: boolean + /** Rate limit data for the subscription */ + subscriptionRateLimit?: SubscriptionRateLimit | null } /** * Bottom status line component - shows below the input box - * Currently displays Claude subscription status when connected + * Displays Claude subscription status and/or Codebuff Strong status */ export const BottomStatusLine: React.FC = ({ isClaudeConnected, isClaudeActive, claudeQuota, + hasSubscription, + subscriptionRateLimit, }) => { const theme = useTheme() - // Don't render if there's nothing to show - if (!isClaudeConnected) { - return null - } - // Use the more restrictive of the two quotas (5-hour window is usually the limiting factor) - const displayRemaining = claudeQuota + const claudeDisplayRemaining = claudeQuota ? Math.min(claudeQuota.fiveHourRemaining, claudeQuota.sevenDayRemaining) : null - // Check if quota is exhausted (0%) - const isExhausted = displayRemaining !== null && displayRemaining <= 0 + // Check if Claude quota is exhausted (0%) + const isClaudeExhausted = claudeDisplayRemaining !== null && claudeDisplayRemaining <= 0 - // Get the reset time for the limiting quota window - const resetTime = claudeQuota + // Get the reset time for the limiting Claude quota window + const claudeResetTime = claudeQuota ? claudeQuota.fiveHourRemaining <= claudeQuota.sevenDayRemaining ? claudeQuota.fiveHourResetsAt : claudeQuota.sevenDayResetsAt : null - // Determine dot color: red if exhausted, green if active, muted otherwise - const dotColor = isExhausted + // Show Claude when connected and not depleted (takes priority over Strong) + const showClaude = isClaudeConnected && !isClaudeExhausted + // Show Strong when subscribed AND (no Claude connected OR Claude depleted) + const showStrong = hasSubscription && (!isClaudeConnected || isClaudeExhausted) + + // Don't render if there's nothing to show + if (!showClaude && !showStrong && !(isClaudeConnected && isClaudeExhausted)) { + return null + } + + // Determine dot color for Claude: red if exhausted, green if active, muted otherwise + const claudeDotColor = isClaudeExhausted ? theme.error : isClaudeActive ? theme.success : theme.muted + // Subscription remaining percentage (based on weekly) + const subscriptionRemaining = subscriptionRateLimit + ? 100 - subscriptionRateLimit.weeklyPercentUsed + : null + const isSubscriptionLimited = subscriptionRateLimit?.limited === true + + // Get subscription reset time + const subscriptionResetTime = subscriptionRateLimit + ? subscriptionRateLimit.reason === 'block_exhausted' && subscriptionRateLimit.blockResetsAt + ? new Date(subscriptionRateLimit.blockResetsAt) + : subscriptionRateLimit.weeklyResetsAt + ? new Date(subscriptionRateLimit.weeklyResetsAt) + : null + : null + + // Determine dot color for Strong: red if limited, green if has remaining credits, muted otherwise + const strongDotColor = isSubscriptionLimited + ? theme.error + : subscriptionRemaining !== null && subscriptionRemaining > 0 + ? theme.success + : theme.muted + return ( = ({ flexDirection: 'row', justifyContent: 'flex-end', paddingRight: 1, + gap: 2, }} > - - - Claude subscription - {isExhausted && resetTime ? ( - {` · resets in ${formatResetTime(resetTime)}`} - ) : displayRemaining !== null ? ( - - ) : null} - + {/* Show Claude subscription when connected (even when depleted, to show reset time) */} + {isClaudeConnected && !isClaudeExhausted && ( + + + Claude subscription + {claudeDisplayRemaining !== null ? ( + + ) : null} + + )} + + {/* Show Claude as depleted when exhausted */} + {isClaudeConnected && isClaudeExhausted && ( + + + Claude + {claudeResetTime && ( + {` · resets in ${formatResetTime(claudeResetTime)}`} + )} + + )} + + {/* Show Codebuff Strong when subscribed and Claude not healthy */} + {showStrong && ( + + + Codebuff Strong + {isSubscriptionLimited && subscriptionResetTime ? ( + {` · resets in ${formatResetTime(subscriptionResetTime)}`} + ) : subscriptionRemaining !== null ? ( + + ) : null} + + )} ) } diff --git a/cli/src/components/chat-input-bar.tsx b/cli/src/components/chat-input-bar.tsx index 7e0c8c5335..4fdbca152c 100644 --- a/cli/src/components/chat-input-bar.tsx +++ b/cli/src/components/chat-input-bar.tsx @@ -6,6 +6,7 @@ import { FeedbackContainer } from './feedback-container' import { InputModeBanner } from './input-mode-banner' import { MultilineInput, type MultilineInputHandle } from './multiline-input' import { OutOfCreditsBanner } from './out-of-credits-banner' +import { SubscriptionLimitBanner } from './subscription-limit-banner' import { PublishContainer } from './publish-container' import { SuggestionMenu, type SuggestionItem } from './suggestion-menu' import { useAskUserBridge } from '../hooks/use-ask-user-bridge' @@ -187,6 +188,11 @@ export const ChatInputBar = ({ return } + // Subscription limit mode: replace entire input with subscription limit banner + if (inputMode === 'subscriptionLimit') { + return + } + // Handle input changes with special mode entry detection const handleInputChange = (value: InputValue) => { // Detect entering bash mode: user typed exactly '!' when in default mode diff --git a/cli/src/components/input-mode-banner.tsx b/cli/src/components/input-mode-banner.tsx index e73b74f8a7..1a69ff03d6 100644 --- a/cli/src/components/input-mode-banner.tsx +++ b/cli/src/components/input-mode-banner.tsx @@ -4,6 +4,7 @@ import { ClaudeConnectBanner } from './claude-connect-banner' import { HelpBanner } from './help-banner' import { PendingAttachmentsBanner } from './pending-attachments-banner' import { ReferralBanner } from './referral-banner' +import { SubscriptionLimitBanner } from './subscription-limit-banner' import { UsageBanner } from './usage-banner' import { useChatStore } from '../state/chat-store' @@ -26,6 +27,7 @@ const BANNER_REGISTRY: Record< referral: () => , help: () => , 'connect:claude': () => , + subscriptionLimit: () => , } /** diff --git a/cli/src/components/subscription-limit-banner.tsx b/cli/src/components/subscription-limit-banner.tsx new file mode 100644 index 0000000000..76057ab1c3 --- /dev/null +++ b/cli/src/components/subscription-limit-banner.tsx @@ -0,0 +1,190 @@ +import open from 'open' +import React from 'react' + +import { Button } from './button' +import { ProgressBar } from './progress-bar' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' +import { useTheme } from '../hooks/use-theme' +import { useUsageQuery } from '../hooks/use-usage-query' +import { WEBSITE_URL } from '../login/constants' +import { useChatStore } from '../state/chat-store' +import { + getAlwaysUseALaCarte, + setAlwaysUseALaCarte, +} from '../utils/settings' +import { formatResetTime } from '../utils/time-format' +import { BORDER_CHARS } from '../utils/ui-constants' + +export const SubscriptionLimitBanner = () => { + const setInputMode = useChatStore((state) => state.setInputMode) + const theme = useTheme() + + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: 15 * 1000, + }) + + const { data: usageData } = useUsageQuery({ + enabled: true, + refetchInterval: 30 * 1000, + }) + + const rateLimit = subscriptionData?.rateLimit + const remainingBalance = usageData?.remainingBalance ?? 0 + const hasAlaCarteCredits = remainingBalance > 0 + + const [alwaysALaCarte, setAlwaysALaCarteState] = React.useState( + () => getAlwaysUseALaCarte(), + ) + + const handleToggleAlwaysALaCarte = () => { + const newValue = !alwaysALaCarte + setAlwaysALaCarteState(newValue) + setAlwaysUseALaCarte(newValue) + if (newValue) { + setInputMode('default') + } + } + + if (!subscriptionData) { + return ( + + Loading subscription data... + + ) + } + + if (!rateLimit?.limited) { + return null + } + + const isWeeklyLimit = rateLimit.reason === 'weekly_limit' + const isBlockExhausted = rateLimit.reason === 'block_exhausted' + + const weeklyRemaining = 100 - rateLimit.weeklyPercentUsed + const weeklyResetsAt = rateLimit.weeklyResetsAt + ? new Date(rateLimit.weeklyResetsAt) + : null + + const blockResetsAt = rateLimit.blockResetsAt + ? new Date(rateLimit.blockResetsAt) + : null + + const handleContinueWithCredits = () => { + setInputMode('default') + } + + const handleBuyCredits = () => { + open(WEBSITE_URL + '/usage') + } + + const handleWait = () => { + setInputMode('default') + } + + const borderColor = isWeeklyLimit ? theme.error : theme.warning + + return ( + + + {isWeeklyLimit ? ( + <> + + 🛑 Weekly limit reached + + + You've used all {rateLimit.weeklyLimit.toLocaleString()} credits for this week. + + {weeklyResetsAt && ( + + Weekly usage resets in {formatResetTime(weeklyResetsAt)} + + )} + + ) : isBlockExhausted ? ( + <> + + ⏱️ Block limit reached + + + You've used all {rateLimit.blockLimit?.toLocaleString()} credits in this 5-hour block. + + {blockResetsAt && ( + + New block starts in {formatResetTime(blockResetsAt)} + + )} + + ) : ( + + Subscription limit reached + + )} + + + Weekly: + + {rateLimit.weeklyPercentUsed}% used + + + {hasAlaCarteCredits && ( + + )} + + + {hasAlaCarteCredits ? ( + <> + + {isWeeklyLimit ? ( + + ) : ( + + )} + + ) : ( + <> + No a-la-carte credits available. + + + + )} + + + + ) +} diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 7283fc6570..1235a8cd7f 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -7,6 +7,7 @@ import { Button } from './button' import { ProgressBar } from './progress-bar' import { getActivityQueryData } from '../hooks/use-activity-query' import { useClaudeQuotaQuery } from '../hooks/use-claude-quota-query' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' import { useTheme } from '../hooks/use-theme' import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' import { WEBSITE_URL } from '../login/constants' @@ -53,6 +54,11 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { refetchInterval: 30 * 1000, // Refresh every 30 seconds when banner is open }) + // Fetch subscription data + const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionQuery({ + refetchInterval: 30 * 1000, + }) + const { data: apiData, isLoading, @@ -99,12 +105,68 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { const adCredits = activeData.balanceBreakdown?.ad const renewalDate = activeData.next_quota_reset ? formatRenewalDate(activeData.next_quota_reset) : null + const hasSubscription = subscriptionData?.hasSubscription === true + const rateLimit = subscriptionData?.rateLimit + const subscriptionInfo = subscriptionData?.subscription + return ( setInputMode('default')} > + {/* Strong subscription section - only show if subscribed */} + {hasSubscription && ( + + + {subscriptionData.displayName ?? 'Strong'} subscription + {subscriptionInfo?.tier ? ` · $${subscriptionInfo.tier}/mo` : ''} + {subscriptionInfo?.billingPeriodEnd + ? ` · Renews ${formatRenewalDate(subscriptionInfo.billingPeriodEnd)}` + : ''} + + {isSubscriptionLoading ? ( + Loading subscription data... + ) : rateLimit ? ( + + {/* Block progress - show if there's an active block */} + {rateLimit.blockLimit != null && rateLimit.blockUsed != null && ( + + + {subscriptionData.displayName ?? 'Strong'}: + + + + {Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100)}% used + + {rateLimit.blockResetsAt && ( + + (resets in {formatResetTime(new Date(rateLimit.blockResetsAt))}) + + )} + + )} + {/* Weekly progress */} + + Week: + + + {rateLimit.weeklyPercentUsed}% used · Resets {formatRenewalDate(rateLimit.weeklyResetsAt)} + + + + ) : null} + + )} + {/* Codebuff credits section - structured layout */} - -
- )} + - - - - - 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 ( - - ) - }, - )} -
- - - -
-
) } @@ -469,7 +278,7 @@ function SubscriptionCta() { } export function SubscriptionSection() { - const { status } = useSession() + const { data: session, status } = useSession() const { data, isLoading } = useQuery({ queryKey: ['subscription'], @@ -500,5 +309,7 @@ export function SubscriptionSection() { return } - return + const email = session?.user?.email || '' + + return } From 3d5b4d1f542fa5df09cd84f35d244093973b6f42 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 29 Jan 2026 22:34:00 -0800 Subject: [PATCH 22/83] Makeover for subscription panel --- .../components/subscription-section.tsx | 167 ++++++------------ 1 file changed, 55 insertions(+), 112 deletions(-) diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index a2ce01424a..b5e95200ff 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -4,9 +4,6 @@ import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscripti import { env } from '@codebuff/common/env' import { useQuery } from '@tanstack/react-query' import { - Zap, - Clock, - CalendarDays, AlertTriangle, ExternalLink, Loader2, @@ -47,45 +44,26 @@ interface SubscriptionApiResponse { } } -function formatRelativeTime(dateStr: string): string { +function formatHours(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` + if (isNaN(diffMs) || diffMs <= 0) return '0h' + const hours = Math.ceil(diffMs / (1000 * 60 * 60)) + return `${hours}h` } -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, + percentAvailable, label, className, }: { - value: number - max: number + percentAvailable: number label: string className?: string }) { - const percent = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0 + const percent = Math.min(100, Math.max(0, Math.round(percentAvailable))) return (
= 100 + percent <= 0 ? 'bg-red-500' - : percent >= 75 + : percent <= 25 ? 'bg-yellow-500' - : 'bg-indigo-500', + : 'bg-green-500', )} style={{ width: `${percent}%` }} /> @@ -126,23 +104,30 @@ function SubscriptionActive({ const billingPortalUrl = `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` return ( - - + +
- - - {SUBSCRIPTION_DISPLAY_NAME} · ${subscription?.tier ?? 200}/mo - - + 💪 + {SUBSCRIPTION_DISPLAY_NAME} + + ${subscription?.tier ?? 200}/mo + + {isCanceling && ( + + Canceling + )} + + - {isCanceling ? 'Canceling' : 'Active'} - + Manage + +
@@ -151,64 +136,40 @@ function SubscriptionActive({ <>
- - - Current Block + + Session + + + {rateLimit.blockLimit != null && rateLimit.blockUsed != null && rateLimit.blockLimit > 0 + ? `${Math.round(100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100)}%` + : '100%'} + {rateLimit.blockResetsAt && ` · Resets in ${formatHours(rateLimit.blockResetsAt)}`} - {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 -

- - )} + 0 + ? 100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100 + : 100 + } + label="Session usage" + />
{/* Weekly usage */}
- - - Weekly Usage + + Weekly - Resets {formatShortDate(rateLimit.weeklyResetsAt)} + {100 - rateLimit.weeklyPercentUsed}% · Resets in {formatHours(rateLimit.weeklyResetsAt)}
-

- {rateLimit.weeklyPercentUsed}% used -

{/* Rate limit warning */} @@ -217,33 +178,15 @@ function SubscriptionActive({

{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.`} + ? `Weekly limit reached. Resets in ${formatHours(rateLimit.weeklyResetsAt)}. You can still use a-la-carte credits.` + : `Session exhausted. New session in ${rateLimit.blockResetsAt ? formatHours(rateLimit.blockResetsAt) : 'soon'}. You can still use a-la-carte credits.`}

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

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

- -
+ ) @@ -255,7 +198,7 @@ function SubscriptionCta() {
- + 💪

From 20f680cb8861e7d617fd0843d5ef21282b2eb3da Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 29 Jan 2026 23:23:16 -0800 Subject: [PATCH 23/83] Tweak subscription section design --- .../components/subscription-section.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index b5e95200ff..a72c8929f6 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -44,12 +44,17 @@ interface SubscriptionApiResponse { } } -function formatHours(dateStr: string): string { +function formatDaysHours(dateStr: string): string { const target = new Date(dateStr) const now = new Date() const diffMs = target.getTime() - now.getTime() if (isNaN(diffMs) || diffMs <= 0) return '0h' - const hours = Math.ceil(diffMs / (1000 * 60 * 60)) + const totalHours = Math.ceil(diffMs / (1000 * 60 * 60)) + const days = Math.floor(totalHours / 24) + const hours = totalHours % 24 + if (days > 0) { + return hours > 0 ? `${days}d ${hours}h` : `${days}d` + } return `${hours}h` } @@ -138,12 +143,16 @@ function SubscriptionActive({
Session + {rateLimit.blockResetsAt && ( + + resets in {formatDaysHours(rateLimit.blockResetsAt)} + + )} {rateLimit.blockLimit != null && rateLimit.blockUsed != null && rateLimit.blockLimit > 0 ? `${Math.round(100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100)}%` : '100%'} - {rateLimit.blockResetsAt && ` · Resets in ${formatHours(rateLimit.blockResetsAt)}`}
Weekly + + resets in {formatDaysHours(rateLimit.weeklyResetsAt)} + - {100 - rateLimit.weeklyPercentUsed}% · Resets in {formatHours(rateLimit.weeklyResetsAt)} + {100 - rateLimit.weeklyPercentUsed}%

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

)} From 13e8fc038f472a7ee8b520ee5fb863da5a786207 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 30 Jan 2026 15:06:35 -0800 Subject: [PATCH 24/83] Create a credit block when you send a message --- web/src/app/api/v1/chat/completions/_post.ts | 25 ++++++++++++++++++++ web/src/app/api/v1/chat/completions/route.ts | 6 +++++ 2 files changed, 31 insertions(+) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index ac8dde87fb..d8dcadd6f9 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -17,6 +17,11 @@ import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' + +import type { + BlockGrantResult, + SubscriptionRow, +} from '@codebuff/billing/subscription' import type { NextRequest } from 'next/server' import type { ChatCompletionRequestBody } from '@/llm-api/types' @@ -78,6 +83,8 @@ export async function postChatCompletions(params: { getAgentRunFromId: GetAgentRunFromIdFn fetch: typeof globalThis.fetch insertMessageBigquery: InsertMessageBigqueryFn + getActiveSubscription?: (params: { userId: string; logger: Logger }) => Promise + ensureActiveBlockGrant?: (params: { userId: string; subscription: SubscriptionRow; logger: Logger }) => Promise }) { const { req, @@ -88,6 +95,8 @@ export async function postChatCompletions(params: { getAgentRunFromId, fetch, insertMessageBigquery, + getActiveSubscription, + ensureActiveBlockGrant, } = params let { logger } = params @@ -182,6 +191,22 @@ export async function postChatCompletions(params: { logger, }) + // For subscribers, ensure a block grant exists before checking balance. + // This is done here block grants should only start when the user begins working. + if (getActiveSubscription && ensureActiveBlockGrant) { + try { + const activeSub = await getActiveSubscription({ userId, logger }) + if (activeSub) { + await ensureActiveBlockGrant({ userId, subscription: activeSub, logger }) + } + } catch (error) { + logger.error( + { error: getErrorObject(error), userId }, + 'Error ensuring subscription block grant', + ) + } + } + // Check user credits const { balance: { totalRemaining }, diff --git a/web/src/app/api/v1/chat/completions/route.ts b/web/src/app/api/v1/chat/completions/route.ts index 7b49e8232d..a92cf818e7 100644 --- a/web/src/app/api/v1/chat/completions/route.ts +++ b/web/src/app/api/v1/chat/completions/route.ts @@ -1,4 +1,8 @@ import { insertMessageBigquery } from '@codebuff/bigquery' +import { + ensureActiveBlockGrant, + getActiveSubscription, +} from '@codebuff/billing/subscription' import { getUserUsageData } from '@codebuff/billing/usage-service' import { trackEvent } from '@codebuff/common/analytics' @@ -21,5 +25,7 @@ export async function POST(req: NextRequest) { getAgentRunFromId, fetch, insertMessageBigquery, + getActiveSubscription, + ensureActiveBlockGrant, }) } From b9c5a926e673e13d428e52861dfb4d0014cf0525 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 30 Jan 2026 15:19:30 -0800 Subject: [PATCH 25/83] fix 401 getting subscription --- cli/src/hooks/use-subscription-query.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/src/hooks/use-subscription-query.ts b/cli/src/hooks/use-subscription-query.ts index 83cd5f6142..63be6bb4c8 100644 --- a/cli/src/hooks/use-subscription-query.ts +++ b/cli/src/hooks/use-subscription-query.ts @@ -1,6 +1,6 @@ import { useActivityQuery } from './use-activity-query' import { getAuthToken } from '../utils/auth' -import { getApiClient } from '../utils/codebuff-api' +import { getApiClient, setApiClientAuthToken } from '../utils/codebuff-api' import { logger as defaultLogger } from '../utils/logger' import type { Logger } from '@codebuff/common/types/contracts/logger' @@ -48,6 +48,8 @@ export interface SubscriptionData { export async function fetchSubscriptionData( logger: Logger = defaultLogger, ): Promise { + const authToken = getAuthToken() + setApiClientAuthToken(authToken) const client = getApiClient() const response = await client.get( '/api/user/subscription', From 2a0015be92d4a417550d92f8646eb7a11e1ef6de Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 30 Jan 2026 15:30:58 -0800 Subject: [PATCH 26/83] Set auth token at app startup --- cli/src/hooks/use-subscription-query.ts | 4 +--- cli/src/hooks/use-user-details-query.ts | 8 ++------ cli/src/index.tsx | 6 +++++- cli/src/utils/fetch-usage.ts | 8 ++------ 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/cli/src/hooks/use-subscription-query.ts b/cli/src/hooks/use-subscription-query.ts index 63be6bb4c8..83cd5f6142 100644 --- a/cli/src/hooks/use-subscription-query.ts +++ b/cli/src/hooks/use-subscription-query.ts @@ -1,6 +1,6 @@ import { useActivityQuery } from './use-activity-query' import { getAuthToken } from '../utils/auth' -import { getApiClient, setApiClientAuthToken } from '../utils/codebuff-api' +import { getApiClient } from '../utils/codebuff-api' import { logger as defaultLogger } from '../utils/logger' import type { Logger } from '@codebuff/common/types/contracts/logger' @@ -48,8 +48,6 @@ export interface SubscriptionData { export async function fetchSubscriptionData( logger: Logger = defaultLogger, ): Promise { - const authToken = getAuthToken() - setApiClientAuthToken(authToken) const client = getApiClient() const response = await client.get( '/api/user/subscription', diff --git a/cli/src/hooks/use-user-details-query.ts b/cli/src/hooks/use-user-details-query.ts index 4c3f335ae9..243bbc83f4 100644 --- a/cli/src/hooks/use-user-details-query.ts +++ b/cli/src/hooks/use-user-details-query.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { getAuthToken } from '../utils/auth' -import { getApiClient, setApiClientAuthToken } from '../utils/codebuff-api' +import { getApiClient } from '../utils/codebuff-api' import { logger as defaultLogger } from '../utils/logger' import type { @@ -38,11 +38,7 @@ export async function fetchUserDetails({ apiClient: providedApiClient, }: FetchUserDetailsParams): Promise | null> { const apiClient = - providedApiClient ?? - (() => { - setApiClientAuthToken(authToken) - return getApiClient() - })() + providedApiClient ?? getApiClient() const response = await apiClient.me(fields) diff --git a/cli/src/index.tsx b/cli/src/index.tsx index fcef730c7a..3fd6affed9 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -24,8 +24,9 @@ import { runPlainLogin } from './login/plain-login' import { initializeApp } from './init/init-app' import { getProjectRoot, setProjectRoot } from './project-files' import { initAnalytics, trackEvent } from './utils/analytics' -import { getAuthTokenDetails } from './utils/auth' +import { getAuthToken, getAuthTokenDetails } from './utils/auth' import { resetCodebuffClient } from './utils/codebuff-client' +import { setApiClientAuthToken } from './utils/codebuff-api' import { getCliEnv } from './utils/env' import { initializeAgentRegistry } from './utils/local-agent-registry' import { clearLogFile, logger } from './utils/logger' @@ -181,6 +182,9 @@ async function main(): Promise { await initializeApp({ cwd }) + // Set the auth token for the API client + setApiClientAuthToken(getAuthToken()) + // Handle login command before rendering the app if (isLoginCommand) { await runPlainLogin() diff --git a/cli/src/utils/fetch-usage.ts b/cli/src/utils/fetch-usage.ts index 8102cf85b5..0706876302 100644 --- a/cli/src/utils/fetch-usage.ts +++ b/cli/src/utils/fetch-usage.ts @@ -1,5 +1,5 @@ import { getAuthToken } from './auth' -import { getApiClient, setApiClientAuthToken } from './codebuff-api' +import { getApiClient } from './codebuff-api' import { logger } from './logger' import { useChatStore } from '../state/chat-store' @@ -42,11 +42,7 @@ export async function fetchAndUpdateUsage( } const apiClient = - providedApiClient ?? - (() => { - setApiClientAuthToken(authToken) - return getApiClient() - })() + providedApiClient ?? getApiClient() try { const response = await apiClient.usage() From 05b032195894799f92b5246ceff05a543646e0d2 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 30 Jan 2026 16:12:12 -0800 Subject: [PATCH 27/83] Improve 5 hour limit banner --- .../components/subscription-limit-banner.tsx | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/cli/src/components/subscription-limit-banner.tsx b/cli/src/components/subscription-limit-banner.tsx index 76057ab1c3..b4a63103cb 100644 --- a/cli/src/components/subscription-limit-banner.tsx +++ b/cli/src/components/subscription-limit-banner.tsx @@ -40,9 +40,6 @@ export const SubscriptionLimitBanner = () => { const newValue = !alwaysALaCarte setAlwaysALaCarteState(newValue) setAlwaysUseALaCarte(newValue) - if (newValue) { - setInputMode('default') - } } if (!subscriptionData) { @@ -123,10 +120,7 @@ export const SubscriptionLimitBanner = () => { ) : isBlockExhausted ? ( <> - ⏱️ Block limit reached - - - You've used all {rateLimit.blockLimit?.toLocaleString()} credits in this 5-hour block. + ⏱️ 5 hour limit reached {blockResetsAt && ( @@ -158,17 +152,20 @@ export const SubscriptionLimitBanner = () => { {hasAlaCarteCredits ? ( <> {isWeeklyLimit ? ( ) : ( )} @@ -176,10 +173,10 @@ export const SubscriptionLimitBanner = () => { <> No a-la-carte credits available. )} From 6e58594a78becea4ae344e2b6ce8eddb05652826 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 30 Jan 2026 16:12:28 -0800 Subject: [PATCH 28/83] Don't create a new block if the previous one's 5 hours is not up --- packages/billing/src/subscription.ts | 42 ++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index d83c998b81..7716742ed0 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -63,12 +63,25 @@ export interface WeeklyLimitError { resetsAt: Date } -export type BlockGrantResult = BlockGrant | WeeklyLimitError +export interface BlockExhaustedError { + error: 'block_exhausted' + blockUsed: number + blockLimit: number + resetsAt: Date +} + +export type BlockGrantResult = BlockGrant | WeeklyLimitError | BlockExhaustedError export function isWeeklyLimitError( result: BlockGrantResult, ): result is WeeklyLimitError { - return 'error' in result + return 'error' in result && result.error === 'weekly_limit_reached' +} + +export function isBlockExhaustedError( + result: BlockGrantResult, +): result is BlockExhaustedError { + return 'error' in result && result.error === 'block_exhausted' } export interface RateLimitStatus { @@ -251,7 +264,7 @@ export async function ensureActiveBlockGrantCallback(params: { const { conn, userId, subscription, logger, now = new Date() } = params const subscriptionId = subscription.stripe_subscription_id - // 1. Check for an existing active block grant + // 1. Check for an existing non-expired block grant (regardless of balance) const existingGrants = await conn .select() .from(schema.creditLedger) @@ -260,7 +273,6 @@ export async function ensureActiveBlockGrantCallback(params: { 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)) @@ -268,12 +280,24 @@ export async function ensureActiveBlockGrantCallback(params: { if (existingGrants.length > 0) { const g = existingGrants[0] + + // Block exists with credits remaining - return it + if (g.balance > 0) { + return { + grantId: g.operation_id, + credits: g.balance, + expiresAt: g.expires_at!, + isNew: false, + } satisfies BlockGrant + } + + // Block exists but is exhausted - don't create a new one until it expires return { - grantId: g.operation_id, - credits: g.balance, - expiresAt: g.expires_at!, - isNew: false, - } satisfies BlockGrant + error: 'block_exhausted', + blockUsed: g.principal, + blockLimit: g.principal, + resetsAt: g.expires_at!, + } satisfies BlockExhaustedError } // 2. Resolve limits From f23f122ff50b2bbc87850989a5acf80fe9734412 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 30 Jan 2026 16:47:02 -0800 Subject: [PATCH 29/83] Show the scheduled tier in subscription panel --- web/src/app/profile/components/subscription-section.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index a72c8929f6..facaf613b1 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -24,6 +24,7 @@ interface SubscriptionApiResponse { cancelAtPeriodEnd: boolean canceledAt: string | null tier?: number | null + scheduledTier?: number | null } rateLimit?: { limited: boolean @@ -123,6 +124,11 @@ function SubscriptionActive({ Canceling )} + {subscription?.scheduledTier != null && ( + + Renewing at ${subscription.scheduledTier}/mo + + )} Date: Fri, 30 Jan 2026 16:59:58 -0800 Subject: [PATCH 30/83] Fix: when cancelling a downgrade, scheduled_tier was not being cleared --- packages/billing/src/subscription-webhooks.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 9b09e21142..378e8eb5f8 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -505,9 +505,14 @@ export async function handleSubscriptionScheduleReleasedOrCanceled(params: { }): Promise { const { schedule, logger } = params + // When a schedule is released, the subscription field becomes null and + // the subscription ID moves to released_subscription. When canceled, + // the subscription field is retained. Check both fields. const subscriptionId = schedule.subscription ? getStripeId(schedule.subscription) - : null + : schedule.released_subscription + ? getStripeId(schedule.released_subscription) + : null if (!subscriptionId) { logger.debug( From 07ba6f5213836fd7f97629b638b9abdcb3140e1d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 1 Feb 2026 18:41:28 -0800 Subject: [PATCH 31/83] fix test --- cli/src/hooks/use-user-details-query.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cli/src/hooks/use-user-details-query.ts b/cli/src/hooks/use-user-details-query.ts index 243bbc83f4..fa5f7524c2 100644 --- a/cli/src/hooks/use-user-details-query.ts +++ b/cli/src/hooks/use-user-details-query.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { getAuthToken } from '../utils/auth' -import { getApiClient } from '../utils/codebuff-api' +import { getApiClient, setApiClientAuthToken } from '../utils/codebuff-api' import { logger as defaultLogger } from '../utils/logger' import type { @@ -37,8 +37,13 @@ export async function fetchUserDetails({ logger = defaultLogger, apiClient: providedApiClient, }: FetchUserDetailsParams): Promise | null> { - const apiClient = - providedApiClient ?? getApiClient() + let apiClient: CodebuffApiClient + if (providedApiClient) { + apiClient = providedApiClient + } else { + setApiClientAuthToken(authToken) + apiClient = getApiClient() + } const response = await apiClient.me(fields) From 12794da32cb9c9d2421f20bb21c236c5ea1dc5d4 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 1 Feb 2026 18:59:11 -0800 Subject: [PATCH 32/83] Remove bottom status bar for Strong subscription. Include subscription indicator in message footer --- cli/src/chat.tsx | 2 - cli/src/components/bottom-status-line.tsx | 63 ++----------------- cli/src/components/message-footer.tsx | 77 +++++++++++++++++++---- 3 files changed, 69 insertions(+), 73 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 222dcb05c2..bc66989404 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -1461,8 +1461,6 @@ export const Chat = ({ isClaudeConnected={isClaudeOAuthActive} isClaudeActive={isClaudeActive} claudeQuota={claudeQuota} - hasSubscription={subscriptionData?.hasSubscription ?? false} - subscriptionRateLimit={subscriptionData?.rateLimit} /> diff --git a/cli/src/components/bottom-status-line.tsx b/cli/src/components/bottom-status-line.tsx index c9fe7b4c9a..bb876b88fa 100644 --- a/cli/src/components/bottom-status-line.tsx +++ b/cli/src/components/bottom-status-line.tsx @@ -4,7 +4,6 @@ import { useTheme } from '../hooks/use-theme' import { formatResetTime } from '../utils/time-format' import type { ClaudeQuotaData } from '../hooks/use-claude-quota-query' -import type { SubscriptionRateLimit } from '../hooks/use-subscription-query' interface BottomStatusLineProps { /** Whether Claude OAuth is connected */ @@ -13,10 +12,6 @@ interface BottomStatusLineProps { isClaudeActive: boolean /** Quota data from Anthropic API */ claudeQuota?: ClaudeQuotaData | null - /** Whether the user has an active Codebuff Strong subscription */ - hasSubscription: boolean - /** Rate limit data for the subscription */ - subscriptionRateLimit?: SubscriptionRateLimit | null } /** @@ -27,8 +22,6 @@ export const BottomStatusLine: React.FC = ({ isClaudeConnected, isClaudeActive, claudeQuota, - hasSubscription, - subscriptionRateLimit, }) => { const theme = useTheme() @@ -47,13 +40,8 @@ export const BottomStatusLine: React.FC = ({ : claudeQuota.sevenDayResetsAt : null - // Show Claude when connected and not depleted (takes priority over Strong) - const showClaude = isClaudeConnected && !isClaudeExhausted - // Show Strong when subscribed AND (no Claude connected OR Claude depleted) - const showStrong = hasSubscription && (!isClaudeConnected || isClaudeExhausted) - - // Don't render if there's nothing to show - if (!showClaude && !showStrong && !(isClaudeConnected && isClaudeExhausted)) { + // Only show when Claude is connected + if (!isClaudeConnected) { return null } @@ -64,28 +52,6 @@ export const BottomStatusLine: React.FC = ({ ? theme.success : theme.muted - // Subscription remaining percentage (based on weekly) - const subscriptionRemaining = subscriptionRateLimit - ? 100 - subscriptionRateLimit.weeklyPercentUsed - : null - const isSubscriptionLimited = subscriptionRateLimit?.limited === true - - // Get subscription reset time - const subscriptionResetTime = subscriptionRateLimit - ? subscriptionRateLimit.reason === 'block_exhausted' && subscriptionRateLimit.blockResetsAt - ? new Date(subscriptionRateLimit.blockResetsAt) - : subscriptionRateLimit.weeklyResetsAt - ? new Date(subscriptionRateLimit.weeklyResetsAt) - : null - : null - - // Determine dot color for Strong: red if limited, green if has remaining credits, muted otherwise - const strongDotColor = isSubscriptionLimited - ? theme.error - : subscriptionRemaining !== null && subscriptionRemaining > 0 - ? theme.success - : theme.muted - return ( = ({ gap: 2, }} > - {/* Show Claude subscription when connected (even when depleted, to show reset time) */} - {isClaudeConnected && !isClaudeExhausted && ( + {/* Show Claude subscription when connected and not depleted */} + {!isClaudeExhausted && ( = ({ )} {/* Show Claude as depleted when exhausted */} - {isClaudeConnected && isClaudeExhausted && ( + {isClaudeExhausted && ( = ({ )} )} - - {/* Show Codebuff Strong when subscribed and Claude not healthy */} - {showStrong && ( - - - Codebuff Strong - {isSubscriptionLimited && subscriptionResetTime ? ( - {` · resets in ${formatResetTime(subscriptionResetTime)}`} - ) : subscriptionRemaining !== null ? ( - - ) : null} - - )} ) } diff --git a/cli/src/components/message-footer.tsx b/cli/src/components/message-footer.tsx index 13c2b3e9c3..5e0a81bca8 100644 --- a/cli/src/components/message-footer.tsx +++ b/cli/src/components/message-footer.tsx @@ -1,3 +1,4 @@ +import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' import { pluralize } from '@codebuff/common/util/string' import { TextAttributes } from '@opentui/core' import React, { useCallback, useMemo } from 'react' @@ -5,6 +6,7 @@ import React, { useCallback, useMemo } from 'react' import { CopyButton } from './copy-button' import { ElapsedTimer } from './elapsed-timer' import { FeedbackIconButton } from './feedback-icon-button' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' import { useTheme } from '../hooks/use-theme' import { useFeedbackStore, @@ -157,19 +159,7 @@ export const MessageFooter: React.FC = ({ if (typeof credits === 'number' && credits > 0) { footerItems.push({ key: 'credits', - node: ( - - {pluralize(credits, 'credit')} - - ), + node: , }) } if (shouldRenderFeedbackButton) { @@ -222,3 +212,64 @@ export const MessageFooter: React.FC = ({ ) } + +/** + * Shows either subscription indicator or credits count based on subscription status. + * If user has an active subscription with remaining block credits, shows "✓ Strong". + * If block is < 15% remaining, also shows the percentage. + * Otherwise, shows the regular credits count. + */ +const CreditsOrSubscriptionIndicator: React.FC<{ credits: number }> = ({ credits }) => { + const theme = useTheme() + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: false, // Don't poll, just use cached data + refetchOnActivity: false, + pauseWhenIdle: false, + }) + + const hasActiveSubscription = subscriptionData?.hasSubscription === true + const rateLimit = subscriptionData?.rateLimit + const isLimited = rateLimit?.limited === true + + // Calculate block remaining percentage + const blockPercentRemaining = useMemo(() => { + if (!rateLimit?.blockLimit || rateLimit.blockUsed == null) return null + const remaining = rateLimit.blockLimit - rateLimit.blockUsed + return Math.round((remaining / rateLimit.blockLimit) * 100) + }, [rateLimit]) + + // Show subscription indicator if user has active subscription and block is not depleted + const showSubscriptionIndicator = hasActiveSubscription && !isLimited && blockPercentRemaining !== null && blockPercentRemaining > 0 + + if (showSubscriptionIndicator) { + const showPercentage = blockPercentRemaining < 20 + return ( + + {showPercentage ? `✓ ${SUBSCRIPTION_DISPLAY_NAME} (${blockPercentRemaining}% left)` : `✓ ${SUBSCRIPTION_DISPLAY_NAME}`} + + ) + } + + // Default: show credits count + return ( + + {pluralize(credits, 'credit')} + + ) +} From a124b3e366b3be6a9649bb2fa33202490d81f34c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 1 Feb 2026 18:59:25 -0800 Subject: [PATCH 33/83] Improve usage banner a lot --- cli/src/components/usage-banner.tsx | 33 ++++++++++++++--------------- cli/src/utils/time-format.ts | 27 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 1235a8cd7f..d522c0f659 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -12,7 +12,7 @@ import { useTheme } from '../hooks/use-theme' import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' import { WEBSITE_URL } from '../login/constants' import { useChatStore } from '../state/chat-store' -import { formatResetTime } from '../utils/time-format' +import { formatResetTime, formatResetTimeLong } from '../utils/time-format' import { getBannerColorLevel, generateLoadingBannerText, @@ -118,13 +118,14 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { {/* Strong subscription section - only show if subscribed */} {hasSubscription && ( - - {subscriptionData.displayName ?? 'Strong'} subscription - {subscriptionInfo?.tier ? ` · $${subscriptionInfo.tier}/mo` : ''} - {subscriptionInfo?.billingPeriodEnd - ? ` · Renews ${formatRenewalDate(subscriptionInfo.billingPeriodEnd)}` - : ''} - + + + {subscriptionData.displayName ?? 'Strong'} subscription + + {subscriptionInfo?.tier && ( + ${subscriptionInfo.tier}/mo + )} + {isSubscriptionLoading ? ( Loading subscription data... ) : rateLimit ? ( @@ -132,34 +133,32 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { {/* Block progress - show if there's an active block */} {rateLimit.blockLimit != null && rateLimit.blockUsed != null && ( - - {subscriptionData.displayName ?? 'Strong'}: - + 5h limit: - {Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100)}% used + {Math.max(0, 100 - Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100))}% remaining {rateLimit.blockResetsAt && ( - (resets in {formatResetTime(new Date(rateLimit.blockResetsAt))}) + · resets in {formatResetTime(new Date(rateLimit.blockResetsAt))} )} )} {/* Weekly progress */} - Week: + Weekly limit: - {rateLimit.weeklyPercentUsed}% used · Resets {formatRenewalDate(rateLimit.weeklyResetsAt)} + {100 - rateLimit.weeklyPercentUsed}% remaining · resets in {formatResetTimeLong(rateLimit.weeklyResetsAt)} @@ -177,14 +176,14 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { {/* Main stats row */} Session: - {sessionCreditsUsed.toLocaleString()} + {sessionCreditsUsed.toLocaleString()} credits · Remaining: {isLoadingData ? ( ... ) : ( - {activeData.remainingBalance?.toLocaleString() ?? '?'} + {activeData.remainingBalance?.toLocaleString() ?? '?'} credits )} {adCredits != null && adCredits > 0 && ( diff --git a/cli/src/utils/time-format.ts b/cli/src/utils/time-format.ts index af178fde8c..20c3303dec 100644 --- a/cli/src/utils/time-format.ts +++ b/cli/src/utils/time-format.ts @@ -18,3 +18,30 @@ export const formatResetTime = (resetDate: Date | null): string => { } return `${diffMins}m` } + +/** + * Format time until reset in human-readable form, including days + * @param resetDate - The date when the quota/resource resets + * @returns Human-readable string like "4d 7h" or "2h 30m" + */ +export const formatResetTimeLong = (resetDate: Date | string | null): string => { + if (!resetDate) return '' + const date = typeof resetDate === 'string' ? new Date(resetDate) : resetDate + const now = new Date() + const diffMs = date.getTime() - now.getTime() + if (diffMs <= 0) return 'now' + + const diffMins = Math.floor(diffMs / (1000 * 60)) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + const remainingHours = diffHours % 24 + const remainingMins = diffMins % 60 + + if (diffDays > 0) { + return `${diffDays}d ${remainingHours}h` + } + if (diffHours > 0) { + return `${diffHours}h ${remainingMins}m` + } + return `${diffMins}m` +} From 12e7c0181e0419bd2f332cc7428e12e9b4a22dd0 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 00:03:23 -0800 Subject: [PATCH 34/83] Update /usage and subscription banner labels/ui --- cli/src/components/progress-bar.tsx | 2 +- cli/src/components/usage-banner.tsx | 71 ++++++++++--------- .../components/subscription-section.tsx | 8 +-- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/cli/src/components/progress-bar.tsx b/cli/src/components/progress-bar.tsx index acc11fac94..e9e18353d0 100644 --- a/cli/src/components/progress-bar.tsx +++ b/cli/src/components/progress-bar.tsx @@ -72,7 +72,7 @@ export const ProgressBar: React.FC = ({ {label && {label} } {filled} - {empty} + {emptyWidth > 0 && {empty}} {showPercentage && ( {Math.round(clampedValue)}% )} diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index d522c0f659..f88e445d03 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -32,13 +32,13 @@ const formatRenewalDate = (dateStr: string | null): string => { const isToday = resetDate.toDateString() === today.toDateString() return isToday ? resetDate.toLocaleString('en-US', { - hour: 'numeric', - minute: '2-digit', - }) + hour: 'numeric', + minute: '2-digit', + }) : resetDate.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }) + month: 'short', + day: 'numeric', + }) } export const UsageBanner = ({ showTime }: { showTime: number }) => { @@ -120,7 +120,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { - {subscriptionData.displayName ?? 'Strong'} subscription + 💪 {subscriptionData.displayName ?? 'Strong'} subscription {subscriptionInfo?.tier && ( ${subscriptionInfo.tier}/mo @@ -130,37 +130,38 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { Loading subscription data... ) : rateLimit ? ( - {/* Block progress - show if there's an active block */} - {rateLimit.blockLimit != null && rateLimit.blockUsed != null && ( - - 5h limit: - - - {Math.max(0, 100 - Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100))}% remaining - - {rateLimit.blockResetsAt && ( + {/* Block progress - always show for Strong subscription */} + {(() => { + const blockPercent = rateLimit.blockLimit != null && rateLimit.blockUsed != null + ? Math.max(0, 100 - Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100)) + : 100 + return ( + + 5-hour limit + {`${blockPercent}%`.padStart(4)} + - · resets in {formatResetTime(new Date(rateLimit.blockResetsAt))} + {rateLimit.blockResetsAt + ? ` resets in ${formatResetTime(new Date(rateLimit.blockResetsAt))}` + : ''} - )} - - )} + + ) + })()} {/* Weekly progress */} - - Weekly limit: - - - {100 - rateLimit.weeklyPercentUsed}% remaining · resets in {formatResetTimeLong(rateLimit.weeklyResetsAt)} - - + {(() => { + const weeklyPercent = 100 - rateLimit.weeklyPercentUsed + return ( + + Weekly limit + {`${weeklyPercent}%`.padStart(4)} + + + {' '}resets in {formatResetTimeLong(rateLimit.weeklyResetsAt)} + + + ) + })()} ) : null} diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index facaf613b1..3d0ca33307 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -148,7 +148,7 @@ function SubscriptionActive({
- Session + 5-hour limit {rateLimit.blockResetsAt && ( resets in {formatDaysHours(rateLimit.blockResetsAt)} @@ -158,7 +158,7 @@ function SubscriptionActive({ {rateLimit.blockLimit != null && rateLimit.blockUsed != null && rateLimit.blockLimit > 0 ? `${Math.round(100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100)}%` - : '100%'} + : '100%'} remaining
- Weekly + Weekly limit resets in {formatDaysHours(rateLimit.weeklyResetsAt)} - {100 - rateLimit.weeklyPercentUsed}% + {100 - rateLimit.weeklyPercentUsed}% remaining
Date: Mon, 2 Feb 2026 00:34:53 -0800 Subject: [PATCH 35/83] Revert thinking code changes --- cli/src/utils/block-operations.ts | 17 +---------------- cli/src/utils/message-block-helpers.ts | 1 - 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/cli/src/utils/block-operations.ts b/cli/src/utils/block-operations.ts index 32352c85ad..1f1a86234c 100644 --- a/cli/src/utils/block-operations.ts +++ b/cli/src/utils/block-operations.ts @@ -12,17 +12,6 @@ import type { TextContentBlock, } from '../types/chat' -const SHORT_THINKING_LINE_THRESHOLD = 5 -const SHORT_THINKING_CHAR_LIMIT = 500 - -/** - * Returns true if the thinking content is short enough to stay uncollapsed. - */ -export const isShortThinkingContent = (content: string): boolean => { - const trimmed = content.trim() - return trimmed.split('\n').length <= SHORT_THINKING_LINE_THRESHOLD && trimmed.length <= SHORT_THINKING_CHAR_LIMIT -} - let thinkingIdCounter = 0 const generateThinkingId = (): string => { thinkingIdCounter++ @@ -245,12 +234,10 @@ const appendTextWithThinkParsingToBlocks = ( const thinkingOpen = !hasMoreSegments && !textToParse.includes(THINK_CLOSE_TAG) - const closedContent = currentLastBlock.content + firstSegment.content nextBlocks[nextBlocks.length - 1] = { ...currentLastBlock, - content: closedContent, + content: currentLastBlock.content + firstSegment.content, thinkingOpen, - ...(!thinkingOpen && isShortThinkingContent(closedContent) && { isCollapsed: false }), } } segmentStartIdx = 1 @@ -262,7 +249,6 @@ const appendTextWithThinkParsingToBlocks = ( nextBlocks[nextBlocks.length - 1] = { ...currentLastBlock, thinkingOpen: false, - ...(isShortThinkingContent(currentLastBlock.content) && { isCollapsed: false }), } } } @@ -399,7 +385,6 @@ export const closeNativeReasoningBlock = ( nextBlocks[lastReasoningIndex] = { ...reasoningBlock, thinkingOpen: false, - ...(isShortThinkingContent(reasoningBlock.content) && { isCollapsed: false }), } return nextBlocks } diff --git a/cli/src/utils/message-block-helpers.ts b/cli/src/utils/message-block-helpers.ts index 163a029bc0..b9668da411 100644 --- a/cli/src/utils/message-block-helpers.ts +++ b/cli/src/utils/message-block-helpers.ts @@ -1,6 +1,5 @@ import { isEqual } from 'lodash' -import { isShortThinkingContent } from './block-operations' import { formatToolOutput } from './codebuff-client' import { shouldCollapseByDefault, shouldCollapseForParent } from './constants' From 0a72e183452c0358cdffcfdfdec8fe34ca770ef3 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 00:49:27 -0800 Subject: [PATCH 36/83] Refactor to pull out Subscription types --- cli/src/components/message-footer.tsx | 42 ++-- .../components/subscription-limit-banner.tsx | 31 +-- cli/src/components/usage-banner.tsx | 132 +++++++----- cli/src/hooks/use-subscription-query.ts | 42 +--- common/src/types/subscription.ts | 62 ++++++ packages/billing/src/usage-service.ts | 14 +- .../components/subscription-section.tsx | 192 ++++++------------ 7 files changed, 236 insertions(+), 279 deletions(-) create mode 100644 common/src/types/subscription.ts diff --git a/cli/src/components/message-footer.tsx b/cli/src/components/message-footer.tsx index 5e0a81bca8..57138ed7da 100644 --- a/cli/src/components/message-footer.tsx +++ b/cli/src/components/message-footer.tsx @@ -213,61 +213,43 @@ export const MessageFooter: React.FC = ({ ) } -/** - * Shows either subscription indicator or credits count based on subscription status. - * If user has an active subscription with remaining block credits, shows "✓ Strong". - * If block is < 15% remaining, also shows the percentage. - * Otherwise, shows the regular credits count. - */ const CreditsOrSubscriptionIndicator: React.FC<{ credits: number }> = ({ credits }) => { const theme = useTheme() const { data: subscriptionData } = useSubscriptionQuery({ - refetchInterval: false, // Don't poll, just use cached data + refetchInterval: false, refetchOnActivity: false, pauseWhenIdle: false, }) - const hasActiveSubscription = subscriptionData?.hasSubscription === true - const rateLimit = subscriptionData?.rateLimit - const isLimited = rateLimit?.limited === true + const activeSubscription = subscriptionData?.hasSubscription ? subscriptionData : null + const rateLimit = activeSubscription?.rateLimit - // Calculate block remaining percentage const blockPercentRemaining = useMemo(() => { if (!rateLimit?.blockLimit || rateLimit.blockUsed == null) return null - const remaining = rateLimit.blockLimit - rateLimit.blockUsed - return Math.round((remaining / rateLimit.blockLimit) * 100) + return Math.round(((rateLimit.blockLimit - rateLimit.blockUsed) / rateLimit.blockLimit) * 100) }, [rateLimit]) - // Show subscription indicator if user has active subscription and block is not depleted - const showSubscriptionIndicator = hasActiveSubscription && !isLimited && blockPercentRemaining !== null && blockPercentRemaining > 0 + const showSubscriptionIndicator = + activeSubscription && !rateLimit?.limited && blockPercentRemaining != null && blockPercentRemaining > 0 if (showSubscriptionIndicator) { - const showPercentage = blockPercentRemaining < 20 + const label = blockPercentRemaining < 20 + ? `✓ ${SUBSCRIPTION_DISPLAY_NAME} (${blockPercentRemaining}% left)` + : `✓ ${SUBSCRIPTION_DISPLAY_NAME}` return ( - {showPercentage ? `✓ ${SUBSCRIPTION_DISPLAY_NAME} (${blockPercentRemaining}% left)` : `✓ ${SUBSCRIPTION_DISPLAY_NAME}`} + {label} ) } - // Default: show credits count return ( {pluralize(credits, 'credit')} diff --git a/cli/src/components/subscription-limit-banner.tsx b/cli/src/components/subscription-limit-banner.tsx index b4a63103cb..74590ebb08 100644 --- a/cli/src/components/subscription-limit-banner.tsx +++ b/cli/src/components/subscription-limit-banner.tsx @@ -28,7 +28,7 @@ export const SubscriptionLimitBanner = () => { refetchInterval: 30 * 1000, }) - const rateLimit = subscriptionData?.rateLimit + const rateLimit = subscriptionData?.hasSubscription ? subscriptionData.rateLimit : undefined const remainingBalance = usageData?.remainingBalance ?? 0 const hasAlaCarteCredits = remainingBalance > 0 @@ -42,29 +42,16 @@ export const SubscriptionLimitBanner = () => { setAlwaysUseALaCarte(newValue) } - if (!subscriptionData) { - return ( - - Loading subscription data... - - ) - } - - if (!rateLimit?.limited) { + if (!subscriptionData || !rateLimit?.limited) { return null } - const isWeeklyLimit = rateLimit.reason === 'weekly_limit' - const isBlockExhausted = rateLimit.reason === 'block_exhausted' - - const weeklyRemaining = 100 - rateLimit.weeklyPercentUsed - const weeklyResetsAt = rateLimit.weeklyResetsAt - ? new Date(rateLimit.weeklyResetsAt) - : null - - const blockResetsAt = rateLimit.blockResetsAt - ? new Date(rateLimit.blockResetsAt) - : null + const { reason, weeklyPercentUsed, weeklyResetsAt: weeklyResetsAtStr, blockResetsAt: blockResetsAtStr } = rateLimit + const isWeeklyLimit = reason === 'weekly_limit' + const isBlockExhausted = reason === 'block_exhausted' + const weeklyRemaining = 100 - weeklyPercentUsed + const weeklyResetsAt = weeklyResetsAtStr ? new Date(weeklyResetsAtStr) : null + const blockResetsAt = blockResetsAtStr ? new Date(blockResetsAtStr) : null const handleContinueWithCredits = () => { setInputMode('default') @@ -137,7 +124,7 @@ export const SubscriptionLimitBanner = () => { Weekly: - {rateLimit.weeklyPercentUsed}% used + {weeklyPercentUsed}% used {hasAlaCarteCredits && ( diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index f88e445d03..210764acca 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -1,6 +1,6 @@ import { isClaudeOAuthValid } from '@codebuff/sdk' import open from 'open' -import React, { useEffect } from 'react' +import React, { useEffect, useMemo } from 'react' import { BottomBanner } from './bottom-banner' import { Button } from './button' @@ -105,9 +105,8 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { const adCredits = activeData.balanceBreakdown?.ad const renewalDate = activeData.next_quota_reset ? formatRenewalDate(activeData.next_quota_reset) : null - const hasSubscription = subscriptionData?.hasSubscription === true - const rateLimit = subscriptionData?.rateLimit - const subscriptionInfo = subscriptionData?.subscription + const activeSubscription = subscriptionData?.hasSubscription ? subscriptionData : null + const { rateLimit, subscription: subscriptionInfo, displayName } = activeSubscription ?? {} return ( { onClose={() => setInputMode('default')} > - {/* Strong subscription section - only show if subscribed */} - {hasSubscription && ( - - - - 💪 {subscriptionData.displayName ?? 'Strong'} subscription - - {subscriptionInfo?.tier && ( - ${subscriptionInfo.tier}/mo - )} - - {isSubscriptionLoading ? ( - Loading subscription data... - ) : rateLimit ? ( - - {/* Block progress - always show for Strong subscription */} - {(() => { - const blockPercent = rateLimit.blockLimit != null && rateLimit.blockUsed != null - ? Math.max(0, 100 - Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100)) - : 100 - return ( - - 5-hour limit - {`${blockPercent}%`.padStart(4)} - - - {rateLimit.blockResetsAt - ? ` resets in ${formatResetTime(new Date(rateLimit.blockResetsAt))}` - : ''} - - - ) - })()} - {/* Weekly progress */} - {(() => { - const weeklyPercent = 100 - rateLimit.weeklyPercentUsed - return ( - - Weekly limit - {`${weeklyPercent}%`.padStart(4)} - - - {' '}resets in {formatResetTimeLong(rateLimit.weeklyResetsAt)} - - - ) - })()} - - ) : null} - + {activeSubscription && ( + )} {/* Codebuff credits section - structured layout */} @@ -190,7 +146,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { {adCredits != null && adCredits > 0 && ( {`(${adCredits} from ads)`} )} - {!hasSubscription && renewalDate && ( + {!activeSubscription && renewalDate && ( <> · Renews: {renewalDate} @@ -239,3 +195,69 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { ) } + +interface SubscriptionUsageSectionProps { + displayName?: string + subscriptionInfo?: { tier: number } + rateLimit?: { + blockLimit?: number + blockUsed?: number + blockResetsAt?: string + weeklyPercentUsed: number + weeklyResetsAt: string + } + isLoading: boolean +} + +const SubscriptionUsageSection: React.FC = ({ + displayName, + subscriptionInfo, + rateLimit, + isLoading, +}) => { + const theme = useTheme() + + const blockPercent = useMemo(() => { + if (rateLimit?.blockLimit == null || rateLimit.blockUsed == null) return 100 + return Math.max(0, 100 - Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100)) + }, [rateLimit?.blockLimit, rateLimit?.blockUsed]) + + const weeklyPercent = rateLimit ? 100 - rateLimit.weeklyPercentUsed : 100 + + return ( + + + + 💪 {displayName ?? 'Strong'} subscription + + {subscriptionInfo?.tier && ( + ${subscriptionInfo.tier}/mo + )} + + {isLoading ? ( + Loading subscription data... + ) : rateLimit ? ( + + + 5-hour limit + {`${blockPercent}%`.padStart(4)} + + + {rateLimit.blockResetsAt + ? ` resets in ${formatResetTime(new Date(rateLimit.blockResetsAt))}` + : ''} + + + + Weekly limit + {`${weeklyPercent}%`.padStart(4)} + + + {' '}resets in {formatResetTimeLong(rateLimit.weeklyResetsAt)} + + + + ) : null} + + ) +} diff --git a/cli/src/hooks/use-subscription-query.ts b/cli/src/hooks/use-subscription-query.ts index 83cd5f6142..75ea01166a 100644 --- a/cli/src/hooks/use-subscription-query.ts +++ b/cli/src/hooks/use-subscription-query.ts @@ -4,52 +4,20 @@ import { getApiClient } from '../utils/codebuff-api' import { logger as defaultLogger } from '../utils/logger' import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { SubscriptionResponse } from '@codebuff/common/types/subscription' + +export type { SubscriptionResponse } export const subscriptionQueryKeys = { all: ['subscription'] as const, current: () => [...subscriptionQueryKeys.all, 'current'] as const, } -export interface SubscriptionRateLimit { - limited: boolean - reason?: 'block_exhausted' | 'weekly_limit' - canStartNewBlock: boolean - blockUsed?: number - blockLimit?: number - blockResetsAt?: string - weeklyUsed: number - weeklyLimit: number - weeklyResetsAt: string - weeklyPercentUsed: number -} - -export interface SubscriptionInfo { - status: string - billingPeriodEnd: string - cancelAtPeriodEnd: boolean - canceledAt: string | null - tier: number -} - -export interface SubscriptionLimits { - creditsPerBlock: number - blockDurationHours: number - weeklyCreditsLimit: number -} - -export interface SubscriptionData { - hasSubscription: boolean - displayName?: string - subscription?: SubscriptionInfo - rateLimit?: SubscriptionRateLimit - limits?: SubscriptionLimits -} - export async function fetchSubscriptionData( logger: Logger = defaultLogger, -): Promise { +): Promise { const client = getApiClient() - const response = await client.get( + const response = await client.get( '/api/user/subscription', { includeCookie: true }, ) diff --git a/common/src/types/subscription.ts b/common/src/types/subscription.ts new file mode 100644 index 0000000000..7abd58f7ae --- /dev/null +++ b/common/src/types/subscription.ts @@ -0,0 +1,62 @@ +/** + * Core subscription information for an active subscription. + */ +export interface SubscriptionInfo { + status: string + billingPeriodEnd: string + cancelAtPeriodEnd: boolean + canceledAt: string | null + tier: number + scheduledTier?: number | null +} + +/** + * Rate limit information for subscription usage. + */ +export interface SubscriptionRateLimit { + limited: boolean + reason?: 'block_exhausted' | 'weekly_limit' + canStartNewBlock: boolean + blockUsed?: number + blockLimit?: number + blockResetsAt?: string + weeklyUsed: number + weeklyLimit: number + weeklyResetsAt: string + weeklyPercentUsed: number +} + +/** + * Subscription limits configuration. + */ +export interface SubscriptionLimits { + creditsPerBlock: number + blockDurationHours: number + weeklyCreditsLimit: number +} + +/** + * Response when user has no active subscription. + */ +export interface NoSubscriptionResponse { + hasSubscription: false +} + +/** + * Response when user has an active subscription. + * All fields are required - no invalid states possible. + */ +export interface ActiveSubscriptionResponse { + hasSubscription: true + displayName: string + subscription: SubscriptionInfo + rateLimit: SubscriptionRateLimit + limits: SubscriptionLimits +} + +/** + * Discriminated union for subscription API response. + * Use `hasSubscription` to narrow the type. + */ +export type SubscriptionResponse = NoSubscriptionResponse | ActiveSubscriptionResponse + diff --git a/packages/billing/src/usage-service.ts b/packages/billing/src/usage-service.ts index 80b6f41fe8..df47cf628e 100644 --- a/packages/billing/src/usage-service.ts +++ b/packages/billing/src/usage-service.ts @@ -14,19 +14,17 @@ 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 + subscription?: { + status: string + billingPeriodEnd: string + cancelAtPeriodEnd: boolean + } } export interface OrganizationUsageData { @@ -88,7 +86,7 @@ export async function getUserUsageData(params: { }) // Check for active subscription - let subscription: SubscriptionInfo | undefined + let subscription: UserUsageData['subscription'] const activeSub = await getActiveSubscription({ userId, logger }) if (activeSub) { subscription = { diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index 3d0ca33307..9c3e7dbf4b 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -15,61 +15,25 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { cn } from '@/lib/utils' -interface SubscriptionApiResponse { - hasSubscription: boolean - displayName?: string - subscription?: { - status: string - billingPeriodEnd: string - cancelAtPeriodEnd: boolean - canceledAt: string | null - tier?: number | null - scheduledTier?: 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 - } -} +import type { + SubscriptionResponse, + ActiveSubscriptionResponse, +} from '@codebuff/common/types/subscription' function formatDaysHours(dateStr: string): string { const target = new Date(dateStr) - const now = new Date() - const diffMs = target.getTime() - now.getTime() + const diffMs = target.getTime() - Date.now() if (isNaN(diffMs) || diffMs <= 0) return '0h' const totalHours = Math.ceil(diffMs / (1000 * 60 * 60)) const days = Math.floor(totalHours / 24) const hours = totalHours % 24 - if (days > 0) { - return hours > 0 ? `${days}d ${hours}h` : `${days}d` - } + if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d` return `${hours}h` } - -function ProgressBar({ - percentAvailable, - label, - className, -}: { - percentAvailable: number - label: string - className?: string -}) { +function ProgressBar({ percentAvailable, label }: { percentAvailable: number; label: string }) { const percent = Math.min(100, Math.max(0, Math.round(percentAvailable))) + const colorClass = percent <= 0 ? 'bg-red-500' : percent <= 25 ? 'bg-yellow-500' : 'bg-green-500' return (
) } -function SubscriptionActive({ - data, - email, -}: { - data: SubscriptionApiResponse - email: string -}) { +function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; email: string }) { const { subscription, rateLimit } = data - - const isCanceling = subscription?.cancelAtPeriodEnd + const isCanceling = subscription.cancelAtPeriodEnd const billingPortalUrl = `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` return ( @@ -117,14 +64,14 @@ function SubscriptionActive({ 💪 {SUBSCRIPTION_DISPLAY_NAME} - ${subscription?.tier ?? 200}/mo + ${subscription.tier}/mo {isCanceling && ( Canceling )} - {subscription?.scheduledTier != null && ( + {subscription.scheduledTier != null && ( Renewing at ${subscription.scheduledTier}/mo @@ -142,69 +89,60 @@ function SubscriptionActive({
- {/* Block usage */} - {rateLimit && ( - <> -
-
- - 5-hour limit - {rateLimit.blockResetsAt && ( - - resets in {formatDaysHours(rateLimit.blockResetsAt)} - - )} - - - {rateLimit.blockLimit != null && rateLimit.blockUsed != null && rateLimit.blockLimit > 0 - ? `${Math.round(100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100)}%` - : '100%'} remaining +
+
+ + 5-hour limit + {rateLimit.blockResetsAt && ( + + resets in {formatDaysHours(rateLimit.blockResetsAt)} -
- 0 - ? 100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100 - : 100 - } - label="Session usage" - /> -
+ )} +
+ + {rateLimit.blockLimit && rateLimit.blockUsed != null + ? `${Math.round(100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100)}%` + : '100%'} remaining + +
+ +
- {/* Weekly usage */} -
-
- - Weekly limit - - resets in {formatDaysHours(rateLimit.weeklyResetsAt)} - - - - {100 - rateLimit.weeklyPercentUsed}% remaining - -
- -
+
+
+ + Weekly limit + + resets in {formatDaysHours(rateLimit.weeklyResetsAt)} + + + + {100 - rateLimit.weeklyPercentUsed}% remaining + +
+ +
- {/* Rate limit warning */} - {rateLimit.limited && ( -
- -

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

-
- )} - + {rateLimit.limited && ( +
+ +

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

+
)} - -
) @@ -241,7 +179,7 @@ function SubscriptionCta() { export function SubscriptionSection() { const { data: session, status } = useSession() - const { data, isLoading } = useQuery({ + const { data, isLoading } = useQuery({ queryKey: ['subscription'], queryFn: async () => { const res = await fetch('/api/user/subscription') From c9b56fc155fbc137b091426b091a4b5c0aab57cf Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 00:53:54 -0800 Subject: [PATCH 37/83] Use generated updated_at for subscription table --- packages/billing/src/subscription-webhooks.ts | 6 ----- .../migrations/0039_automatic_updated_at.sql | 24 +++++++++++++++++++ .../api/stripe/cancel-subscription/route.ts | 2 +- .../stripe/change-subscription-tier/route.ts | 4 +--- web/src/app/api/user/subscription/route.ts | 15 ++++++++---- 5 files changed, 37 insertions(+), 14 deletions(-) create mode 100644 packages/internal/src/db/migrations/0039_automatic_updated_at.sql diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 378e8eb5f8..5d42e08422 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -146,7 +146,6 @@ export async function handleSubscriptionInvoicePaid(params: { ), billing_period_end: new Date(stripeSub.current_period_end * 1000), cancel_at_period_end: stripeSub.cancel_at_period_end, - updated_at: new Date(), }, }) @@ -197,7 +196,6 @@ export async function handleSubscriptionInvoicePaymentFailed(params: { .update(schema.subscription) .set({ status: 'past_due', - updated_at: new Date(), }) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) @@ -310,7 +308,6 @@ export async function handleSubscriptionUpdated(params: { billing_period_end: new Date( stripeSubscription.current_period_end * 1000, ), - updated_at: new Date(), }, }) @@ -356,7 +353,6 @@ export async function handleSubscriptionDeleted(params: { status: 'canceled', scheduled_tier: null, canceled_at: new Date(), - updated_at: new Date(), }) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) @@ -456,7 +452,6 @@ export async function handleSubscriptionScheduleCreatedOrUpdated(params: { .update(schema.subscription) .set({ scheduled_tier: scheduledTier, - updated_at: new Date(), }) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) .returning({ tier: schema.subscription.tier }) @@ -526,7 +521,6 @@ export async function handleSubscriptionScheduleReleasedOrCanceled(params: { .update(schema.subscription) .set({ scheduled_tier: null, - updated_at: new Date(), }) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) .returning({ tier: schema.subscription.tier }) diff --git a/packages/internal/src/db/migrations/0039_automatic_updated_at.sql b/packages/internal/src/db/migrations/0039_automatic_updated_at.sql new file mode 100644 index 0000000000..ac3863f399 --- /dev/null +++ b/packages/internal/src/db/migrations/0039_automatic_updated_at.sql @@ -0,0 +1,24 @@ +-- Create a reusable function that sets updated_at to NOW() +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +--> statement-breakpoint + +-- Add trigger to subscription table +CREATE TRIGGER trigger_subscription_updated_at + BEFORE UPDATE ON "subscription" + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +--> statement-breakpoint + +-- Add trigger to limit_override table +CREATE TRIGGER trigger_limit_override_updated_at + BEFORE UPDATE ON "limit_override" + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); diff --git a/web/src/app/api/stripe/cancel-subscription/route.ts b/web/src/app/api/stripe/cancel-subscription/route.ts index d7075802c6..af1aa779bc 100644 --- a/web/src/app/api/stripe/cancel-subscription/route.ts +++ b/web/src/app/api/stripe/cancel-subscription/route.ts @@ -44,7 +44,7 @@ export async function POST() { try { await db .update(schema.subscription) - .set({ cancel_at_period_end: true, scheduled_tier: null, updated_at: new Date() }) + .set({ cancel_at_period_end: true, scheduled_tier: null }) .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 ac5b9f245d..cef5e70b02 100644 --- a/web/src/app/api/stripe/change-subscription-tier/route.ts +++ b/web/src/app/api/stripe/change-subscription-tier/route.ts @@ -122,7 +122,7 @@ export async function POST(req: NextRequest) { if (isCancelDowngrade) { await db .update(schema.subscription) - .set({ scheduled_tier: null, updated_at: new Date() }) + .set({ scheduled_tier: null }) .where( eq( schema.subscription.stripe_subscription_id, @@ -137,7 +137,6 @@ export async function POST(req: NextRequest) { tier, stripe_price_id: newPriceId, scheduled_tier: null, - updated_at: new Date(), }) .where( eq( @@ -158,7 +157,6 @@ export async function POST(req: NextRequest) { .update(schema.subscription) .set({ scheduled_tier: tier, - updated_at: new Date(), }) .where( eq( diff --git a/web/src/app/api/user/subscription/route.ts b/web/src/app/api/user/subscription/route.ts index c8d53b8dbd..cb0be3751d 100644 --- a/web/src/app/api/user/subscription/route.ts +++ b/web/src/app/api/user/subscription/route.ts @@ -10,6 +10,11 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' import { logger } from '@/util/logger' +import type { + NoSubscriptionResponse, + ActiveSubscriptionResponse, +} from '@codebuff/common/types/subscription' + export async function GET() { const session = await getServerSession(authOptions) if (!session?.user?.id) { @@ -19,8 +24,9 @@ export async function GET() { const userId = session.user.id const subscription = await getActiveSubscription({ userId, logger }) - if (!subscription) { - return NextResponse.json({ hasSubscription: false }) + if (!subscription || !subscription.tier) { + const response: NoSubscriptionResponse = { hasSubscription: false } + return NextResponse.json(response) } const [rateLimit, limits] = await Promise.all([ @@ -28,7 +34,7 @@ export async function GET() { getSubscriptionLimits({ userId, logger, tier: subscription.tier }), ]) - return NextResponse.json({ + const response: ActiveSubscriptionResponse = { hasSubscription: true, displayName: SUBSCRIPTION_DISPLAY_NAME, subscription: { @@ -52,5 +58,6 @@ export async function GET() { weeklyPercentUsed: rateLimit.weeklyPercentUsed, }, limits, - }) + } + return NextResponse.json(response) } From a52d40326e38130194e08ab2f584d9a118691262 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 01:01:00 -0800 Subject: [PATCH 38/83] Improve stripe "phases" docs --- packages/billing/src/subscription-webhooks.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 5d42e08422..546cd335f9 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -414,7 +414,10 @@ export async function handleSubscriptionScheduleCreatedOrUpdated(params: { return } - // Need at least 2 phases to have a scheduled change (current + future) + // Stripe subscription schedules use "phases" to represent timeline segments: + // - Phase 0: The current subscription state (e.g., $200/month) + // - Phase 1: The scheduled future state (e.g., $100/month after renewal) + // We need at least 2 phases to have a pending change; 1 phase means no scheduled change. if (!schedule.phases || schedule.phases.length < 2) { logger.debug( { scheduleId: schedule.id, subscriptionId, phases: schedule.phases?.length }, @@ -423,7 +426,7 @@ export async function handleSubscriptionScheduleCreatedOrUpdated(params: { return } - // Extract the scheduled tier from the next phase (phase 1) + // Extract the scheduled tier from phase 1 (the upcoming change) const nextPhase = schedule.phases[1] const scheduledPriceId = nextPhase?.items?.[0]?.price const priceId = typeof scheduledPriceId === 'string' From fba5e793ec237a0e8f93f4af16e567c3e93152d9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 01:05:28 -0800 Subject: [PATCH 39/83] Let you change setting for pause/spend credits for when subscription runs out --- cli/src/chat.tsx | 5 +++-- cli/src/components/usage-banner.tsx | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index bc66989404..afed039478 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -1254,8 +1254,9 @@ export const Chat = ({ // Auto-show subscription limit banner when rate limit becomes active const subscriptionLimitShownRef = useRef(false) + const subscriptionRateLimit = subscriptionData?.hasSubscription ? subscriptionData.rateLimit : undefined useEffect(() => { - const isLimited = subscriptionData?.rateLimit?.limited === true + const isLimited = subscriptionRateLimit?.limited === true if (isLimited && !subscriptionLimitShownRef.current) { subscriptionLimitShownRef.current = true // Skip showing the banner if user prefers to always fall back to a-la-carte @@ -1268,7 +1269,7 @@ export const Chat = ({ useChatStore.getState().setInputMode('default') } } - }, [subscriptionData?.rateLimit?.limited]) + }, [subscriptionRateLimit?.limited]) const inputBoxTitle = useMemo(() => { const segments: string[] = [] diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 210764acca..9583f22c3e 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -1,6 +1,6 @@ import { isClaudeOAuthValid } from '@codebuff/sdk' import open from 'open' -import React, { useEffect, useMemo } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { BottomBanner } from './bottom-banner' import { Button } from './button' @@ -11,6 +11,7 @@ import { useSubscriptionQuery } from '../hooks/use-subscription-query' import { useTheme } from '../hooks/use-theme' import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' import { WEBSITE_URL } from '../login/constants' +import { getAlwaysUseALaCarte, setAlwaysUseALaCarte } from '../utils/settings' import { useChatStore } from '../state/chat-store' import { formatResetTime, formatResetTimeLong } from '../utils/time-format' import { @@ -216,6 +217,13 @@ const SubscriptionUsageSection: React.FC = ({ isLoading, }) => { const theme = useTheme() + const [useALaCarte, setUseALaCarte] = useState(() => getAlwaysUseALaCarte()) + + const handleToggleALaCarte = () => { + const newValue = !useALaCarte + setUseALaCarte(newValue) + setAlwaysUseALaCarte(newValue) + } const blockPercent = useMemo(() => { if (rateLimit?.blockLimit == null || rateLimit.blockUsed == null) return 100 @@ -258,6 +266,17 @@ const SubscriptionUsageSection: React.FC = ({ ) : null} + + When limit reached: + + {useALaCarte ? 'spend credits' : 'pause'} + + + ) } From 2d9cbea2975efac1d6591e168aecfedd53f6409a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 01:06:19 -0800 Subject: [PATCH 40/83] Refactor so only one ensureSubscriberBlockGrant function is injected --- packages/billing/src/subscription.ts | 18 ++++++++++++++++++ web/src/app/api/v1/chat/completions/_post.ts | 20 ++++++-------------- web/src/app/api/v1/chat/completions/route.ts | 8 ++------ 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index 7716742ed0..279c7f5244 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -422,6 +422,24 @@ export async function ensureActiveBlockGrant(params: { return result } +/** + * Combined function that gets the active subscription and ensures a block grant exists. + * Returns the block grant result if the user has an active subscription, null otherwise. + */ +export async function ensureSubscriberBlockGrant(params: { + userId: string + logger: Logger +}): Promise { + const { userId, logger } = params + + const subscription = await getActiveSubscription({ userId, logger }) + if (!subscription) { + return null + } + + return ensureActiveBlockGrant({ userId, subscription, logger }) +} + // --------------------------------------------------------------------------- // Rate limiting // --------------------------------------------------------------------------- diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index d8dcadd6f9..11801d225d 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -18,10 +18,7 @@ import type { LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' -import type { - BlockGrantResult, - SubscriptionRow, -} from '@codebuff/billing/subscription' +import type { BlockGrantResult } from '@codebuff/billing/subscription' import type { NextRequest } from 'next/server' import type { ChatCompletionRequestBody } from '@/llm-api/types' @@ -83,8 +80,7 @@ export async function postChatCompletions(params: { getAgentRunFromId: GetAgentRunFromIdFn fetch: typeof globalThis.fetch insertMessageBigquery: InsertMessageBigqueryFn - getActiveSubscription?: (params: { userId: string; logger: Logger }) => Promise - ensureActiveBlockGrant?: (params: { userId: string; subscription: SubscriptionRow; logger: Logger }) => Promise + ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise }) { const { req, @@ -95,8 +91,7 @@ export async function postChatCompletions(params: { getAgentRunFromId, fetch, insertMessageBigquery, - getActiveSubscription, - ensureActiveBlockGrant, + ensureSubscriberBlockGrant, } = params let { logger } = params @@ -192,13 +187,10 @@ export async function postChatCompletions(params: { }) // For subscribers, ensure a block grant exists before checking balance. - // This is done here block grants should only start when the user begins working. - if (getActiveSubscription && ensureActiveBlockGrant) { + // This is done here because block grants should only start when the user begins working. + if (ensureSubscriberBlockGrant) { try { - const activeSub = await getActiveSubscription({ userId, logger }) - if (activeSub) { - await ensureActiveBlockGrant({ userId, subscription: activeSub, logger }) - } + await ensureSubscriberBlockGrant({ userId, logger }) } catch (error) { logger.error( { error: getErrorObject(error), userId }, diff --git a/web/src/app/api/v1/chat/completions/route.ts b/web/src/app/api/v1/chat/completions/route.ts index a92cf818e7..44db2feaeb 100644 --- a/web/src/app/api/v1/chat/completions/route.ts +++ b/web/src/app/api/v1/chat/completions/route.ts @@ -1,8 +1,5 @@ import { insertMessageBigquery } from '@codebuff/bigquery' -import { - ensureActiveBlockGrant, - getActiveSubscription, -} from '@codebuff/billing/subscription' +import { ensureSubscriberBlockGrant } from '@codebuff/billing/subscription' import { getUserUsageData } from '@codebuff/billing/usage-service' import { trackEvent } from '@codebuff/common/analytics' @@ -25,7 +22,6 @@ export async function POST(req: NextRequest) { getAgentRunFromId, fetch, insertMessageBigquery, - getActiveSubscription, - ensureActiveBlockGrant, + ensureSubscriberBlockGrant, }) } From 631838cdc5f2764903a71b233686ec6b9f0eba1c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 11:39:47 -0800 Subject: [PATCH 41/83] Tweaks for usage banner --- cli/src/components/usage-banner.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 9583f22c3e..b08bab5b30 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -1,4 +1,5 @@ import { isClaudeOAuthValid } from '@codebuff/sdk' +import { TextAttributes } from '@opentui/core' import open from 'open' import React, { useEffect, useMemo, useState } from 'react' @@ -155,7 +156,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { )} {/* See more link */} - ↗ See more on codebuff.com + See more on codebuff.com ↗ @@ -272,7 +273,7 @@ const SubscriptionUsageSection: React.FC = ({ {useALaCarte ? 'spend credits' : 'pause'} From fadcc88c0eb82ccf36d55d33ddab7d72b3e051ca Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 11:49:59 -0800 Subject: [PATCH 42/83] Clean up time formatting utils --- cli/src/utils/time-format.ts | 38 ++--------- common/src/util/dates.ts | 64 +++++++++++++++++++ .../components/subscription-section.tsx | 14 ++-- 3 files changed, 74 insertions(+), 42 deletions(-) diff --git a/cli/src/utils/time-format.ts b/cli/src/utils/time-format.ts index 20c3303dec..e7b4723602 100644 --- a/cli/src/utils/time-format.ts +++ b/cli/src/utils/time-format.ts @@ -1,47 +1,21 @@ +import { formatTimeUntil } from '@codebuff/common/util/dates' + /** - * Format time until reset in human-readable form + * Format time until reset in human-readable form. * @param resetDate - The date when the quota/resource resets * @returns Human-readable string like "2h 30m" or "45m" */ export const formatResetTime = (resetDate: Date | null): string => { if (!resetDate) return '' - const now = new Date() - const diffMs = resetDate.getTime() - now.getTime() - if (diffMs <= 0) return 'now' - - const diffMins = Math.floor(diffMs / (1000 * 60)) - const diffHours = Math.floor(diffMins / 60) - const remainingMins = diffMins % 60 - - if (diffHours > 0) { - return `${diffHours}h ${remainingMins}m` - } - return `${diffMins}m` + return formatTimeUntil(resetDate, { fallback: 'now' }) } /** - * Format time until reset in human-readable form, including days + * Format time until reset in human-readable form, including days. * @param resetDate - The date when the quota/resource resets * @returns Human-readable string like "4d 7h" or "2h 30m" */ export const formatResetTimeLong = (resetDate: Date | string | null): string => { if (!resetDate) return '' - const date = typeof resetDate === 'string' ? new Date(resetDate) : resetDate - const now = new Date() - const diffMs = date.getTime() - now.getTime() - if (diffMs <= 0) return 'now' - - const diffMins = Math.floor(diffMs / (1000 * 60)) - const diffHours = Math.floor(diffMins / 60) - const diffDays = Math.floor(diffHours / 24) - const remainingHours = diffHours % 24 - const remainingMins = diffMins % 60 - - if (diffDays > 0) { - return `${diffDays}d ${remainingHours}h` - } - if (diffHours > 0) { - return `${diffHours}h ${remainingMins}m` - } - return `${diffMins}m` + return formatTimeUntil(resetDate, { fallback: 'now' }) } diff --git a/common/src/util/dates.ts b/common/src/util/dates.ts index 6c75b68c19..57096e324a 100644 --- a/common/src/util/dates.ts +++ b/common/src/util/dates.ts @@ -15,3 +15,67 @@ export const getNextQuotaReset = (referenceDate: Date | null): Date => { } return nextMonth } + +export interface FormatTimeUntilOptions { + /** + * What to return when the date is in the past or invalid. + * @default 'now' + */ + fallback?: string + /** + * Whether to include the smaller unit (hours in "Xd Yh", minutes in "Xh Ym"). + * @default true + */ + includeSubUnit?: boolean +} + +/** + * Format the time until a future date in a human-readable string. + * + * @param date - The target date (Date object or ISO string) + * @param options - Formatting options + * @returns Human-readable string like "4d 7h", "2h 30m", or "45m" + * + * @example + * // Date 2 days and 5 hours in the future + * formatTimeUntil(futureDate) // "2d 5h" + * formatTimeUntil(futureDate, { includeSubUnit: false }) // "2d" + * + * // Date 3 hours and 20 minutes in the future + * formatTimeUntil(futureDate) // "3h 20m" + * + * // Date in the past + * formatTimeUntil(pastDate) // "now" + * formatTimeUntil(pastDate, { fallback: '0h' }) // "0h" + */ +export const formatTimeUntil = ( + date: Date | string | null, + options: FormatTimeUntilOptions = {}, +): string => { + const { fallback = 'now', includeSubUnit = true } = options + + if (!date) return fallback + + const target = typeof date === 'string' ? new Date(date) : date + const diffMs = target.getTime() - Date.now() + + if (isNaN(diffMs) || diffMs <= 0) return fallback + + const diffMins = Math.floor(diffMs / (1000 * 60)) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + const remainingHours = diffHours % 24 + const remainingMins = diffMins % 60 + + if (diffDays > 0) { + return includeSubUnit && remainingHours > 0 + ? `${diffDays}d ${remainingHours}h` + : `${diffDays}d` + } + if (diffHours > 0) { + return includeSubUnit && remainingMins > 0 + ? `${diffHours}h ${remainingMins}m` + : `${diffHours}h` + } + return `${diffMins}m` +} diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index 9c3e7dbf4b..5140946f43 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -15,21 +15,15 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { cn } from '@/lib/utils' +import { formatTimeUntil } from '@codebuff/common/util/dates' + import type { SubscriptionResponse, ActiveSubscriptionResponse, } from '@codebuff/common/types/subscription' -function formatDaysHours(dateStr: string): string { - const target = new Date(dateStr) - const diffMs = target.getTime() - Date.now() - if (isNaN(diffMs) || diffMs <= 0) return '0h' - const totalHours = Math.ceil(diffMs / (1000 * 60 * 60)) - const days = Math.floor(totalHours / 24) - const hours = totalHours % 24 - if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d` - return `${hours}h` -} +const formatDaysHours = (dateStr: string): string => + formatTimeUntil(dateStr, { fallback: '0h' }) function ProgressBar({ percentAvailable, label }: { percentAvailable: number; label: string }) { const percent = Math.min(100, Math.max(0, Math.round(percentAvailable))) From f68ac7365f07d683fb415df834284e873c04d3ed Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 12:13:21 -0800 Subject: [PATCH 43/83] Fetch authenticated billing portal link! --- common/src/types/subscription.ts | 1 + web/src/app/api/user/subscription/route.ts | 19 ++++++++++++++++++- .../components/subscription-section.tsx | 6 +++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/common/src/types/subscription.ts b/common/src/types/subscription.ts index 7abd58f7ae..498e25c395 100644 --- a/common/src/types/subscription.ts +++ b/common/src/types/subscription.ts @@ -52,6 +52,7 @@ export interface ActiveSubscriptionResponse { subscription: SubscriptionInfo rateLimit: SubscriptionRateLimit limits: SubscriptionLimits + billingPortalUrl?: string } /** diff --git a/web/src/app/api/user/subscription/route.ts b/web/src/app/api/user/subscription/route.ts index cb0be3751d..63844f7f2a 100644 --- a/web/src/app/api/user/subscription/route.ts +++ b/web/src/app/api/user/subscription/route.ts @@ -4,6 +4,8 @@ import { getSubscriptionLimits, } from '@codebuff/billing' import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' @@ -29,9 +31,23 @@ export async function GET() { return NextResponse.json(response) } - const [rateLimit, limits] = await Promise.all([ + const stripeCustomerId = session.user.stripe_customer_id + + const [rateLimit, limits, billingPortalUrl] = await Promise.all([ checkRateLimit({ userId, subscription, logger }), getSubscriptionLimits({ userId, logger, tier: subscription.tier }), + stripeCustomerId + ? stripeServer.billingPortal.sessions + .create({ + customer: stripeCustomerId, + return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile`, + }) + .then((portalSession) => portalSession.url) + .catch((error) => { + logger.warn({ userId, error }, 'Failed to create billing portal session') + return undefined + }) + : Promise.resolve(undefined), ]) const response: ActiveSubscriptionResponse = { @@ -58,6 +74,7 @@ export async function GET() { weeklyPercentUsed: rateLimit.weeklyPercentUsed, }, limits, + billingPortalUrl, } return NextResponse.json(response) } diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index 5140946f43..e8ef668297 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -48,7 +48,8 @@ function ProgressBar({ percentAvailable, label }: { percentAvailable: number; la function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; email: string }) { const { subscription, rateLimit } = data const isCanceling = subscription.cancelAtPeriodEnd - const billingPortalUrl = `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` + const fallbackPortalUrl = `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` + const billingPortalUrl = data.billingPortalUrl ?? fallbackPortalUrl return ( @@ -155,8 +156,7 @@ function SubscriptionCta() { Upgrade to {SUBSCRIPTION_DISPLAY_NAME}

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

From aedb14c3cec8e8866e59a6176484ea180c9e3326 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 14:06:39 -0800 Subject: [PATCH 44/83] Update the pricing to advertize codebuff strong --- web/src/app/pricing/pricing-client.tsx | 162 ++++++++++-------- .../ui/landing/feature/highlight-text.tsx | 5 +- .../components/ui/landing/feature/index.tsx | 4 +- 3 files changed, 100 insertions(+), 71 deletions(-) diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index e71b4a86c9..a2d63a62bf 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -1,7 +1,13 @@ 'use client' import { DEFAULT_FREE_CREDITS_GRANT } from '@codebuff/common/old-constants' -import { Gift, Shield, Link2, Zap, Terminal } from 'lucide-react' +import { + SUBSCRIPTION_TIERS, + SUBSCRIPTION_DISPLAY_NAME, + type SubscriptionTierPrice, +} from '@codebuff/common/constants/subscription-plans' +import { Gift, Shield, Sparkles, Zap, Brain } from 'lucide-react' +import Link from 'next/link' import { useSession } from 'next-auth/react' import { BlockColor } from '@/components/ui/decorative-blocks' @@ -62,72 +68,91 @@ function PricingCard() { ) } -function ClaudeSubscriptionIllustration() { - return ( -
-
- {/* Connection visual */} -
- {/* Claude card */} -
-
Claude
-
Pro / Max
-
- - {/* Connection arrow */} -
-
- -
-
- - {/* Codebuff card */} -
-
Codebuff
-
CLI
-
-
+const USAGE_MULTIPLIER: Record = { + 100: '1×', + 200: '3×', + 500: '8×', +} - {/* Benefits grid */} -
-
-
- -
-
-
- Save on credits +function StrongSubscriptionIllustration() { + return ( +
+
+
+ {/* Header with Strong branding */} +
+
+
+
-
- Use your subscription for Claude model requests +
+ {SUBSCRIPTION_DISPLAY_NAME} Subscription
+
Monthly plans
-
-
- + {/* Benefits */} +
+
+
+ +
+
+
Deep thinking
+
+ Multi-agent orchestration for complex tasks +
+
-
-
- Simple CLI setup + +
+
+
-
- Connect with one command +
+
Save on credits
+
+ Get more usage compared to pay-as-you-go +
-
- {/* Code snippet */} -
-
$ codebuff
-
- {'>'} /connect:claude -
-
- ✓ Connected to Claude subscription + {/* Pricing tiers */} +
+ {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 +
+
+ ) + })}
+ + {/* CTA */} + + Learn More +
@@ -222,6 +247,21 @@ export default function PricingClient() { return ( <> + Codebuff {SUBSCRIPTION_DISPLAY_NAME}} + description="Deep thinking, multi-agent orchestration, and unlimited potential. Subscribe to save credits with plans starting at $100/mo." + backdropColor={BlockColor.DarkForestGreen} + decorativeColors={[BlockColor.DarkForestGreen, BlockColor.AcidMatrix]} + textColor="text-white" + tagline="POWER SUBSCRIPTION" + highlightText="The strongest coding agent subscription" + highlightIcon="💪" + illustration={} + learnMoreText="Learn More" + learnMoreLink="/strong" + imagePosition="left" + /> + Simple, Usage-Based Pricing} description="Get 500 free credits monthly, then pay just 1¢ per credit. Credits are consumed based on task complexity — simple queries cost less, complex changes more. You'll see how many credits each task consumes." @@ -235,20 +275,6 @@ export default function PricingClient() { learnMoreLink={status === 'authenticated' ? '/usage' : '/login'} /> - Connect Your Claude Subscription} - description="Already have a Claude Pro or Max subscription? Connect it to Codebuff and use your existing subscription for Claude model requests. Note: Using your Claude Pro/Max subscription in Codebuff is not officially supported by Anthropic." - backdropColor={BlockColor.DarkForestGreen} - decorativeColors={[BlockColor.CRTAmber, BlockColor.BetweenGreen]} - textColor="text-white" - tagline="BRING YOUR OWN SUBSCRIPTION" - highlightText="Use your Claude Pro or Max subscription" - illustration={} - learnMoreText="View Documentation" - learnMoreLink="/docs" - imagePosition="left" - /> - Working with others} description="Collaborate with your team more closely using Codebuff by pooling credits and seeing usage analytics." diff --git a/web/src/components/ui/landing/feature/highlight-text.tsx b/web/src/components/ui/landing/feature/highlight-text.tsx index 0d70424aac..923f6e9bf7 100644 --- a/web/src/components/ui/landing/feature/highlight-text.tsx +++ b/web/src/components/ui/landing/feature/highlight-text.tsx @@ -5,9 +5,10 @@ import { cn } from '@/lib/utils' interface HighlightTextProps { text: string isLight?: boolean + icon?: string } -export function HighlightText({ text, isLight }: HighlightTextProps) { +export function HighlightText({ text, isLight, icon = '⚡' }: HighlightTextProps) { return ( -
+
{icon}
{text}
) diff --git a/web/src/components/ui/landing/feature/index.tsx b/web/src/components/ui/landing/feature/index.tsx index da18d774d9..9b276b3423 100644 --- a/web/src/components/ui/landing/feature/index.tsx +++ b/web/src/components/ui/landing/feature/index.tsx @@ -58,6 +58,7 @@ interface FeatureSectionProps { tagline: string decorativeColors?: BlockColor[] highlightText: string + highlightIcon?: string illustration: ReactNode learnMoreText?: string learnMoreLink: string @@ -86,6 +87,7 @@ export function FeatureSection({ tagline, decorativeColors = [BlockColor.GenerativeGreen, BlockColor.DarkForestGreen], highlightText, + highlightIcon, illustration, learnMoreText = 'Learn More', learnMoreLink, @@ -106,7 +108,7 @@ export function FeatureSection({
- +

{description} From e67902b5871432a5f5395e8e50cdb6f79f454d0c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 14:23:34 -0800 Subject: [PATCH 45/83] Update Codebuff strong screen --- web/src/app/strong/strong-client.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/web/src/app/strong/strong-client.tsx b/web/src/app/strong/strong-client.tsx index 4388c8bf8c..63ace1b6e3 100644 --- a/web/src/app/strong/strong-client.tsx +++ b/web/src/app/strong/strong-client.tsx @@ -72,7 +72,7 @@ function SubscribeButton({ onClick={handleSubscribe} disabled={isLoading} className={cn( - 'inline-flex items-center justify-center gap-2 rounded-lg px-10 py-3.5 text-base font-semibold transition-all duration-200', + 'inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 sm:px-10 sm:py-3.5 text-xs sm:text-base font-semibold transition-all duration-200', 'bg-acid-green text-black hover:bg-acid-green/90 shadow-[0_0_30px_rgba(0,255,149,0.2)] hover:shadow-[0_0_50px_rgba(0,255,149,0.3)]', 'disabled:opacity-60 disabled:cursor-not-allowed', className, @@ -156,7 +156,7 @@ export default function StrongClient() { {/* Foreground content */}

-
+
- + ${tier.monthlyPrice} - /mo + /mo
-

+

{USAGE_MULTIPLIER[price]} usage

@@ -236,7 +237,7 @@ export default function StrongClient() { animate={{ opacity: 1 }} transition={{ duration: 0.8, delay: 1.6 }} > - Cancel anytime + Cancel anytime · Tax not included · Usage amounts subject to change
From 6f754614dc5c17746a103b56233763da6863f2fe Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 14:56:02 -0800 Subject: [PATCH 46/83] Remove /strong page. Merge it into /pricing for simplicity --- cli/src/commands/command-registry.ts | 2 +- .../api/stripe/create-subscription/route.ts | 2 +- web/src/app/pricing/pricing-client.tsx | 344 ++++++++++++------ .../components/subscription-section.tsx | 2 +- web/src/app/strong/page.tsx | 39 -- web/src/app/strong/strong-client.tsx | 245 ------------- 6 files changed, 240 insertions(+), 394 deletions(-) delete mode 100644 web/src/app/strong/page.tsx delete mode 100644 web/src/app/strong/strong-client.tsx diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 8df9e91e56..897f1b5bbf 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -384,7 +384,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'subscribe', aliases: ['strong'], handler: (params) => { - open(WEBSITE_URL + '/strong') + open(WEBSITE_URL + '/pricing') clearInput(params) }, }), diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts index 202228e70c..3ae329c5d5 100644 --- a/web/src/app/api/stripe/create-subscription/route.ts +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -75,7 +75,7 @@ export async function POST(req: NextRequest) { 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`, + cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pricing?canceled=true`, metadata: { userId, type: 'strong_subscription', diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index a2d63a62bf..d7f768f008 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -6,13 +6,247 @@ import { SUBSCRIPTION_DISPLAY_NAME, type SubscriptionTierPrice, } from '@codebuff/common/constants/subscription-plans' -import { Gift, Shield, Sparkles, Zap, Brain } from 'lucide-react' -import Link from 'next/link' +import { env } from '@codebuff/common/env' +import { loadStripe } from '@stripe/stripe-js' +import { motion } from 'framer-motion' +import { Gift, Shield, Loader2 } from 'lucide-react' +import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' +import { useState } from 'react' import { BlockColor } from '@/components/ui/decorative-blocks' import { SECTION_THEMES } from '@/components/ui/landing/constants' import { FeatureSection } from '@/components/ui/landing/feature' +import { toast } from '@/components/ui/use-toast' +import { cn } from '@/lib/utils' + +const USAGE_MULTIPLIER: Record = { + 100: '1×', + 200: '3×', + 500: '8×', +} + +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=/pricing') + 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 ( + + ) +} + +function StrongHeroSection() { + 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 · Tax not included · Usage amounts subject to change + +
+
+ ) +} function CreditVisual() { return ( @@ -68,97 +302,6 @@ function PricingCard() { ) } -const USAGE_MULTIPLIER: Record = { - 100: '1×', - 200: '3×', - 500: '8×', -} - -function StrongSubscriptionIllustration() { - return ( -
-
-
- {/* Header with Strong branding */} -
-
-
- -
-
- {SUBSCRIPTION_DISPLAY_NAME} Subscription -
-
-
Monthly plans
-
- - {/* Benefits */} -
-
-
- -
-
-
Deep thinking
-
- Multi-agent orchestration for complex tasks -
-
-
- -
-
- -
-
-
Save on credits
-
- Get more usage compared to pay-as-you-go -
-
-
-
- - {/* Pricing tiers */} -
- {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 -
-
- ) - })} -
- - {/* CTA */} - - Learn More - -
-
-
- ) -} - function TeamPlanIllustration() { return (
@@ -247,20 +390,7 @@ export default function PricingClient() { return ( <> - Codebuff {SUBSCRIPTION_DISPLAY_NAME}} - description="Deep thinking, multi-agent orchestration, and unlimited potential. Subscribe to save credits with plans starting at $100/mo." - backdropColor={BlockColor.DarkForestGreen} - decorativeColors={[BlockColor.DarkForestGreen, BlockColor.AcidMatrix]} - textColor="text-white" - tagline="POWER SUBSCRIPTION" - highlightText="The strongest coding agent subscription" - highlightIcon="💪" - illustration={} - learnMoreText="Learn More" - learnMoreLink="/strong" - imagePosition="left" - /> + Simple, Usage-Based Pricing} diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index e8ef668297..4218650684 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -160,7 +160,7 @@ function SubscriptionCta() {

- + 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 63ace1b6e3..0000000000 --- a/web/src/app/strong/strong-client.tsx +++ /dev/null @@ -1,245 +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: '3×', - 500: '8×', -} - -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 · Tax not included · Usage amounts subject to change - -
-
- ) -} From a6def1fe2100521759655159c3ca96dc268d2d14 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 2 Feb 2026 15:02:45 -0800 Subject: [PATCH 47/83] Tweak usage base pricing copy --- web/src/app/pricing/pricing-client.tsx | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index d7f768f008..96b3068ce8 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -286,7 +286,7 @@ function CreditVisual() { {DEFAULT_FREE_CREDITS_GRANT} credits is typically enough for {' '} - a few hours of intense coding on a new project + a few hours of coding on a new project
) @@ -393,7 +393,7 @@ export default function PricingClient() { Simple, Usage-Based Pricing} + title={Usage-Based Pricing} description="Get 500 free credits monthly, then pay just 1¢ per credit. Credits are consumed based on task complexity — simple queries cost less, complex changes more. You'll see how many credits each task consumes." backdropColor={SECTION_THEMES.competition.background} decorativeColors={[BlockColor.GenerativeGreen, BlockColor.AcidMatrix]} @@ -405,22 +405,7 @@ export default function PricingClient() { learnMoreLink={status === 'authenticated' ? '/usage' : '/login'} /> - Working with others} - description="Collaborate with your team more closely using Codebuff by pooling credits and seeing usage analytics." - backdropColor={BlockColor.CRTAmber} - decorativeColors={[ - BlockColor.DarkForestGreen, - BlockColor.GenerativeGreen, - ]} - textColor="text-black" - tagline="SCALE UP YOUR TEAM" - highlightText="Pooled resources and usage analytics" - illustration={} - learnMoreText="Contact Sales" - learnMoreLink="mailto:founders@codebuff.com" - imagePosition="left" - /> + ) } From e090f0211f98e60b18c33378bad6da8d7a25730a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 3 Feb 2026 15:19:12 -0800 Subject: [PATCH 48/83] Tweak block limits --- common/src/constants/subscription-plans.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/common/src/constants/subscription-plans.ts b/common/src/constants/subscription-plans.ts index 23309e2f43..5f9e3ec8e3 100644 --- a/common/src/constants/subscription-plans.ts +++ b/common/src/constants/subscription-plans.ts @@ -10,21 +10,21 @@ export interface TierConfig { export const SUBSCRIPTION_TIERS = { 100: { monthlyPrice: 100, - creditsPerBlock: 400, + creditsPerBlock: 350, blockDurationHours: 5, - weeklyCreditsLimit: 4000, + weeklyCreditsLimit: 3500, }, 200: { monthlyPrice: 200, - creditsPerBlock: 1200, + creditsPerBlock: 1050, blockDurationHours: 5, - weeklyCreditsLimit: 12000, + weeklyCreditsLimit: 10500, }, 500: { monthlyPrice: 500, - creditsPerBlock: 3200, + creditsPerBlock: 2800, blockDurationHours: 5, - weeklyCreditsLimit: 32000, + weeklyCreditsLimit: 28000, }, } as const satisfies Record From 0c34f9bc7168684feea41c06b81685bb98b54b63 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 3 Feb 2026 15:28:25 -0800 Subject: [PATCH 49/83] Subscription success toast --- web/src/app/profile/page.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/web/src/app/profile/page.tsx b/web/src/app/profile/page.tsx index 72a8ff3227..16cc3ae382 100644 --- a/web/src/app/profile/page.tsx +++ b/web/src/app/profile/page.tsx @@ -1,7 +1,7 @@ 'use client' import { CreditCard, Shield, Users, Key, Menu } from 'lucide-react' -import { useSearchParams } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { useSession } from 'next-auth/react' import { useState, useEffect, Suspense } from 'react' @@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button' import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/utils' +import { toast } from '@/components/ui/use-toast' const sections = [ { @@ -82,6 +83,7 @@ function ProfileSidebar({ function ProfilePageContent() { const { status } = useSession() + const router = useRouter() const searchParams = useSearchParams() ?? new URLSearchParams() const [activeSection, setActiveSection] = useState('usage') const [open, setOpen] = useState(false) @@ -93,6 +95,19 @@ function ProfilePageContent() { } }, [searchParams]) + // Check for subscription success + useEffect(() => { + if (searchParams.get('subscription_success') === 'true') { + toast({ + title: 'Welcome to Codebuff Strong! 🎉', + description: + 'Thanks for subscribing! Your subscription is now active.', + }) + // Clean up the URL while preserving the tab + router.replace('/profile?tab=usage', { scroll: false }) + } + }, [searchParams, router]) + const ActiveComponent = sections.find((s) => s.id === activeSection)?.component || UsageSection const activeTitle = From afa08690bf4de9fadda4107568148c8882157e35 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 3 Feb 2026 15:35:11 -0800 Subject: [PATCH 50/83] cli: Include link to upgrade plan when you hit limit --- .../components/subscription-limit-banner.tsx | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/cli/src/components/subscription-limit-banner.tsx b/cli/src/components/subscription-limit-banner.tsx index 74590ebb08..66743a4eae 100644 --- a/cli/src/components/subscription-limit-banner.tsx +++ b/cli/src/components/subscription-limit-banner.tsx @@ -1,3 +1,4 @@ +import { SUBSCRIPTION_TIERS } from '@codebuff/common/constants/subscription-plans' import open from 'open' import React from 'react' @@ -32,6 +33,11 @@ export const SubscriptionLimitBanner = () => { const remainingBalance = usageData?.remainingBalance ?? 0 const hasAlaCarteCredits = remainingBalance > 0 + // Determine if user can upgrade (not on highest tier) + const maxTier = Math.max(...Object.keys(SUBSCRIPTION_TIERS).map(Number)) + const currentTier = subscriptionData?.hasSubscription ? subscriptionData.subscription.tier : 0 + const canUpgrade = currentTier < maxTier + const [alwaysALaCarte, setAlwaysALaCarteState] = React.useState( () => getAlwaysUseALaCarte(), ) @@ -61,6 +67,10 @@ export const SubscriptionLimitBanner = () => { open(WEBSITE_URL + '/usage') } + const handleUpgrade = () => { + open(WEBSITE_URL + '/pricing') + } + const handleWait = () => { setInputMode('default') } @@ -146,22 +156,33 @@ export const SubscriptionLimitBanner = () => { {' '}({remainingBalance.toLocaleString()} credits) - {isWeeklyLimit ? ( - ) : ( + + )} + {!isWeeklyLimit && - )} + } ) : ( <> No a-la-carte credits available. - + {canUpgrade ? ( + + ) : ( + + )} From 38f349f5723c7783480011c7f53d59221905d818 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 3 Feb 2026 20:14:03 -0800 Subject: [PATCH 51/83] Clean up subscription limit banner --- .../components/subscription-limit-banner.tsx | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/cli/src/components/subscription-limit-banner.tsx b/cli/src/components/subscription-limit-banner.tsx index 66743a4eae..fff43faa93 100644 --- a/cli/src/components/subscription-limit-banner.tsx +++ b/cli/src/components/subscription-limit-banner.tsx @@ -102,7 +102,7 @@ export const SubscriptionLimitBanner = () => { > {isWeeklyLimit ? ( <> - + 🛑 Weekly limit reached @@ -116,12 +116,12 @@ export const SubscriptionLimitBanner = () => { ) : isBlockExhausted ? ( <> - - ⏱️ 5 hour limit reached + + 5 hour limit reached {blockResetsAt && ( - New block starts in {formatResetTime(blockResetsAt)} + New session starts in {formatResetTime(blockResetsAt)} )} @@ -140,7 +140,7 @@ export const SubscriptionLimitBanner = () => { {hasAlaCarteCredits && ( )} @@ -150,26 +150,18 @@ export const SubscriptionLimitBanner = () => { <> {canUpgrade ? ( ) : ( )} - {!isWeeklyLimit && - - } ) : ( <> From 16bf7688d4d29512f76602c4a4d9bd0f37a27e35 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 3 Feb 2026 21:06:50 -0800 Subject: [PATCH 52/83] align usage progress bars --- cli/src/components/usage-banner.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index b08bab5b30..8833ef4543 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -248,8 +248,7 @@ const SubscriptionUsageSection: React.FC = ({ ) : rateLimit ? ( - 5-hour limit - {`${blockPercent}%`.padStart(4)} + {`5-hour limit ${`${blockPercent}%`.padStart(4)} `} {rateLimit.blockResetsAt @@ -258,11 +257,10 @@ const SubscriptionUsageSection: React.FC = ({ - Weekly limit - {`${weeklyPercent}%`.padStart(4)} + {`Weekly limit ${`${weeklyPercent}%`.padStart(4)} `} - {' '}resets in {formatResetTimeLong(rateLimit.weeklyResetsAt)} + {` resets in ${formatResetTimeLong(rateLimit.weeklyResetsAt)}`} From 94ec423364c1e04f0954dc0007f5884b7f0bd624 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 3 Feb 2026 22:16:37 -0800 Subject: [PATCH 53/83] tweak copy in pricing page --- web/src/app/pricing/pricing-client.tsx | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index 96b3068ce8..4d11d7743b 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -162,31 +162,21 @@ function StrongHeroSection() {
- codebuff + codebuff strong - - The strongest coding agent + Access the strongest coding agent - - - Deep thinking. Multi-agent orchestration. Ship faster. -
{/* Pricing cards grid */} @@ -218,7 +208,7 @@ function StrongHeroSection() { /mo
-

+

{USAGE_MULTIPLIER[price]} usage

From 2053bb5f01b6861c1e68a4b084fbf9c48f696ead Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 3 Feb 2026 22:46:34 -0800 Subject: [PATCH 54/83] Update pricing page styles again --- web/src/app/pricing/pricing-client.tsx | 150 ++++++++++++++----------- 1 file changed, 82 insertions(+), 68 deletions(-) diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index 4d11d7743b..e85039523d 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -15,6 +15,7 @@ import { useSession } from 'next-auth/react' import { useState } from 'react' import { BlockColor } from '@/components/ui/decorative-blocks' +import { Section } from '@/components/ui/section' import { SECTION_THEMES } from '@/components/ui/landing/constants' import { FeatureSection } from '@/components/ui/landing/feature' import { toast } from '@/components/ui/use-toast' @@ -93,7 +94,12 @@ function SubscribeButton({ function StrongHeroSection() { return ( -
+
{/* Subtle radial glow behind content */}
{/* Foreground content */} -
-
- - codebuff strong - - +
+ Access the strongest coding agent -
- {/* 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 -

- - + + Subscribe for higher usage limits + + + {/* Pricing cards grid with decorative blocks */} + +
+ {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 · Tax not included · Usage amounts subject to change - + + + + Cancel anytime · Applicable taxes not shown · Usage subject to change + +
-
+
) } @@ -382,6 +393,9 @@ export default function PricingClient() { <> + {/* Visual divider between hero and feature section */} +
+ Usage-Based Pricing} description="Get 500 free credits monthly, then pay just 1¢ per credit. Credits are consumed based on task complexity — simple queries cost less, complex changes more. You'll see how many credits each task consumes." From 4064c46d27fac474b58d44d9dcea58a8de68da2c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 4 Feb 2026 11:47:38 -0800 Subject: [PATCH 55/83] fix tests --- .../src/__tests__/subscription.test.ts | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/billing/src/__tests__/subscription.test.ts b/packages/billing/src/__tests__/subscription.test.ts index b563eaf943..1c5a75fbbd 100644 --- a/packages/billing/src/__tests__/subscription.test.ts +++ b/packages/billing/src/__tests__/subscription.test.ts @@ -412,11 +412,11 @@ describe('subscription', () => { const subscription = createMockSubscription() it('should report weekly_limit when usage reaches limit', async () => { - // tier 200 → weeklyCreditsLimit: 12000 + const weeklyLimit = SUBSCRIPTION_TIERS[200].weeklyCreditsLimit const { conn } = createSequentialMock({ selectResults: [ - [], // no limit overrides - [{ total: 12000 }], // weekly usage at limit + [], // no limit overrides + [{ total: weeklyLimit }], // weekly usage at limit ], }) @@ -430,8 +430,8 @@ describe('subscription', () => { 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) + expect(result.weeklyUsed).toBe(weeklyLimit) + expect(result.weeklyLimit).toBe(weeklyLimit) }) it('should allow new block when no active block exists', async () => { @@ -528,12 +528,12 @@ describe('subscription', () => { }) it('should return weekly limit error when limit is reached', async () => { - // tier 200 → weeklyCreditsLimit: 12000 + const weeklyLimit = SUBSCRIPTION_TIERS[200].weeklyCreditsLimit const { conn } = createSequentialMock({ selectResults: [ - [], // no existing grants - [], // no limit overrides - [{ total: 12000 }], // weekly limit reached + [], // no existing grants + [], // no limit overrides + [{ total: weeklyLimit }], // weekly limit reached ], }) @@ -547,8 +547,8 @@ describe('subscription', () => { 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) + expect(error.used).toBe(weeklyLimit) + expect(error.limit).toBe(weeklyLimit) }) it('should create new block grant when none exists', async () => { @@ -583,14 +583,15 @@ describe('subscription', () => { }) 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 weeklyLimit = SUBSCRIPTION_TIERS[200].weeklyCreditsLimit + const expectedRemaining = 500 + const weeklyUsed = weeklyLimit - expectedRemaining const now = new Date('2025-01-15T10:00:00Z') const { conn, captures } = createSequentialMock({ selectResults: [ - [], // no existing grants - [], // no limit overrides - [{ total: 11500 }], // 500 remaining + [], // no existing grants + [], // no limit overrides + [{ total: weeklyUsed }], // expectedRemaining credits remaining ], insertResults: [ [{ operation_id: 'capped-block' }], @@ -607,9 +608,9 @@ describe('subscription', () => { 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) + expect(grant.credits).toBe(expectedRemaining) + expect(captures.insertValues[0].principal).toBe(expectedRemaining) + expect(captures.insertValues[0].balance).toBe(expectedRemaining) }) it('should throw when insert returns no grant (duplicate operation)', async () => { From 1c6f34660fe83fa24c40443c631079bb397dd5f2 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 4 Feb 2026 14:25:21 -0800 Subject: [PATCH 56/83] Enable invoice creation and tax id collection in stripe checkout --- web/src/app/api/stripe/buy-credits/route.ts | 3 +++ web/src/app/api/stripe/create-subscription/route.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/web/src/app/api/stripe/buy-credits/route.ts b/web/src/app/api/stripe/buy-credits/route.ts index def0eb0fcd..585d2b48bc 100644 --- a/web/src/app/api/stripe/buy-credits/route.ts +++ b/web/src/app/api/stripe/buy-credits/route.ts @@ -185,6 +185,9 @@ export async function POST(req: NextRequest) { }, ], mode: 'payment', + invoice_creation: { enabled: true }, + tax_id_collection: { enabled: true }, // optional (EU B2B) + customer_update: { name: "auto" }, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/payment-success?session_id={CHECKOUT_SESSION_ID}&purchase=credits&amt=${credits}`, cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage?purchase_canceled=true`, metadata: { diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts index 3ae329c5d5..aae72f5872 100644 --- a/web/src/app/api/stripe/create-subscription/route.ts +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -72,6 +72,9 @@ export async function POST(req: NextRequest) { const checkoutSession = await stripeServer.checkout.sessions.create({ customer: user.stripe_customer_id, mode: 'subscription', + invoice_creation: { enabled: true }, + tax_id_collection: { enabled: true }, // optional (EU B2B) + customer_update: { name: "auto" }, line_items: [{ price: priceId, quantity: 1 }], allow_promotion_codes: true, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile?tab=usage&subscription_success=true`, From 8677629976523f2ab98885c0ef6b8c2d0d70d3d0 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 4 Feb 2026 14:28:01 -0800 Subject: [PATCH 57/83] Revert "Enable invoice creation and tax id collection in stripe checkout" This reverts commit 1c6f34660fe83fa24c40443c631079bb397dd5f2. --- web/src/app/api/stripe/buy-credits/route.ts | 3 --- web/src/app/api/stripe/create-subscription/route.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/web/src/app/api/stripe/buy-credits/route.ts b/web/src/app/api/stripe/buy-credits/route.ts index 585d2b48bc..def0eb0fcd 100644 --- a/web/src/app/api/stripe/buy-credits/route.ts +++ b/web/src/app/api/stripe/buy-credits/route.ts @@ -185,9 +185,6 @@ export async function POST(req: NextRequest) { }, ], mode: 'payment', - invoice_creation: { enabled: true }, - tax_id_collection: { enabled: true }, // optional (EU B2B) - customer_update: { name: "auto" }, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/payment-success?session_id={CHECKOUT_SESSION_ID}&purchase=credits&amt=${credits}`, cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage?purchase_canceled=true`, metadata: { diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts index aae72f5872..3ae329c5d5 100644 --- a/web/src/app/api/stripe/create-subscription/route.ts +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -72,9 +72,6 @@ export async function POST(req: NextRequest) { const checkoutSession = await stripeServer.checkout.sessions.create({ customer: user.stripe_customer_id, mode: 'subscription', - invoice_creation: { enabled: true }, - tax_id_collection: { enabled: true }, // optional (EU B2B) - customer_update: { name: "auto" }, line_items: [{ price: priceId, quantity: 1 }], allow_promotion_codes: true, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile?tab=usage&subscription_success=true`, From b971584679d7dd8b9c6cfcd6ac3c6d74956731f5 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 13:49:16 -0800 Subject: [PATCH 58/83] fix(db): split referral_legacy migration to handle PostgreSQL enum limitation PostgreSQL ADD VALUE for enums is not visible within the same transaction, so the UPDATE statements need to run in a separate migration after the enum value is committed. - 0039: Add referral_legacy enum value + is_legacy column (DEFAULT true) - 0040: Backfill credit_ledger with referral_legacy type --- .../db/migrations/0039_quiet_franklin_storm.sql | 16 +--------------- .../migrations/0040_referral_legacy_backfill.sql | 12 ++++++++++++ .../src/db/migrations/meta/_journal.json | 7 +++++++ 3 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 packages/internal/src/db/migrations/0040_referral_legacy_backfill.sql diff --git a/packages/internal/src/db/migrations/0039_quiet_franklin_storm.sql b/packages/internal/src/db/migrations/0039_quiet_franklin_storm.sql index 437d4cc0fd..cf74f063ca 100644 --- a/packages/internal/src/db/migrations/0039_quiet_franklin_storm.sql +++ b/packages/internal/src/db/migrations/0039_quiet_franklin_storm.sql @@ -1,16 +1,2 @@ ALTER TYPE "public"."grant_type" ADD VALUE 'referral_legacy' BEFORE 'purchase';--> statement-breakpoint -ALTER TABLE "referral" ADD COLUMN "is_legacy" boolean DEFAULT false NOT NULL;--> statement-breakpoint --- Backfill: Mark all existing referrals as legacy (they were created under the old recurring program) -UPDATE "referral" SET "is_legacy" = true;--> statement-breakpoint --- Migrate existing referral grants that have an expiry date to referral_legacy type --- (These are the recurring grants from the old program) -UPDATE "credit_ledger" -SET "type" = 'referral_legacy', - "priority" = 30 -WHERE "type" = 'referral' - AND "expires_at" IS NOT NULL;--> statement-breakpoint --- Update priority for remaining referral grants (one-time grants, if any exist) to new priority -UPDATE "credit_ledger" -SET "priority" = 50 -WHERE "type" = 'referral' - AND "expires_at" IS NULL; \ No newline at end of file +ALTER TABLE "referral" ADD COLUMN "is_legacy" boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/packages/internal/src/db/migrations/0040_referral_legacy_backfill.sql b/packages/internal/src/db/migrations/0040_referral_legacy_backfill.sql new file mode 100644 index 0000000000..2df6eb6cc0 --- /dev/null +++ b/packages/internal/src/db/migrations/0040_referral_legacy_backfill.sql @@ -0,0 +1,12 @@ +-- Migrate existing referral grants that have an expiry date to referral_legacy type +-- (These are the recurring grants from the old program) +UPDATE "credit_ledger" +SET "type" = 'referral_legacy', + "priority" = 30 +WHERE "type" = 'referral' + AND "expires_at" IS NOT NULL;--> statement-breakpoint +-- Update priority for remaining referral grants (one-time grants, if any exist) to new priority +UPDATE "credit_ledger" +SET "priority" = 50 +WHERE "type" = 'referral' + AND "expires_at" IS NULL; diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index 8d6ca418d3..26f20f1a64 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1769482939158, "tag": "0039_quiet_franklin_storm", "breakpoints": true + }, + { + "idx": 40, + "version": "7", + "when": 1769650000000, + "tag": "0040_referral_legacy_backfill", + "breakpoints": true } ] } \ No newline at end of file From 9c027aa62a55ce78543bfded5921a24ee7f276ee Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 13:56:17 -0800 Subject: [PATCH 59/83] refactor(db): Switch from drizzle-kit push to migrate for safer production deployments --- packages/internal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/internal/package.json b/packages/internal/package.json index 024f9103a5..daa8b7178f 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -47,7 +47,7 @@ "typecheck": "tsc --noEmit -p .", "test": "bun test", "db:generate": "drizzle-kit generate --config=./src/db/drizzle.config.ts", - "db:migrate": "drizzle-kit push --config=./src/db/drizzle.config.ts", + "db:migrate": "drizzle-kit migrate --config=./src/db/drizzle.config.ts", "db:start": "docker compose -f ./src/db/docker-compose.yml up --wait && bun run db:generate && (timeout 1 || sleep 1) && bun run db:migrate", "db:e2e:setup": "bun ./src/db/e2e-setup.ts", "db:e2e:down": "docker compose -f ./src/db/docker-compose.e2e.yml down --volumes", From 3c54a965a75d35ec7d380e1c6e4cc286da1b416d Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 14:09:34 -0800 Subject: [PATCH 60/83] fix(db): Remove backfill migration to fix PostgreSQL enum transaction issue drizzle-kit migrate runs all pending migrations in a single transaction, so the new enum value is not committed when the UPDATE tries to use it. Moved backfill to standalone script: scripts/backfill-referral-legacy.sql Run this manually after migration 0039 is deployed. --- .../0040_referral_legacy_backfill.sql | 12 -------- .../src/db/migrations/meta/_journal.json | 7 ----- scripts/backfill-referral-legacy.sql | 28 +++++++++++++++++++ 3 files changed, 28 insertions(+), 19 deletions(-) delete mode 100644 packages/internal/src/db/migrations/0040_referral_legacy_backfill.sql create mode 100644 scripts/backfill-referral-legacy.sql diff --git a/packages/internal/src/db/migrations/0040_referral_legacy_backfill.sql b/packages/internal/src/db/migrations/0040_referral_legacy_backfill.sql deleted file mode 100644 index 2df6eb6cc0..0000000000 --- a/packages/internal/src/db/migrations/0040_referral_legacy_backfill.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Migrate existing referral grants that have an expiry date to referral_legacy type --- (These are the recurring grants from the old program) -UPDATE "credit_ledger" -SET "type" = 'referral_legacy', - "priority" = 30 -WHERE "type" = 'referral' - AND "expires_at" IS NOT NULL;--> statement-breakpoint --- Update priority for remaining referral grants (one-time grants, if any exist) to new priority -UPDATE "credit_ledger" -SET "priority" = 50 -WHERE "type" = 'referral' - AND "expires_at" IS NULL; diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index 26f20f1a64..90c1a997e1 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -282,12 +282,5 @@ "tag": "0039_quiet_franklin_storm", "breakpoints": true }, - { - "idx": 40, - "version": "7", - "when": 1769650000000, - "tag": "0040_referral_legacy_backfill", - "breakpoints": true - } ] } \ No newline at end of file diff --git a/scripts/backfill-referral-legacy.sql b/scripts/backfill-referral-legacy.sql new file mode 100644 index 0000000000..726828a95a --- /dev/null +++ b/scripts/backfill-referral-legacy.sql @@ -0,0 +1,28 @@ +-- Backfill script for referral_legacy grants +-- Run this AFTER migration 0039_quiet_franklin_storm.sql has been deployed and committed +-- +-- This script cannot be part of Drizzle migrations because PostgreSQL requires +-- new enum values to be committed before they can be used in subsequent statements. +-- +-- Usage: Connect to your database and run this script manually after deployment +-- psql $DATABASE_URL -f scripts/backfill-referral-legacy.sql + +-- Migrate existing referral grants that have an expiry date to referral_legacy type +-- (These are the recurring grants from the old referral program) +UPDATE "credit_ledger" +SET "type" = 'referral_legacy', + "priority" = 30 +WHERE "type" = 'referral' + AND "expires_at" IS NOT NULL; + +-- Update priority for remaining referral grants (one-time grants) to new priority +UPDATE "credit_ledger" +SET "priority" = 50 +WHERE "type" = 'referral' + AND "expires_at" IS NULL; + +-- Verify the changes +SELECT "type", COUNT(*), MIN("priority"), MAX("priority") +FROM "credit_ledger" +WHERE "type" IN ('referral', 'referral_legacy') +GROUP BY "type"; From d83f3650d74e57216d4852d5286337b80b0dfd78 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 14:10:18 -0800 Subject: [PATCH 61/83] chore: Remove backfill script (already applied manually) --- scripts/backfill-referral-legacy.sql | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 scripts/backfill-referral-legacy.sql diff --git a/scripts/backfill-referral-legacy.sql b/scripts/backfill-referral-legacy.sql deleted file mode 100644 index 726828a95a..0000000000 --- a/scripts/backfill-referral-legacy.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Backfill script for referral_legacy grants --- Run this AFTER migration 0039_quiet_franklin_storm.sql has been deployed and committed --- --- This script cannot be part of Drizzle migrations because PostgreSQL requires --- new enum values to be committed before they can be used in subsequent statements. --- --- Usage: Connect to your database and run this script manually after deployment --- psql $DATABASE_URL -f scripts/backfill-referral-legacy.sql - --- Migrate existing referral grants that have an expiry date to referral_legacy type --- (These are the recurring grants from the old referral program) -UPDATE "credit_ledger" -SET "type" = 'referral_legacy', - "priority" = 30 -WHERE "type" = 'referral' - AND "expires_at" IS NOT NULL; - --- Update priority for remaining referral grants (one-time grants) to new priority -UPDATE "credit_ledger" -SET "priority" = 50 -WHERE "type" = 'referral' - AND "expires_at" IS NULL; - --- Verify the changes -SELECT "type", COUNT(*), MIN("priority"), MAX("priority") -FROM "credit_ledger" -WHERE "type" IN ('referral', 'referral_legacy') -GROUP BY "type"; From ca4ea4b81a740ee4e6aa9ff29aecf2036adb98a9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 4 Feb 2026 14:33:24 -0800 Subject: [PATCH 62/83] Enable invoice creation and tax id collection in stripe checkout --- web/src/app/api/stripe/buy-credits/route.ts | 3 +++ web/src/app/api/stripe/create-subscription/route.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/web/src/app/api/stripe/buy-credits/route.ts b/web/src/app/api/stripe/buy-credits/route.ts index def0eb0fcd..28374e86d3 100644 --- a/web/src/app/api/stripe/buy-credits/route.ts +++ b/web/src/app/api/stripe/buy-credits/route.ts @@ -185,6 +185,9 @@ export async function POST(req: NextRequest) { }, ], mode: 'payment', + invoice_creation: { enabled: true }, + tax_id_collection: { enabled: true }, // optional (EU B2B) + customer_update: { name: "auto", address: "auto" }, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/payment-success?session_id={CHECKOUT_SESSION_ID}&purchase=credits&amt=${credits}`, cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage?purchase_canceled=true`, metadata: { diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts index 3ae329c5d5..e16b60658f 100644 --- a/web/src/app/api/stripe/create-subscription/route.ts +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -72,6 +72,9 @@ export async function POST(req: NextRequest) { const checkoutSession = await stripeServer.checkout.sessions.create({ customer: user.stripe_customer_id, mode: 'subscription', + invoice_creation: { enabled: true }, + tax_id_collection: { enabled: true }, // optional (EU B2B) + customer_update: { name: "auto", address: "auto" }, line_items: [{ price: priceId, quantity: 1 }], allow_promotion_codes: true, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile?tab=usage&subscription_success=true`, From 18bb92fb44be42f1896ab0e4b0a910602c4a8c0c Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 15:54:22 -0800 Subject: [PATCH 63/83] fix(db): Remove trailing comma in migration journal JSON The trailing comma after the last entry caused drizzle-kit migrate to fail with a JSON parse error in CI. --- packages/internal/src/db/migrations/meta/_journal.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index 90c1a997e1..8d6ca418d3 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -281,6 +281,6 @@ "when": 1769482939158, "tag": "0039_quiet_franklin_storm", "breakpoints": true - }, + } ] } \ No newline at end of file From 1f8ae7431035cc64f8d4df52f94dedcd26fbbc3c Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 16:38:49 -0800 Subject: [PATCH 64/83] Revert "refactor(db): Switch from drizzle-kit push to migrate for safer production deployments" This reverts commit d6d19fa13007f79828b4342e804b52702aed9084. --- packages/internal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/internal/package.json b/packages/internal/package.json index daa8b7178f..024f9103a5 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -47,7 +47,7 @@ "typecheck": "tsc --noEmit -p .", "test": "bun test", "db:generate": "drizzle-kit generate --config=./src/db/drizzle.config.ts", - "db:migrate": "drizzle-kit migrate --config=./src/db/drizzle.config.ts", + "db:migrate": "drizzle-kit push --config=./src/db/drizzle.config.ts", "db:start": "docker compose -f ./src/db/docker-compose.yml up --wait && bun run db:generate && (timeout 1 || sleep 1) && bun run db:migrate", "db:e2e:setup": "bun ./src/db/e2e-setup.ts", "db:e2e:down": "docker compose -f ./src/db/docker-compose.e2e.yml down --volumes", From ce513ea09a2e6fcc49ef0579b914b24f356c566c Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 17:28:31 -0800 Subject: [PATCH 65/83] feat: Add fallbackToALaCarte server-side preference --- .../components/subscription-limit-banner.tsx | 20 +- cli/src/components/usage-banner.tsx | 21 +- cli/src/hooks/use-update-preference.ts | 61 + cli/src/utils/settings.ts | 16 +- common/src/types/subscription.ts | 4 + .../db/migrations/0040_empty_phil_sheldon.sql | 1 + .../src/db/migrations/meta/0040_snapshot.json | 3078 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 11 +- packages/internal/src/db/schema.ts | 1 + web/src/app/api/user/preferences/route.ts | 90 + web/src/app/api/user/subscription/route.ts | 18 +- .../components/subscription-section.tsx | 148 +- .../credits/CreditManagementSection.tsx | 16 +- 13 files changed, 3390 insertions(+), 95 deletions(-) create mode 100644 cli/src/hooks/use-update-preference.ts create mode 100644 packages/internal/src/db/migrations/0040_empty_phil_sheldon.sql create mode 100644 packages/internal/src/db/migrations/meta/0040_snapshot.json create mode 100644 web/src/app/api/user/preferences/route.ts diff --git a/cli/src/components/subscription-limit-banner.tsx b/cli/src/components/subscription-limit-banner.tsx index fff43faa93..d219f59257 100644 --- a/cli/src/components/subscription-limit-banner.tsx +++ b/cli/src/components/subscription-limit-banner.tsx @@ -6,13 +6,10 @@ import { Button } from './button' import { ProgressBar } from './progress-bar' import { useSubscriptionQuery } from '../hooks/use-subscription-query' import { useTheme } from '../hooks/use-theme' +import { useUpdatePreference } from '../hooks/use-update-preference' import { useUsageQuery } from '../hooks/use-usage-query' import { WEBSITE_URL } from '../login/constants' import { useChatStore } from '../state/chat-store' -import { - getAlwaysUseALaCarte, - setAlwaysUseALaCarte, -} from '../utils/settings' import { formatResetTime } from '../utils/time-format' import { BORDER_CHARS } from '../utils/ui-constants' @@ -38,14 +35,11 @@ export const SubscriptionLimitBanner = () => { const currentTier = subscriptionData?.hasSubscription ? subscriptionData.subscription.tier : 0 const canUpgrade = currentTier < maxTier - const [alwaysALaCarte, setAlwaysALaCarteState] = React.useState( - () => getAlwaysUseALaCarte(), - ) + const fallbackToALaCarte = subscriptionData?.fallbackToALaCarte ?? false + const updatePreference = useUpdatePreference() - const handleToggleAlwaysALaCarte = () => { - const newValue = !alwaysALaCarte - setAlwaysALaCarteState(newValue) - setAlwaysUseALaCarte(newValue) + const handleToggleFallbackToALaCarte = () => { + updatePreference.mutate({ fallbackToALaCarte: !fallbackToALaCarte }) } if (!subscriptionData || !rateLimit?.limited) { @@ -138,9 +132,9 @@ export const SubscriptionLimitBanner = () => { {hasAlaCarteCredits && ( - )} diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 8833ef4543..97da8334b1 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -1,7 +1,7 @@ import { isClaudeOAuthValid } from '@codebuff/sdk' import { TextAttributes } from '@opentui/core' import open from 'open' -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo } from 'react' import { BottomBanner } from './bottom-banner' import { Button } from './button' @@ -10,9 +10,9 @@ import { getActivityQueryData } from '../hooks/use-activity-query' import { useClaudeQuotaQuery } from '../hooks/use-claude-quota-query' import { useSubscriptionQuery } from '../hooks/use-subscription-query' import { useTheme } from '../hooks/use-theme' +import { useUpdatePreference } from '../hooks/use-update-preference' import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' import { WEBSITE_URL } from '../login/constants' -import { getAlwaysUseALaCarte, setAlwaysUseALaCarte } from '../utils/settings' import { useChatStore } from '../state/chat-store' import { formatResetTime, formatResetTimeLong } from '../utils/time-format' import { @@ -122,6 +122,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { subscriptionInfo={subscriptionInfo} rateLimit={rateLimit} isLoading={isSubscriptionLoading} + fallbackToALaCarte={activeSubscription.fallbackToALaCarte} /> )} @@ -209,6 +210,7 @@ interface SubscriptionUsageSectionProps { weeklyResetsAt: string } isLoading: boolean + fallbackToALaCarte: boolean } const SubscriptionUsageSection: React.FC = ({ @@ -216,14 +218,13 @@ const SubscriptionUsageSection: React.FC = ({ subscriptionInfo, rateLimit, isLoading, + fallbackToALaCarte, }) => { const theme = useTheme() - const [useALaCarte, setUseALaCarte] = useState(() => getAlwaysUseALaCarte()) + const updatePreference = useUpdatePreference() - const handleToggleALaCarte = () => { - const newValue = !useALaCarte - setUseALaCarte(newValue) - setAlwaysUseALaCarte(newValue) + const handleToggleFallbackToALaCarte = () => { + updatePreference.mutate({ fallbackToALaCarte: !fallbackToALaCarte }) } const blockPercent = useMemo(() => { @@ -268,11 +269,11 @@ const SubscriptionUsageSection: React.FC = ({ When limit reached: - {useALaCarte ? 'spend credits' : 'pause'} + {fallbackToALaCarte ? 'spend credits' : 'pause'} - diff --git a/cli/src/hooks/use-update-preference.ts b/cli/src/hooks/use-update-preference.ts new file mode 100644 index 0000000000..e9fe18bf61 --- /dev/null +++ b/cli/src/hooks/use-update-preference.ts @@ -0,0 +1,61 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { subscriptionQueryKeys } from './use-subscription-query' +import { getApiClient } from '../utils/codebuff-api' +import { logger } from '../utils/logger' + +import type { SubscriptionResponse } from '@codebuff/common/types/subscription' + +interface UpdatePreferenceParams { + fallbackToALaCarte?: boolean +} + +export function useUpdatePreference() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (params: UpdatePreferenceParams) => { + const client = getApiClient() + const response = await client.patch('/api/user/preferences', { + body: params, + includeCookie: true, + }) + + if (!response.ok) { + throw new Error('Failed to update preference') + } + + return params + }, + onMutate: async (newParams) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: subscriptionQueryKeys.current() }) + + // Snapshot the previous value + const previousData = queryClient.getQueryData( + subscriptionQueryKeys.current() + ) + + // Optimistically update to the new value + if (previousData && newParams.fallbackToALaCarte !== undefined) { + queryClient.setQueryData( + subscriptionQueryKeys.current(), + { ...previousData, fallbackToALaCarte: newParams.fallbackToALaCarte } + ) + } + + return { previousData } + }, + onError: (err, _newParams, context) => { + // Rollback to previous value on error + if (context?.previousData) { + queryClient.setQueryData(subscriptionQueryKeys.current(), context.previousData) + } + logger.error({ err }, 'Failed to update preference') + }, + onSettled: () => { + // Refetch after mutation + queryClient.invalidateQueries({ queryKey: subscriptionQueryKeys.current() }) + }, + }) +} diff --git a/cli/src/utils/settings.ts b/cli/src/utils/settings.ts index e6ea66c9c0..b81e000ad3 100644 --- a/cli/src/utils/settings.ts +++ b/cli/src/utils/settings.ts @@ -20,7 +20,10 @@ const DEFAULT_SETTINGS: Settings = { export interface Settings { mode?: AgentMode adsEnabled?: boolean + /** @deprecated Use server-side fallbackToALaCarte setting instead */ alwaysUseALaCarte?: boolean + /** @deprecated Use server-side fallbackToALaCarte setting instead */ + fallbackToALaCarte?: boolean } /** @@ -93,11 +96,16 @@ const validateSettings = (parsed: unknown): Settings => { settings.adsEnabled = obj.adsEnabled } - // Validate alwaysUseALaCarte + // Validate alwaysUseALaCarte (legacy) if (typeof obj.alwaysUseALaCarte === 'boolean') { settings.alwaysUseALaCarte = obj.alwaysUseALaCarte } + // Validate fallbackToALaCarte (legacy) + if (typeof obj.fallbackToALaCarte === 'boolean') { + settings.fallbackToALaCarte = obj.fallbackToALaCarte + } + return settings } @@ -143,15 +151,17 @@ export const saveModePreference = (mode: AgentMode): void => { /** * Load the "always use a-la-carte" preference + * @deprecated Use server-side fallbackToALaCarte setting via useSubscriptionQuery instead */ export const getAlwaysUseALaCarte = (): boolean => { const settings = loadSettings() - return settings.alwaysUseALaCarte ?? false + return settings.fallbackToALaCarte ?? settings.alwaysUseALaCarte ?? false } /** * Save the "always use a-la-carte" preference + * @deprecated Use server-side fallbackToALaCarte setting via useUpdatePreference instead */ export const setAlwaysUseALaCarte = (value: boolean): void => { - saveSettings({ alwaysUseALaCarte: value }) + saveSettings({ fallbackToALaCarte: value }) } diff --git a/common/src/types/subscription.ts b/common/src/types/subscription.ts index 498e25c395..d31ac7c5da 100644 --- a/common/src/types/subscription.ts +++ b/common/src/types/subscription.ts @@ -40,6 +40,8 @@ export interface SubscriptionLimits { */ export interface NoSubscriptionResponse { hasSubscription: false + /** Whether user prefers to fallback to a-la-carte credits when subscription limits are reached */ + fallbackToALaCarte: boolean } /** @@ -53,6 +55,8 @@ export interface ActiveSubscriptionResponse { rateLimit: SubscriptionRateLimit limits: SubscriptionLimits billingPortalUrl?: string + /** Whether user prefers to fallback to a-la-carte credits when subscription limits are reached */ + fallbackToALaCarte: boolean } /** diff --git a/packages/internal/src/db/migrations/0040_empty_phil_sheldon.sql b/packages/internal/src/db/migrations/0040_empty_phil_sheldon.sql new file mode 100644 index 0000000000..66111f5a06 --- /dev/null +++ b/packages/internal/src/db/migrations/0040_empty_phil_sheldon.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "fallback_to_a_la_carte" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0040_snapshot.json b/packages/internal/src/db/migrations/meta/0040_snapshot.json new file mode 100644 index 0000000000..74a942dbfa --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0040_snapshot.json @@ -0,0 +1,3078 @@ +{ + "id": "20f36987-146d-4bca-ab34-2f0201235556", + "prevId": "c08ced84-4b3d-4bd3-8934-aa9531d889ca", + "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 + }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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 + }, + "fallback_to_a_la_carte": { + "name": "fallback_to_a_la_carte", + "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", + "referral_legacy", + "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 8d6ca418d3..7fd42149fd 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -278,8 +278,15 @@ { "idx": 39, "version": "7", - "when": 1769482939158, - "tag": "0039_quiet_franklin_storm", + "when": 1770252529987, + "tag": "0039_bumpy_vertigo", + "breakpoints": true + }, + { + "idx": 40, + "version": "7", + "when": 1770252805234, + "tag": "0040_empty_phil_sheldon", "breakpoints": true } ] diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 3d3f9e024b..694437f003 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -88,6 +88,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), + fallback_to_a_la_carte: boolean('fallback_to_a_la_carte').notNull().default(false), }) export const account = pgTable( diff --git a/web/src/app/api/user/preferences/route.ts b/web/src/app/api/user/preferences/route.ts new file mode 100644 index 0000000000..f9a3eab5b5 --- /dev/null +++ b/web/src/app/api/user/preferences/route.ts @@ -0,0 +1,90 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { z } from 'zod' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +const updatePreferencesSchema = z.object({ + fallbackToALaCarte: z.boolean().optional(), +}) + +export async function PATCH(request: Request) { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const parsed = updatePreferencesSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.flatten() }, + { status: 400 }, + ) + } + + const { fallbackToALaCarte } = parsed.data + + // Build the update object with only provided fields + const updates: Partial<{ fallback_to_a_la_carte: boolean }> = {} + + if (fallbackToALaCarte !== undefined) { + updates.fallback_to_a_la_carte = fallbackToALaCarte + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: 'No updates provided' }, { status: 400 }) + } + + try { + await db + .update(schema.user) + .set(updates) + .where(eq(schema.user.id, userId)) +the + logger.info({ userId, updates }, 'User preferences updated') + + return NextResponse.json({ success: true, ...parsed.data }) + } catch (error) { + logger.error({ error, userId }, 'Error updating user preferences') + return NextResponse.json( + { error: 'Failed to update preferences' }, + { status: 500 }, + ) + } +} + +export async function GET() { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const user = await db.query.user.findFirst({ + where: eq(schema.user.id, session.user.id), + columns: { fallback_to_a_la_carte: true }, + }) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + return NextResponse.json({ + fallbackToALaCarte: user.fallback_to_a_la_carte, + }) +} diff --git a/web/src/app/api/user/subscription/route.ts b/web/src/app/api/user/subscription/route.ts index 63844f7f2a..b36ec564f7 100644 --- a/web/src/app/api/user/subscription/route.ts +++ b/web/src/app/api/user/subscription/route.ts @@ -4,8 +4,11 @@ import { getSubscriptionLimits, } from '@codebuff/billing' import { SUBSCRIPTION_DISPLAY_NAME } 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' @@ -24,10 +27,20 @@ export async function GET() { } const userId = session.user.id - const subscription = await getActiveSubscription({ userId, logger }) + + // Fetch user preference for always use a-la-carte + const [subscription, userPrefs] = await Promise.all([ + getActiveSubscription({ userId, logger }), + db.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { fallback_to_a_la_carte: true }, + }), + ]) + + const fallbackToALaCarte = userPrefs?.fallback_to_a_la_carte ?? false if (!subscription || !subscription.tier) { - const response: NoSubscriptionResponse = { hasSubscription: false } + const response: NoSubscriptionResponse = { hasSubscription: false, fallbackToALaCarte } return NextResponse.json(response) } @@ -75,6 +88,7 @@ export async function GET() { }, limits, billingPortalUrl, + fallbackToALaCarte, } return NextResponse.json(response) } diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index 4218650684..cd1cb5bfe9 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -2,7 +2,7 @@ import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' import { env } from '@codebuff/common/env' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AlertTriangle, ExternalLink, @@ -13,6 +13,9 @@ import { useSession } from 'next-auth/react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { toast } from '@/components/ui/use-toast' import { cn } from '@/lib/utils' import { formatTimeUntil } from '@codebuff/common/util/dates' @@ -25,6 +28,8 @@ import type { const formatDaysHours = (dateStr: string): string => formatTimeUntil(dateStr, { fallback: '0h' }) +const clampPercent = (n: number): number => Math.min(100, Math.max(0, Math.round(n))) + function ProgressBar({ percentAvailable, label }: { percentAvailable: number; label: string }) { const percent = Math.min(100, Math.max(0, Math.round(percentAvailable))) const colorClass = percent <= 0 ? 'bg-red-500' : percent <= 25 ? 'bg-yellow-500' : 'bg-green-500' @@ -34,8 +39,9 @@ function ProgressBar({ percentAvailable, label }: { percentAvailable: number; la aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100} + aria-valuetext={`${percent}% remaining`} aria-label={label} - className="h-2.5 w-full rounded-full bg-muted overflow-hidden" + className="h-3 w-full rounded-full bg-muted overflow-hidden" >
{ + const res = await fetch('/api/user/preferences', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fallbackToALaCarte: newValue }), + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Failed to update preference' })) + throw new Error(error.error || 'Failed to update preference') + } + return newValue + }, + onSuccess: (newValue) => { + queryClient.setQueryData(['subscription'], (old: SubscriptionResponse | undefined) => + old ? { ...old, fallbackToALaCarte: newValue } : old + ) + }, + onError: (error: Error) => { + toast({ + title: 'Error', + description: error.message, + variant: 'destructive', + }) + }, + }) + + const blockRemainingPercent = + rateLimit.blockLimit != null && rateLimit.blockUsed != null && rateLimit.blockLimit > 0 + ? clampPercent(100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100) + : 100 + const weeklyRemainingPercent = clampPercent(100 - rateLimit.weeklyPercentUsed) return ( - +
@@ -84,60 +124,60 @@ function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse;
-
-
- - 5-hour limit - {rateLimit.blockResetsAt && ( - - resets in {formatDaysHours(rateLimit.blockResetsAt)} - - )} - - - {rateLimit.blockLimit && rateLimit.blockUsed != null - ? `${Math.round(100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100)}%` - : '100%'} remaining - -
- -
- -
-
- - Weekly limit - - resets in {formatDaysHours(rateLimit.weeklyResetsAt)} - - - - {100 - rateLimit.weeklyPercentUsed}% remaining - -
- -
- {rateLimit.limited && (

{rateLimit.reason === 'weekly_limit' - ? `Weekly limit reached. Resets in ${formatDaysHours(rateLimit.weeklyResetsAt)}. You can still use a-la-carte credits.` - : `Session exhausted. New session in ${rateLimit.blockResetsAt ? formatDaysHours(rateLimit.blockResetsAt) : 'soon'}. You can still use a-la-carte credits.`} + ? `Weekly limit reached. Resets in ${formatDaysHours(rateLimit.weeklyResetsAt)}. ${fallbackToALaCarte ? 'Automatically using your credits.' : 'Your credits will not be used.'}` + : `Session exhausted. New session in ${rateLimit.blockResetsAt ? formatDaysHours(rateLimit.blockResetsAt) : 'soon'}. ${fallbackToALaCarte ? 'Automatically using your credits.' : 'Your credits will not be used.'}`}

)} + +
+
+ 5-hour limit + +
+ {blockRemainingPercent}% remaining + {rateLimit.blockResetsAt && ( + <> + · + Resets in {formatDaysHours(rateLimit.blockResetsAt)} + + )} +
+
+ +
+ Weekly limit + +
+ {weeklyRemainingPercent}% remaining + · + Resets in {formatDaysHours(rateLimit.weeklyResetsAt)} +
+
+
+ +
+ updatePreferenceMutation.mutate(checked)} + disabled={updatePreferenceMutation.isPending} + /> + +
) @@ -160,11 +200,9 @@ function SubscriptionCta() {

- - - + ) diff --git a/web/src/components/credits/CreditManagementSection.tsx b/web/src/components/credits/CreditManagementSection.tsx index 9c3ba003f4..554d2c23f6 100644 --- a/web/src/components/credits/CreditManagementSection.tsx +++ b/web/src/components/credits/CreditManagementSection.tsx @@ -57,16 +57,12 @@ export function CreditManagementSection({ isPurchasePending={isPurchasePending} isOrganization={isOrgContext} /> - {showAutoTopup && ( - <> -
- {isOrgContext && organizationId ? ( - - ) : ( - - )} - - )} + {showAutoTopup && + (isOrgContext && organizationId ? ( + + ) : ( + + ))}
) From 666ec0505715f91d05611a9efef936b1f40d201f Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 17:34:48 -0800 Subject: [PATCH 66/83] fix: address code review feedback for fallbackToALaCarte feature - Fix syntax error (stray "the" token) in preferences API route - Fix CLI cache mismatch: use setActivityQueryData instead of TanStack Query - Update chat.tsx to use server-side fallbackToALaCarte instead of local setting - Add disabled state to toggle buttons while mutation is pending - Add query invalidation to web mutation onSettled - Improve CLI mutation error handling to show actual server error - Remove deprecated getAlwaysUseALaCarte/setAlwaysUseALaCarte functions --- cli/src/chat.tsx | 7 +- .../components/subscription-limit-banner.tsx | 4 +- cli/src/components/usage-banner.tsx | 6 +- cli/src/hooks/use-update-preference.ts | 77 ++++++++++--------- cli/src/utils/settings.ts | 16 ---- web/src/app/api/user/preferences/route.ts | 2 +- .../components/subscription-section.tsx | 8 +- 7 files changed, 56 insertions(+), 64 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 40ddd88f63..a32acdfb91 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -58,7 +58,7 @@ import { getClaudeOAuthStatus } from './utils/claude-oauth' import { showClipboardMessage } from './utils/clipboard' import { readClipboardImage } from './utils/clipboard-image' import { getInputModeConfig } from './utils/input-modes' -import { getAlwaysUseALaCarte } from './utils/settings' + import { type ChatKeyboardState, createDefaultChatKeyboardState, @@ -1288,12 +1288,13 @@ export const Chat = ({ // Auto-show subscription limit banner when rate limit becomes active const subscriptionLimitShownRef = useRef(false) const subscriptionRateLimit = subscriptionData?.hasSubscription ? subscriptionData.rateLimit : undefined + const fallbackToALaCarte = subscriptionData?.fallbackToALaCarte ?? false useEffect(() => { const isLimited = subscriptionRateLimit?.limited === true if (isLimited && !subscriptionLimitShownRef.current) { subscriptionLimitShownRef.current = true // Skip showing the banner if user prefers to always fall back to a-la-carte - if (!getAlwaysUseALaCarte()) { + if (!fallbackToALaCarte) { useChatStore.getState().setInputMode('subscriptionLimit') } } else if (!isLimited) { @@ -1302,7 +1303,7 @@ export const Chat = ({ useChatStore.getState().setInputMode('default') } } - }, [subscriptionRateLimit?.limited]) + }, [subscriptionRateLimit?.limited, fallbackToALaCarte]) const inputBoxTitle = useMemo(() => { const segments: string[] = [] diff --git a/cli/src/components/subscription-limit-banner.tsx b/cli/src/components/subscription-limit-banner.tsx index d219f59257..4f9a16686d 100644 --- a/cli/src/components/subscription-limit-banner.tsx +++ b/cli/src/components/subscription-limit-banner.tsx @@ -132,9 +132,9 @@ export const SubscriptionLimitBanner = () => { {hasAlaCarteCredits && ( - )} diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 97da8334b1..6dc9d0c247 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -122,7 +122,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { subscriptionInfo={subscriptionInfo} rateLimit={rateLimit} isLoading={isSubscriptionLoading} - fallbackToALaCarte={activeSubscription.fallbackToALaCarte} + fallbackToALaCarte={activeSubscription.fallbackToALaCarte ?? false} /> )} @@ -271,9 +271,9 @@ const SubscriptionUsageSection: React.FC = ({ {fallbackToALaCarte ? 'spend credits' : 'pause'} - diff --git a/cli/src/hooks/use-update-preference.ts b/cli/src/hooks/use-update-preference.ts index e9fe18bf61..d6f7989f94 100644 --- a/cli/src/hooks/use-update-preference.ts +++ b/cli/src/hooks/use-update-preference.ts @@ -1,5 +1,10 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useCallback, useState } from 'react' +import { + getActivityQueryData, + invalidateActivityQuery, + setActivityQueryData, +} from './use-activity-query' import { subscriptionQueryKeys } from './use-subscription-query' import { getApiClient } from '../utils/codebuff-api' import { logger } from '../utils/logger' @@ -11,51 +16,49 @@ interface UpdatePreferenceParams { } export function useUpdatePreference() { - const queryClient = useQueryClient() + const [isPending, setIsPending] = useState(false) - return useMutation({ - mutationFn: async (params: UpdatePreferenceParams) => { - const client = getApiClient() - const response = await client.patch('/api/user/preferences', { - body: params, - includeCookie: true, - }) + const mutate = useCallback(async (params: UpdatePreferenceParams) => { + const queryKey = subscriptionQueryKeys.current() - if (!response.ok) { - throw new Error('Failed to update preference') - } + // Snapshot the previous value for rollback + const previousData = getActivityQueryData(queryKey) - return params - }, - onMutate: async (newParams) => { - // Cancel any outgoing refetches - await queryClient.cancelQueries({ queryKey: subscriptionQueryKeys.current() }) + // Optimistically update to the new value + if (previousData && params.fallbackToALaCarte !== undefined) { + setActivityQueryData(queryKey, { + ...previousData, + fallbackToALaCarte: params.fallbackToALaCarte, + }) + } - // Snapshot the previous value - const previousData = queryClient.getQueryData( - subscriptionQueryKeys.current() + setIsPending(true) + + try { + const client = getApiClient() + const response = await client.patch<{ success: boolean; error?: string }>( + '/api/user/preferences', + params as Record, + { includeCookie: true }, ) - // Optimistically update to the new value - if (previousData && newParams.fallbackToALaCarte !== undefined) { - queryClient.setQueryData( - subscriptionQueryKeys.current(), - { ...previousData, fallbackToALaCarte: newParams.fallbackToALaCarte } - ) + if (!response.ok) { + const errorMessage = response.error || 'Failed to update preference' + throw new Error(errorMessage) } - return { previousData } - }, - onError: (err, _newParams, context) => { + // Invalidate to refetch fresh data from server + invalidateActivityQuery(queryKey) + } catch (err) { // Rollback to previous value on error - if (context?.previousData) { - queryClient.setQueryData(subscriptionQueryKeys.current(), context.previousData) + if (previousData) { + setActivityQueryData(queryKey, previousData) } logger.error({ err }, 'Failed to update preference') - }, - onSettled: () => { - // Refetch after mutation - queryClient.invalidateQueries({ queryKey: subscriptionQueryKeys.current() }) - }, - }) + } finally { + setIsPending(false) + } + }, []) + + return { mutate, isPending } } diff --git a/cli/src/utils/settings.ts b/cli/src/utils/settings.ts index b81e000ad3..7ce71e2d6f 100644 --- a/cli/src/utils/settings.ts +++ b/cli/src/utils/settings.ts @@ -149,19 +149,3 @@ export const saveModePreference = (mode: AgentMode): void => { saveSettings({ mode }) } -/** - * Load the "always use a-la-carte" preference - * @deprecated Use server-side fallbackToALaCarte setting via useSubscriptionQuery instead - */ -export const getAlwaysUseALaCarte = (): boolean => { - const settings = loadSettings() - return settings.fallbackToALaCarte ?? settings.alwaysUseALaCarte ?? false -} - -/** - * Save the "always use a-la-carte" preference - * @deprecated Use server-side fallbackToALaCarte setting via useUpdatePreference instead - */ -export const setAlwaysUseALaCarte = (value: boolean): void => { - saveSettings({ fallbackToALaCarte: value }) -} diff --git a/web/src/app/api/user/preferences/route.ts b/web/src/app/api/user/preferences/route.ts index f9a3eab5b5..43478d81ce 100644 --- a/web/src/app/api/user/preferences/route.ts +++ b/web/src/app/api/user/preferences/route.ts @@ -55,7 +55,7 @@ export async function PATCH(request: Request) { .update(schema.user) .set(updates) .where(eq(schema.user.id, userId)) -the + logger.info({ userId, updates }, 'User preferences updated') return NextResponse.json({ success: true, ...parsed.data }) diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index cd1cb5bfe9..61777b9efd 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -76,13 +76,17 @@ function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; old ? { ...old, fallbackToALaCarte: newValue } : old ) }, - onError: (error: Error) => { + onError: (err: Error) => { toast({ title: 'Error', - description: error.message, + description: err.message, variant: 'destructive', }) }, + onSettled: () => { + // Refetch to ensure consistency with server + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + }, }) const blockRemainingPercent = From 09bdb58c7ea1418b387b7cdcc9d5a234f21625b8 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 18:10:48 -0800 Subject: [PATCH 67/83] style: remove max-width constraint from UsageDisplay card --- web/src/app/profile/components/usage-display.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index 718a178971..6358982dba 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -295,7 +295,7 @@ export const UsageDisplay = ({ ) return ( - + Credit Balance @@ -395,7 +395,7 @@ export const UsageDisplay = ({ } export const UsageDisplaySkeleton = () => ( - +
From 9f8e9d07ba811d0bb2ba429be8897b79d3413423 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 18:49:00 -0800 Subject: [PATCH 68/83] style: update SubscriptionCta with acid-green button and cleaner copy --- web/src/app/profile/components/subscription-section.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index 61777b9efd..4864a1d735 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -189,10 +189,10 @@ function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; function SubscriptionCta() { return ( - +
-
+
💪
@@ -200,11 +200,11 @@ function SubscriptionCta() { Upgrade to {SUBSCRIPTION_DISPLAY_NAME}

- From $100/mo · Save credits with 5-hour work sessions included + From $100/mo · Subscribe to save on credits

- From 85a002289f3bd3fb7d8f78599b2e0d16e0264cdd Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 18:53:00 -0800 Subject: [PATCH 69/83] style: use acid-green for SubscriptionCta card border and icon background --- web/src/app/profile/components/subscription-section.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index 4864a1d735..ae9cfb6524 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -189,10 +189,10 @@ function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; function SubscriptionCta() { return ( - +
-
+
💪
From dd71ccf52f6efd890f7d293795b8522137f58733 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 19:01:24 -0800 Subject: [PATCH 70/83] fix: add warning log when subscription not found in handleSubscriptionDeleted --- packages/billing/src/subscription-webhooks.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index 546cd335f9..ea30b5862f 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -347,7 +347,7 @@ export async function handleSubscriptionDeleted(params: { const user = await getUserByStripeCustomerId(customerId) const userId = user?.id ?? null - await db + const result = await db .update(schema.subscription) .set({ status: 'canceled', @@ -355,6 +355,22 @@ export async function handleSubscriptionDeleted(params: { canceled_at: new Date(), }) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) + .returning({ id: schema.subscription.stripe_subscription_id }) + + if (result.length === 0) { + logger.warn( + { subscriptionId, customerId }, + 'No subscription found to cancel — may not exist in our database', + ) + // Still track the event for observability + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_CANCELED, + userId: userId ?? 'system', + properties: { subscriptionId, notFoundInDb: true }, + logger, + }) + return + } if (userId) { await expireActiveBlockGrants({ userId, subscriptionId, logger }) From d407cdfbc186f2501e4671c43593d9dbe7bb0d36 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 19:39:04 -0800 Subject: [PATCH 71/83] Revert "Enable invoice creation and tax id collection in stripe checkout" This reverts commit ca4ea4b81a740ee4e6aa9ff29aecf2036adb98a9. --- web/src/app/api/stripe/buy-credits/route.ts | 3 --- web/src/app/api/stripe/create-subscription/route.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/web/src/app/api/stripe/buy-credits/route.ts b/web/src/app/api/stripe/buy-credits/route.ts index 28374e86d3..def0eb0fcd 100644 --- a/web/src/app/api/stripe/buy-credits/route.ts +++ b/web/src/app/api/stripe/buy-credits/route.ts @@ -185,9 +185,6 @@ export async function POST(req: NextRequest) { }, ], mode: 'payment', - invoice_creation: { enabled: true }, - tax_id_collection: { enabled: true }, // optional (EU B2B) - customer_update: { name: "auto", address: "auto" }, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/payment-success?session_id={CHECKOUT_SESSION_ID}&purchase=credits&amt=${credits}`, cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage?purchase_canceled=true`, metadata: { diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts index e16b60658f..3ae329c5d5 100644 --- a/web/src/app/api/stripe/create-subscription/route.ts +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -72,9 +72,6 @@ export async function POST(req: NextRequest) { const checkoutSession = await stripeServer.checkout.sessions.create({ customer: user.stripe_customer_id, mode: 'subscription', - invoice_creation: { enabled: true }, - tax_id_collection: { enabled: true }, // optional (EU B2B) - customer_update: { name: "auto", address: "auto" }, line_items: [{ price: priceId, quantity: 1 }], allow_promotion_codes: true, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile?tab=usage&subscription_success=true`, From 8c65530afe5afa7ecfafe7c33786fa9fd31c91db Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 4 Feb 2026 19:55:11 -0800 Subject: [PATCH 72/83] Don't include subscription credits in /usage stats --- .../src/__tests__/balance-calculator.test.ts | 193 ++++++++++++++++++ packages/billing/src/balance-calculator.ts | 5 +- 2 files changed, 196 insertions(+), 2 deletions(-) diff --git a/packages/billing/src/__tests__/balance-calculator.test.ts b/packages/billing/src/__tests__/balance-calculator.test.ts index 616a7e4214..d0bdcbe8a6 100644 --- a/packages/billing/src/__tests__/balance-calculator.test.ts +++ b/packages/billing/src/__tests__/balance-calculator.test.ts @@ -139,6 +139,199 @@ function createDbMockForUnion(options: { } } +describe('Balance Calculator - calculateUsageAndBalance', () => { + afterEach(() => { + clearMockedModules() + }) + + describe('isPersonalContext behavior', () => { + it('should exclude subscription credits when isPersonalContext is true', async () => { + const now = new Date() + const quotaResetDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) // 7 days ago + + const grants = [ + createMockGrant({ + operation_id: 'free-grant', + balance: 500, + principal: 1000, + priority: 20, + type: 'purchase', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'subscription-grant', + balance: 2000, + principal: 5000, + priority: 10, + type: 'subscription', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ] + + // Mock the database to return our test grants + await mockModule('@codebuff/internal/db', () => ({ + default: { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => grants, + }), + }), + }), + }, + })) + + // Mock analytics to prevent actual tracking + await mockModule('@codebuff/common/analytics', () => ({ + trackEvent: () => {}, + })) + + const { calculateUsageAndBalance } = await import( + '@codebuff/billing/balance-calculator' + ) + + const result = await calculateUsageAndBalance({ + userId: 'user-123', + quotaResetDate, + now, + isPersonalContext: true, + logger, + }) + + // Should only include purchase credits (500), not subscription (2000) + expect(result.balance.totalRemaining).toBe(500) + expect(result.balance.breakdown.purchase).toBe(500) + expect(result.balance.breakdown.subscription).toBe(0) + + // Usage should only include purchase usage (1000 - 500 = 500), not subscription (5000 - 2000 = 3000) + expect(result.usageThisCycle).toBe(500) + }) + + it('should include subscription credits when isPersonalContext is false', async () => { + const now = new Date() + const quotaResetDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) // 7 days ago + + const grants = [ + createMockGrant({ + operation_id: 'free-grant', + balance: 500, + principal: 1000, + priority: 20, + type: 'purchase', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'subscription-grant', + balance: 2000, + principal: 5000, + priority: 10, + type: 'subscription', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ] + + await mockModule('@codebuff/internal/db', () => ({ + default: { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => grants, + }), + }), + }), + }, + })) + + await mockModule('@codebuff/common/analytics', () => ({ + trackEvent: () => {}, + })) + + const { calculateUsageAndBalance } = await import( + '@codebuff/billing/balance-calculator' + ) + + const result = await calculateUsageAndBalance({ + userId: 'user-123', + quotaResetDate, + now, + isPersonalContext: false, + logger, + }) + + // Should include both purchase (500) and subscription (2000) credits + expect(result.balance.totalRemaining).toBe(2500) + expect(result.balance.breakdown.purchase).toBe(500) + expect(result.balance.breakdown.subscription).toBe(2000) + + // Usage should include both: (1000 - 500) + (5000 - 2000) = 3500 + expect(result.usageThisCycle).toBe(3500) + }) + + it('should exclude organization credits when isPersonalContext is true', async () => { + const now = new Date() + const quotaResetDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + + const grants = [ + createMockGrant({ + operation_id: 'free-grant', + balance: 500, + principal: 1000, + priority: 20, + type: 'purchase', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'org-grant', + balance: 3000, + principal: 5000, + priority: 5, + type: 'organization', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ] + + await mockModule('@codebuff/internal/db', () => ({ + default: { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => grants, + }), + }), + }), + }, + })) + + await mockModule('@codebuff/common/analytics', () => ({ + trackEvent: () => {}, + })) + + const { calculateUsageAndBalance } = await import( + '@codebuff/billing/balance-calculator' + ) + + const result = await calculateUsageAndBalance({ + userId: 'user-123', + quotaResetDate, + now, + isPersonalContext: true, + logger, + }) + + // Should only include purchase credits (500), not organization (3000) + expect(result.balance.totalRemaining).toBe(500) + expect(result.balance.breakdown.purchase).toBe(500) + expect(result.balance.breakdown.organization).toBe(0) + }) + }) +}) + describe('Balance Calculator - Grant Ordering for Consumption', () => { // NOTE: This test suite uses a complex mock (createDbMockForUnion) to simulate the // behavior of the UNION query in `getOrderedActiveGrantsForConsumption`. diff --git a/packages/billing/src/balance-calculator.ts b/packages/billing/src/balance-calculator.ts index 9b46e5fafd..165c2030a0 100644 --- a/packages/billing/src/balance-calculator.ts +++ b/packages/billing/src/balance-calculator.ts @@ -326,8 +326,9 @@ export async function calculateUsageAndBalance( for (const grant of grants) { const grantType = grant.type as GrantType - // Skip organization credits for personal context - if (isPersonalContext && grantType === 'organization') { + // Skip organization and subscription credits for personal context + // Subscription credits are shown separately in the CLI with progress bars + if (isPersonalContext && (grantType === 'organization' || grantType === 'subscription')) { continue } From 16702f88d9c579fe71032f3209b45547e07fbba4 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 4 Feb 2026 19:56:57 -0800 Subject: [PATCH 73/83] /usage: Don't add to session credits if credits spent are part of subscription --- cli/src/chat.tsx | 11 +++++----- cli/src/components/message-footer.tsx | 20 ++++++++--------- cli/src/hooks/use-send-message.ts | 11 +++++++++- cli/src/utils/subscription.ts | 31 +++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 16 deletions(-) create mode 100644 cli/src/utils/subscription.ts diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index a32acdfb91..77674e0af5 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -163,6 +163,11 @@ export const Chat = ({ const { statusMessage } = useClipboard() const { ad } = useGravityAd() + // Fetch subscription data early - needed for session credits tracking + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: 60 * 1000, + }) + // Set initial mode from CLI flag on mount useEffect(() => { if (initialMode) { @@ -427,6 +432,7 @@ export const Chat = ({ resumeQueue, continueChat, continueChatId, + subscriptionData, }) sendMessageRef.current = sendMessage @@ -1280,11 +1286,6 @@ export const Chat = ({ refetchInterval: 60 * 1000, // Refetch every 60 seconds }) - // Fetch subscription data - const { data: subscriptionData } = useSubscriptionQuery({ - refetchInterval: 60 * 1000, - }) - // Auto-show subscription limit banner when rate limit becomes active const subscriptionLimitShownRef = useRef(false) const subscriptionRateLimit = subscriptionData?.hasSubscription ? subscriptionData.rateLimit : undefined diff --git a/cli/src/components/message-footer.tsx b/cli/src/components/message-footer.tsx index 57138ed7da..678611302f 100644 --- a/cli/src/components/message-footer.tsx +++ b/cli/src/components/message-footer.tsx @@ -7,6 +7,10 @@ import { CopyButton } from './copy-button' import { ElapsedTimer } from './elapsed-timer' import { FeedbackIconButton } from './feedback-icon-button' import { useSubscriptionQuery } from '../hooks/use-subscription-query' +import { + getBlockPercentRemaining, + isCoveredBySubscription, +} from '../utils/subscription' import { useTheme } from '../hooks/use-theme' import { useFeedbackStore, @@ -221,19 +225,15 @@ const CreditsOrSubscriptionIndicator: React.FC<{ credits: number }> = ({ credits pauseWhenIdle: false, }) - const activeSubscription = subscriptionData?.hasSubscription ? subscriptionData : null - const rateLimit = activeSubscription?.rateLimit - - const blockPercentRemaining = useMemo(() => { - if (!rateLimit?.blockLimit || rateLimit.blockUsed == null) return null - return Math.round(((rateLimit.blockLimit - rateLimit.blockUsed) / rateLimit.blockLimit) * 100) - }, [rateLimit]) + const blockPercentRemaining = useMemo( + () => getBlockPercentRemaining(subscriptionData), + [subscriptionData], + ) - const showSubscriptionIndicator = - activeSubscription && !rateLimit?.limited && blockPercentRemaining != null && blockPercentRemaining > 0 + const showSubscriptionIndicator = isCoveredBySubscription(subscriptionData) if (showSubscriptionIndicator) { - const label = blockPercentRemaining < 20 + const label = (blockPercentRemaining ?? 0) < 20 ? `✓ ${SUBSCRIPTION_DISPLAY_NAME} (${blockPercentRemaining}% left)` : `✓ ${SUBSCRIPTION_DISPLAY_NAME}` return ( diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 4411c79e8e..9cc0b6cf07 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -38,6 +38,9 @@ import type { SendMessageFn } from '../types/contracts/send-message' import type { AgentMode } from '../utils/constants' import type { SendMessageTimerEvent } from '../utils/send-message-timer' import type { AgentDefinition, MessageContent, RunState } from '@codebuff/sdk' +import { isCoveredBySubscription } from '../utils/subscription' + +import type { SubscriptionResponse } from './use-subscription-query' interface UseSendMessageOptions { inputRef: React.MutableRefObject @@ -59,6 +62,7 @@ interface UseSendMessageOptions { resumeQueue?: () => void continueChat: boolean continueChatId?: string + subscriptionData?: SubscriptionResponse | null } // Choose the agent definition by explicit selection or mode-based fallback. @@ -109,6 +113,7 @@ export const useSendMessage = ({ resumeQueue, continueChat, continueChatId, + subscriptionData, }: UseSendMessageOptions): { sendMessage: SendMessageFn clearMessages: () => void @@ -431,7 +436,11 @@ export const useSendMessage = ({ setIsRetrying, onTotalCost: (cost: number) => { actualCredits = cost - addSessionCredits(cost) + // Only add to session credits if not covered by subscription + // (subscription credits are shown separately in the UI) + if (!isCoveredBySubscription(subscriptionData)) { + addSessionCredits(cost) + } }, }) diff --git a/cli/src/utils/subscription.ts b/cli/src/utils/subscription.ts new file mode 100644 index 0000000000..5bbdc5ae9f --- /dev/null +++ b/cli/src/utils/subscription.ts @@ -0,0 +1,31 @@ +import type { SubscriptionResponse } from '../hooks/use-subscription-query' + +/** + * Calculates the percentage of subscription block credits remaining. + * Returns null if the subscription data is incomplete. + */ +export function getBlockPercentRemaining( + subscriptionData: SubscriptionResponse | null | undefined, +): number | null { + if (!subscriptionData?.hasSubscription) return null + const rateLimit = subscriptionData.rateLimit + if (!rateLimit?.blockLimit || rateLimit.blockUsed == null) return null + return Math.round( + ((rateLimit.blockLimit - rateLimit.blockUsed) / rateLimit.blockLimit) * 100, + ) +} + +/** + * Determines if a request is covered by subscription based on subscription data. + * Returns true if the user has an active subscription that's not rate-limited + * and has remaining block credits. + */ +export function isCoveredBySubscription( + subscriptionData: SubscriptionResponse | null | undefined, +): boolean { + if (!subscriptionData?.hasSubscription) return false + const rateLimit = subscriptionData.rateLimit + if (rateLimit?.limited) return false + const blockPercentRemaining = getBlockPercentRemaining(subscriptionData) + return blockPercentRemaining != null && blockPercentRemaining > 0 +} From 7eedfa41cfd01430ed0bad30b5cf79c4c1d3e767 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 20:02:20 -0800 Subject: [PATCH 74/83] fix: address code review feedback for subscription-client branch --- cli/src/components/chat-input-bar.tsx | 6 ------ cli/src/hooks/use-update-preference.ts | 2 ++ packages/billing/src/subscription-webhooks.ts | 2 +- web/src/app/api/user/subscription/route.ts | 19 +------------------ 4 files changed, 4 insertions(+), 25 deletions(-) diff --git a/cli/src/components/chat-input-bar.tsx b/cli/src/components/chat-input-bar.tsx index 6fb0ce2b16..eda5ea64c3 100644 --- a/cli/src/components/chat-input-bar.tsx +++ b/cli/src/components/chat-input-bar.tsx @@ -6,7 +6,6 @@ import { FeedbackContainer } from './feedback-container' import { InputModeBanner } from './input-mode-banner' import { MultilineInput, type MultilineInputHandle } from './multiline-input' import { OutOfCreditsBanner } from './out-of-credits-banner' -import { SubscriptionLimitBanner } from './subscription-limit-banner' import { PublishContainer } from './publish-container' import { SuggestionMenu, type SuggestionItem } from './suggestion-menu' import { useAskUserBridge } from '../hooks/use-ask-user-bridge' @@ -191,11 +190,6 @@ export const ChatInputBar = ({ return } - // Subscription limit mode: replace entire input with subscription limit banner - if (inputMode === 'subscriptionLimit') { - return - } - // Handle input changes with special mode entry detection const handleInputChange = (value: InputValue) => { // Detect entering bash mode: user typed exactly '!' when in default mode diff --git a/cli/src/hooks/use-update-preference.ts b/cli/src/hooks/use-update-preference.ts index d6f7989f94..7c72f304bb 100644 --- a/cli/src/hooks/use-update-preference.ts +++ b/cli/src/hooks/use-update-preference.ts @@ -6,6 +6,7 @@ import { setActivityQueryData, } from './use-activity-query' import { subscriptionQueryKeys } from './use-subscription-query' +import { showClipboardMessage } from '../utils/clipboard' import { getApiClient } from '../utils/codebuff-api' import { logger } from '../utils/logger' @@ -55,6 +56,7 @@ export function useUpdatePreference() { setActivityQueryData(queryKey, previousData) } logger.error({ err }, 'Failed to update preference') + showClipboardMessage('Failed to update preference', { durationMs: 3000 }) } finally { setIsPending(false) } diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index ea30b5862f..ea923f3721 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -447,7 +447,7 @@ export async function handleSubscriptionScheduleCreatedOrUpdated(params: { const scheduledPriceId = nextPhase?.items?.[0]?.price const priceId = typeof scheduledPriceId === 'string' ? scheduledPriceId - : scheduledPriceId?.toString() + : scheduledPriceId?.id if (!priceId) { logger.warn( diff --git a/web/src/app/api/user/subscription/route.ts b/web/src/app/api/user/subscription/route.ts index b36ec564f7..9d09b36723 100644 --- a/web/src/app/api/user/subscription/route.ts +++ b/web/src/app/api/user/subscription/route.ts @@ -6,8 +6,6 @@ import { import { SUBSCRIPTION_DISPLAY_NAME } 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' @@ -44,23 +42,9 @@ export async function GET() { return NextResponse.json(response) } - const stripeCustomerId = session.user.stripe_customer_id - - const [rateLimit, limits, billingPortalUrl] = await Promise.all([ + const [rateLimit, limits] = await Promise.all([ checkRateLimit({ userId, subscription, logger }), getSubscriptionLimits({ userId, logger, tier: subscription.tier }), - stripeCustomerId - ? stripeServer.billingPortal.sessions - .create({ - customer: stripeCustomerId, - return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile`, - }) - .then((portalSession) => portalSession.url) - .catch((error) => { - logger.warn({ userId, error }, 'Failed to create billing portal session') - return undefined - }) - : Promise.resolve(undefined), ]) const response: ActiveSubscriptionResponse = { @@ -87,7 +71,6 @@ export async function GET() { weeklyPercentUsed: rateLimit.weeklyPercentUsed, }, limits, - billingPortalUrl, fallbackToALaCarte, } return NextResponse.json(response) From 020121fb58212c02c7fb8d94824bc3da2cb7ad01 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 4 Feb 2026 20:11:29 -0800 Subject: [PATCH 75/83] Add back some stripe checkout fields that are mildly beneficial --- web/src/app/api/stripe/buy-credits/route.ts | 3 +++ web/src/app/api/stripe/create-subscription/route.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/web/src/app/api/stripe/buy-credits/route.ts b/web/src/app/api/stripe/buy-credits/route.ts index def0eb0fcd..28374e86d3 100644 --- a/web/src/app/api/stripe/buy-credits/route.ts +++ b/web/src/app/api/stripe/buy-credits/route.ts @@ -185,6 +185,9 @@ export async function POST(req: NextRequest) { }, ], mode: 'payment', + invoice_creation: { enabled: true }, + tax_id_collection: { enabled: true }, // optional (EU B2B) + customer_update: { name: "auto", address: "auto" }, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/payment-success?session_id={CHECKOUT_SESSION_ID}&purchase=credits&amt=${credits}`, cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage?purchase_canceled=true`, metadata: { diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts index 3ae329c5d5..f23f5635e1 100644 --- a/web/src/app/api/stripe/create-subscription/route.ts +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -72,6 +72,8 @@ export async function POST(req: NextRequest) { const checkoutSession = await stripeServer.checkout.sessions.create({ customer: user.stripe_customer_id, mode: 'subscription', + tax_id_collection: { enabled: true }, // optional (EU B2B) + customer_update: { name: "auto", address: "auto" }, line_items: [{ price: priceId, quantity: 1 }], allow_promotion_codes: true, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile?tab=usage&subscription_success=true`, From 9047000266b427cbbb918c465ce1a2228eae4333 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 20:16:45 -0800 Subject: [PATCH 76/83] fix: enforce fallback_to_a_la_carte preference and move block grant after validation --- web/src/app/api/v1/chat/completions/_post.ts | 81 ++++++++++++++++---- web/src/app/api/v1/chat/completions/route.ts | 15 ++++ 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 11801d225d..62c3a7eb3e 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -18,7 +18,18 @@ import type { LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' -import type { BlockGrantResult } from '@codebuff/billing/subscription' +import type { + BlockGrantResult, +} from '@codebuff/billing/subscription' +import { + isWeeklyLimitError, + isBlockExhaustedError, +} from '@codebuff/billing/subscription' + +export type GetUserPreferencesFn = (params: { + userId: string + logger: Logger +}) => Promise<{ fallbackToALaCarte: boolean }> import type { NextRequest } from 'next/server' import type { ChatCompletionRequestBody } from '@/llm-api/types' @@ -81,6 +92,7 @@ export async function postChatCompletions(params: { fetch: typeof globalThis.fetch insertMessageBigquery: InsertMessageBigqueryFn ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise + getUserPreferences?: GetUserPreferencesFn }) { const { req, @@ -92,6 +104,7 @@ export async function postChatCompletions(params: { fetch, insertMessageBigquery, ensureSubscriberBlockGrant, + getUserPreferences, } = params let { logger } = params @@ -186,19 +199,6 @@ export async function postChatCompletions(params: { logger, }) - // For subscribers, ensure a block grant exists before checking balance. - // This is done here because block grants should only start when the user begins working. - if (ensureSubscriberBlockGrant) { - try { - await ensureSubscriberBlockGrant({ userId, logger }) - } catch (error) { - logger.error( - { error: getErrorObject(error), userId }, - 'Error ensuring subscription block grant', - ) - } - } - // Check user credits const { balance: { totalRemaining }, @@ -281,6 +281,59 @@ export async function postChatCompletions(params: { ) } + // For subscribers, ensure a block grant exists before processing the request. + // This is done AFTER validation so malformed requests don't start a new 5-hour block. + if (ensureSubscriberBlockGrant) { + try { + const blockGrantResult = await ensureSubscriberBlockGrant({ userId, logger }) + + // Check if user hit subscription limit and should be rate-limited + if (blockGrantResult && (isWeeklyLimitError(blockGrantResult) || isBlockExhaustedError(blockGrantResult))) { + // Fetch user's preference for falling back to a-la-carte credits + const preferences = getUserPreferences + ? await getUserPreferences({ userId, logger }) + : { fallbackToALaCarte: true } // Default to allowing a-la-carte if no preference function + + if (!preferences.fallbackToALaCarte) { + const resetTime = blockGrantResult.resetsAt + const resetCountdown = formatQuotaResetCountdown(resetTime.toISOString()) + const limitType = isWeeklyLimitError(blockGrantResult) ? 'weekly' : '5-hour session' + + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_INSUFFICIENT_CREDITS, + userId, + properties: { + reason: 'subscription_limit_no_fallback', + limitType, + fallbackToALaCarte: false, + }, + logger, + }) + + return NextResponse.json( + { + error: 'rate_limit_exceeded', + message: `Subscription ${limitType} limit reached. Your limit resets ${resetCountdown}. Enable "Continue with credits" in the CLI to use a-la-carte credits.`, + }, + { status: 429 }, + ) + } + // If fallbackToALaCarte is true, continue to use a-la-carte credits + logger.info( + { userId, limitType: isWeeklyLimitError(blockGrantResult) ? 'weekly' : 'session' }, + 'Subscriber hit limit, falling back to a-la-carte credits', + ) + } + } catch (error) { + logger.error( + { error: getErrorObject(error), userId }, + 'Error ensuring subscription block grant', + ) + // Fail open: if we can't check the subscription status, allow the request to proceed + // This is intentional - we prefer to allow requests rather than block legitimate users + } + } + const openrouterApiKey = req.headers.get(BYOK_OPENROUTER_HEADER) // Handle streaming vs non-streaming diff --git a/web/src/app/api/v1/chat/completions/route.ts b/web/src/app/api/v1/chat/completions/route.ts index 44db2feaeb..a6a4ace378 100644 --- a/web/src/app/api/v1/chat/completions/route.ts +++ b/web/src/app/api/v1/chat/completions/route.ts @@ -2,15 +2,29 @@ import { insertMessageBigquery } from '@codebuff/bigquery' import { ensureSubscriberBlockGrant } from '@codebuff/billing/subscription' import { getUserUsageData } from '@codebuff/billing/usage-service' import { trackEvent } from '@codebuff/common/analytics' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' import { postChatCompletions } from './_post' +import type { GetUserPreferencesFn } from './_post' import type { NextRequest } from 'next/server' import { getAgentRunFromId } from '@/db/agent-run' import { getUserInfoFromApiKey } from '@/db/user' import { logger, loggerWithContext } from '@/util/logger' +const getUserPreferences: GetUserPreferencesFn = async ({ userId }) => { + const userPrefs = await db.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { fallback_to_a_la_carte: true }, + }) + return { + fallbackToALaCarte: userPrefs?.fallback_to_a_la_carte ?? false, + } +} + export async function POST(req: NextRequest) { return postChatCompletions({ req, @@ -23,5 +37,6 @@ export async function POST(req: NextRequest) { fetch, insertMessageBigquery, ensureSubscriberBlockGrant, + getUserPreferences, }) } From 7a1531baff6de4523fc7872d2197c4504f73a7d0 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 20:16:52 -0800 Subject: [PATCH 77/83] test: add unit tests for subscription limit enforcement in chat completions API --- .../completions/__tests__/completions.test.ts | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 40c763fd45..f3ab9a3651 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -14,6 +14,8 @@ import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' +import type { BlockGrantResult } from '@codebuff/billing/subscription' +import type { GetUserPreferencesFn } from '../_post' describe('/api/v1/chat/completions POST endpoint', () => { const mockUserData: Record< @@ -497,4 +499,265 @@ describe('/api/v1/chat/completions POST endpoint', () => { expect(body.choices[0].message.content).toBe('test response') }) }) + + describe('Subscription limit enforcement', () => { + const createValidRequest = () => + new NextRequest('http://localhost:3000/api/v1/chat/completions', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + client_request_id: 'test-client-session-id-123', + }, + }), + }) + + it('returns 429 when weekly limit reached and fallback disabled', async () => { + const weeklyLimitError: BlockGrantResult = { + error: 'weekly_limit_reached', + used: 3500, + limit: 3500, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock(async () => weeklyLimitError) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + expect(response.status).toBe(429) + const body = await response.json() + expect(body.error).toBe('rate_limit_exceeded') + expect(body.message).toContain('weekly limit reached') + expect(body.message).toContain('Enable "Continue with credits"') + }) + + it('returns 429 when block exhausted and fallback disabled', async () => { + const blockExhaustedError: BlockGrantResult = { + error: 'block_exhausted', + blockUsed: 350, + blockLimit: 350, + resetsAt: new Date(Date.now() + 4 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock(async () => blockExhaustedError) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + expect(response.status).toBe(429) + const body = await response.json() + expect(body.error).toBe('rate_limit_exceeded') + expect(body.message).toContain('5-hour session limit reached') + expect(body.message).toContain('Enable "Continue with credits"') + }) + + it('continues when weekly limit reached but fallback is enabled', async () => { + const weeklyLimitError: BlockGrantResult = { + error: 'weekly_limit_reached', + used: 3500, + limit: 3500, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock(async () => weeklyLimitError) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: true, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + expect(response.status).toBe(200) + expect(mockLogger.info).toHaveBeenCalled() + }) + + it('continues when block grant is created successfully', async () => { + const blockGrant: BlockGrantResult = { + grantId: 'block-123', + credits: 350, + expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000), + isNew: true, + } + const mockEnsureSubscriberBlockGrant = mock(async () => blockGrant) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + expect(response.status).toBe(200) + // getUserPreferences should not be called when block grant succeeds + expect(mockGetUserPreferences).not.toHaveBeenCalled() + }) + + it('continues when ensureSubscriberBlockGrant throws an error (fail open)', async () => { + const mockEnsureSubscriberBlockGrant = mock(async () => { + throw new Error('Database connection failed') + }) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + // Should continue processing (fail open) + expect(response.status).toBe(200) + expect(mockLogger.error).toHaveBeenCalled() + }) + + it('continues when user is not a subscriber (null result)', async () => { + const mockEnsureSubscriberBlockGrant = mock(async () => null) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + expect(response.status).toBe(200) + // getUserPreferences should not be called for non-subscribers + expect(mockGetUserPreferences).not.toHaveBeenCalled() + }) + + it('defaults to allowing fallback when getUserPreferences is not provided', async () => { + const weeklyLimitError: BlockGrantResult = { + error: 'weekly_limit_reached', + used: 3500, + limit: 3500, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock(async () => weeklyLimitError) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + // Note: getUserPreferences is NOT provided + }) + + // Should continue processing (default to allowing a-la-carte) + expect(response.status).toBe(200) + }) + + it('does not call ensureSubscriberBlockGrant before validation passes', async () => { + const mockEnsureSubscriberBlockGrant = mock(async () => null) + + // Request with invalid run_id + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-nonexistent', + }, + }), + }, + ) + + const response = await postChatCompletions({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + }) + + // Should return 400 for invalid run_id + expect(response.status).toBe(400) + // ensureSubscriberBlockGrant should NOT have been called + expect(mockEnsureSubscriberBlockGrant).not.toHaveBeenCalled() + }) + }) }) From 164abc5f8cecb97c73ec74d5d46c14b8ebc84d31 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 20:28:40 -0800 Subject: [PATCH 78/83] feat: add dedicated billing-portal endpoint for on-demand portal URL generation --- web/src/app/api/user/billing-portal/route.ts | 40 +++++++++++++++ .../components/subscription-section.tsx | 50 +++++++++++++++---- 2 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 web/src/app/api/user/billing-portal/route.ts diff --git a/web/src/app/api/user/billing-portal/route.ts b/web/src/app/api/user/billing-portal/route.ts new file mode 100644 index 0000000000..1462adc5a8 --- /dev/null +++ b/web/src/app/api/user/billing-portal/route.ts @@ -0,0 +1,40 @@ +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' +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 stripeCustomerId = session.user.stripe_customer_id + if (!stripeCustomerId) { + return NextResponse.json( + { error: 'No Stripe customer ID found' }, + { status: 400 } + ) + } + + try { + const portalSession = await stripeServer.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile`, + }) + + return NextResponse.json({ url: portalSession.url }) + } catch (error) { + logger.error( + { userId: session.user.id, error }, + 'Failed to create billing portal session' + ) + return NextResponse.json( + { error: 'Failed to create billing portal session' }, + { status: 500 } + ) + } +} diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index ae9cfb6524..8c61829418 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -55,9 +55,33 @@ function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; const { subscription, rateLimit, fallbackToALaCarte } = data const isCanceling = subscription.cancelAtPeriodEnd const fallbackPortalUrl = `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` - const billingPortalUrl = data.billingPortalUrl ?? fallbackPortalUrl const queryClient = useQueryClient() + const billingPortalMutation = useMutation({ + mutationFn: async () => { + const res = await fetch('/api/user/billing-portal', { + method: 'POST', + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' })) + throw new Error(error.error || 'Failed to open billing portal') + } + const data = await res.json() + return data.url as string + }, + onSuccess: (url) => { + window.open(url, '_blank', 'noopener,noreferrer') + }, + onError: (err: Error) => { + // Fall back to the prefilled email portal URL on error + window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer') + toast({ + title: 'Note', + description: 'Opened billing portal - you may need to sign in.', + }) + }, + }) + const updatePreferenceMutation = useMutation({ mutationFn: async (newValue: boolean) => { const res = await fetch('/api/user/preferences', { @@ -116,15 +140,23 @@ function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; )} - billingPortalMutation.mutate()} + disabled={billingPortalMutation.isPending} + className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 disabled:opacity-50" > - Manage - - + {billingPortalMutation.isPending ? ( + <> + + Opening... + + ) : ( + <> + Manage + + + )} +
From 71c264178aee06917319d069786dc6aac64abe56 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 20:34:36 -0800 Subject: [PATCH 79/83] test: add unit tests for billing-portal endpoint using dependency injection --- .../__tests__/billing-portal.test.ts | 177 ++++++++++++++++++ web/src/app/api/user/billing-portal/_post.ts | 61 ++++++ web/src/app/api/user/billing-portal/route.ts | 40 +--- 3 files changed, 247 insertions(+), 31 deletions(-) create mode 100644 web/src/app/api/user/billing-portal/__tests__/billing-portal.test.ts create mode 100644 web/src/app/api/user/billing-portal/_post.ts diff --git a/web/src/app/api/user/billing-portal/__tests__/billing-portal.test.ts b/web/src/app/api/user/billing-portal/__tests__/billing-portal.test.ts new file mode 100644 index 0000000000..0fa8744380 --- /dev/null +++ b/web/src/app/api/user/billing-portal/__tests__/billing-portal.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, mock, test } from 'bun:test' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +import { postBillingPortal } from '../_post' + +import type { CreateBillingPortalSessionFn, GetSessionFn, Session } from '../_post' + +const createMockLogger = (errorFn = mock(() => {})): Logger => ({ + error: errorFn, + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), +}) + +const createMockGetSession = (session: Session): GetSessionFn => mock(() => Promise.resolve(session)) + +const createMockCreateBillingPortalSession = ( + result: { url: string } | Error = { url: 'https://billing.stripe.com/session/test_123' } +): CreateBillingPortalSessionFn => { + if (result instanceof Error) { + return mock(() => Promise.reject(result)) + } + return mock(() => Promise.resolve(result)) +} + +describe('/api/user/billing-portal POST endpoint', () => { + const returnUrl = 'https://codebuff.com/profile' + + describe('Authentication', () => { + test('returns 401 when session is null', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user is null', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ user: null }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user.id is missing', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ user: { stripe_customer_id: 'cus_123' } as any }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + }) + + describe('Stripe customer validation', () => { + test('returns 400 when stripe_customer_id is null', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: null }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'No Stripe customer ID found' }) + }) + + test('returns 400 when stripe_customer_id is undefined', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'No Stripe customer ID found' }) + }) + }) + + describe('Successful portal session creation', () => { + test('returns 200 with portal URL on success', async () => { + const expectedUrl = 'https://billing.stripe.com/session/abc123' + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession({ url: expectedUrl }), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ url: expectedUrl }) + }) + + test('calls createBillingPortalSession with correct parameters', async () => { + const mockCreateSession = createMockCreateBillingPortalSession() + await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_456' }, + }), + createBillingPortalSession: mockCreateSession, + logger: createMockLogger(), + returnUrl: 'https://example.com/return', + }) + + expect(mockCreateSession).toHaveBeenCalledTimes(1) + expect(mockCreateSession).toHaveBeenCalledWith({ + customer: 'cus_test_456', + return_url: 'https://example.com/return', + }) + }) + }) + + describe('Error handling', () => { + test('returns 500 when Stripe API throws an error', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession( + new Error('Stripe API error') + ), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: 'Failed to create billing portal session' }) + }) + + test('logs error when Stripe API fails', async () => { + const mockLoggerError = mock(() => {}) + const testError = new Error('Stripe connection failed') + + await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(testError), + logger: createMockLogger(mockLoggerError), + returnUrl, + }) + + expect(mockLoggerError).toHaveBeenCalledTimes(1) + expect(mockLoggerError).toHaveBeenCalledWith( + { userId: 'user-123', error: testError }, + 'Failed to create billing portal session' + ) + }) + }) +}) diff --git a/web/src/app/api/user/billing-portal/_post.ts b/web/src/app/api/user/billing-portal/_post.ts new file mode 100644 index 0000000000..a8c7bd81eb --- /dev/null +++ b/web/src/app/api/user/billing-portal/_post.ts @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export type SessionUser = { + id: string + stripe_customer_id?: string | null +} + +export type Session = { + user?: SessionUser | null +} | null + +export type GetSessionFn = () => Promise + +export type CreateBillingPortalSessionFn = (params: { + customer: string + return_url: string +}) => Promise<{ url: string }> + +export type PostBillingPortalParams = { + getSession: GetSessionFn + createBillingPortalSession: CreateBillingPortalSessionFn + logger: Logger + returnUrl: string +} + +export async function postBillingPortal(params: PostBillingPortalParams) { + const { getSession, createBillingPortalSession, logger, returnUrl } = params + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const stripeCustomerId = session.user.stripe_customer_id + if (!stripeCustomerId) { + return NextResponse.json( + { error: 'No Stripe customer ID found' }, + { status: 400 } + ) + } + + try { + const portalSession = await createBillingPortalSession({ + customer: stripeCustomerId, + return_url: returnUrl, + }) + + return NextResponse.json({ url: portalSession.url }) + } catch (error) { + logger.error( + { userId: session.user.id, error }, + 'Failed to create billing portal session' + ) + return NextResponse.json( + { error: 'Failed to create billing portal session' }, + { status: 500 } + ) + } +} diff --git a/web/src/app/api/user/billing-portal/route.ts b/web/src/app/api/user/billing-portal/route.ts index 1462adc5a8..491d78d22b 100644 --- a/web/src/app/api/user/billing-portal/route.ts +++ b/web/src/app/api/user/billing-portal/route.ts @@ -1,40 +1,18 @@ import { env } from '@codebuff/internal/env' import { stripeServer } from '@codebuff/internal/util/stripe' -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 stripeCustomerId = session.user.stripe_customer_id - if (!stripeCustomerId) { - return NextResponse.json( - { error: 'No Stripe customer ID found' }, - { status: 400 } - ) - } +import { postBillingPortal } from './_post' - try { - const portalSession = await stripeServer.billingPortal.sessions.create({ - customer: stripeCustomerId, - return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile`, - }) - - return NextResponse.json({ url: portalSession.url }) - } catch (error) { - logger.error( - { userId: session.user.id, error }, - 'Failed to create billing portal session' - ) - return NextResponse.json( - { error: 'Failed to create billing portal session' }, - { status: 500 } - ) - } +export async function POST() { + return postBillingPortal({ + getSession: () => getServerSession(authOptions), + createBillingPortalSession: (params) => + stripeServer.billingPortal.sessions.create(params), + logger, + returnUrl: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile`, + }) } From 5617bac0b0cbdeb69d2f603674d6563cbda8d57a Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 20:38:29 -0800 Subject: [PATCH 80/83] feat: use on-demand billing portal fetch for all Billing Portal buttons --- common/src/types/subscription.ts | 2 +- .../api/orgs/[orgId]/billing/portal/route.ts | 93 +++++++++++++++++++ .../api/orgs/[orgId]/billing/status/route.ts | 35 +++---- .../app/profile/components/usage-section.tsx | 4 +- .../credits/CreditManagementSection.tsx | 64 +++++++++++-- .../organization/billing-status.tsx | 54 ++++++++--- 6 files changed, 202 insertions(+), 50 deletions(-) create mode 100644 web/src/app/api/orgs/[orgId]/billing/portal/route.ts diff --git a/common/src/types/subscription.ts b/common/src/types/subscription.ts index d31ac7c5da..3da553d1c1 100644 --- a/common/src/types/subscription.ts +++ b/common/src/types/subscription.ts @@ -54,7 +54,7 @@ export interface ActiveSubscriptionResponse { subscription: SubscriptionInfo rateLimit: SubscriptionRateLimit limits: SubscriptionLimits - billingPortalUrl?: string + /** Whether user prefers to fallback to a-la-carte credits when subscription limits are reached */ fallbackToALaCarte: boolean } diff --git a/web/src/app/api/orgs/[orgId]/billing/portal/route.ts b/web/src/app/api/orgs/[orgId]/billing/portal/route.ts new file mode 100644 index 0000000000..c686b46de8 --- /dev/null +++ b/web/src/app/api/orgs/[orgId]/billing/portal/route.ts @@ -0,0 +1,93 @@ +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, and } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import type { NextRequest } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { ORG_BILLING_ENABLED } from '@/lib/billing-config' +import { logger } from '@/util/logger' + +interface RouteParams { + params: Promise<{ + orgId: string + }> +} + +export async function POST(req: NextRequest, { params }: RouteParams) { + if (!ORG_BILLING_ENABLED) { + return NextResponse.json( + { error: 'Organization billing is temporarily disabled' }, + { status: 503 } + ) + } + + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { orgId } = await params + + try { + // Check if user has access to this organization + const membership = await db + .select({ + role: schema.orgMember.role, + organization: schema.org, + }) + .from(schema.orgMember) + .innerJoin(schema.org, eq(schema.orgMember.org_id, schema.org.id)) + .where( + and( + eq(schema.orgMember.org_id, orgId), + eq(schema.orgMember.user_id, session.user.id), + ), + ) + .limit(1) + + if (membership.length === 0) { + return NextResponse.json( + { error: 'Organization not found' }, + { status: 404 }, + ) + } + + const { role, organization } = membership[0] + + // Check if user has permission to access billing + if (role !== 'owner' && role !== 'admin') { + return NextResponse.json( + { error: 'Insufficient permissions' }, + { status: 403 }, + ) + } + + if (!organization.stripe_customer_id) { + return NextResponse.json( + { error: 'No Stripe customer ID found for organization' }, + { status: 400 }, + ) + } + + const portalSession = await stripeServer.billingPortal.sessions.create({ + customer: organization.stripe_customer_id, + return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/orgs/${organization.slug}/settings`, + }) + + return NextResponse.json({ url: portalSession.url }) + } catch (error) { + logger.error( + { userId: session.user.id, orgId, error }, + 'Failed to create org billing portal session', + ) + return NextResponse.json( + { error: 'Failed to create billing portal session' }, + { status: 500 }, + ) + } +} diff --git a/web/src/app/api/orgs/[orgId]/billing/status/route.ts b/web/src/app/api/orgs/[orgId]/billing/status/route.ts index 6bf6509d76..057db56ea4 100644 --- a/web/src/app/api/orgs/[orgId]/billing/status/route.ts +++ b/web/src/app/api/orgs/[orgId]/billing/status/route.ts @@ -1,6 +1,5 @@ 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, and, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' @@ -74,32 +73,21 @@ export async function GET(req: NextRequest, { params }: RouteParams) { // Get subscription details if it exists let subscriptionDetails = null - let billingPortalUrl = null - if (organization.stripe_customer_id) { + if (organization.stripe_customer_id && organization.stripe_subscription_id) { try { - // Create billing portal session - const portalSession = await stripeServer.billingPortal.sessions.create({ - customer: organization.stripe_customer_id, - return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/orgs/${organization.slug}/settings`, - }) - billingPortalUrl = portalSession.url - - // Get subscription details if subscription exists - if (organization.stripe_subscription_id) { - const subscription = await stripeServer.subscriptions.retrieve( - organization.stripe_subscription_id, - ) - - subscriptionDetails = { - status: subscription.status, - current_period_start: subscription.current_period_start, - current_period_end: subscription.current_period_end, - cancel_at_period_end: subscription.cancel_at_period_end, - } + const subscription = await stripeServer.subscriptions.retrieve( + organization.stripe_subscription_id, + ) + + subscriptionDetails = { + status: subscription.status, + current_period_start: subscription.current_period_start, + current_period_end: subscription.current_period_end, + cancel_at_period_end: subscription.cancel_at_period_end, } } catch (error) { - logger.warn({ orgId, error }, 'Failed to get Stripe billing details') + logger.warn({ orgId, error }, 'Failed to get Stripe subscription details') } } @@ -112,7 +100,6 @@ export async function GET(req: NextRequest, { params }: RouteParams) { totalMonthlyCost: seatCount * pricePerSeat, hasActiveSubscription: !!organization.stripe_subscription_id, subscriptionDetails, - billingPortalUrl, organization: { id: organization.id, name: organization.name, diff --git a/web/src/app/profile/components/usage-section.tsx b/web/src/app/profile/components/usage-section.tsx index 9f62d01341..7929b915f7 100644 --- a/web/src/app/profile/components/usage-section.tsx +++ b/web/src/app/profile/components/usage-section.tsx @@ -16,7 +16,7 @@ import { toast } from '@/components/ui/use-toast' const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => { const { data: session } = useSession() - const email = encodeURIComponent(session?.user?.email || '') + const email = session?.user?.email || '' const queryClient = useQueryClient() const [showConfetti, setShowConfetti] = useState(false) const [purchasedAmount, setPurchasedAmount] = useState(0) @@ -84,7 +84,7 @@ const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => { isPurchasePending={buyCreditsMutation.isPending} showAutoTopup={true} isLoading={isLoading} - billingPortalUrl={`${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${email}`} + email={email} />
diff --git a/web/src/components/credits/CreditManagementSection.tsx b/web/src/components/credits/CreditManagementSection.tsx index 554d2c23f6..1b293ba618 100644 --- a/web/src/components/credits/CreditManagementSection.tsx +++ b/web/src/components/credits/CreditManagementSection.tsx @@ -1,8 +1,14 @@ +import { env } from '@codebuff/common/env' +import { useMutation } from '@tanstack/react-query' +import { ExternalLink, Loader2 } from 'lucide-react' + import { CreditManagementSkeleton } from './CreditManagementSkeleton' import { CreditPurchaseSection } from './CreditPurchaseSection' import { AutoTopupSettings } from '@/components/auto-topup/AutoTopupSettings' import { OrgAutoTopupSettings } from '@/components/auto-topup/OrgAutoTopupSettings' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' export interface CreditManagementSectionProps { onPurchase: (credits: number) => void @@ -13,7 +19,7 @@ export interface CreditManagementSectionProps { organizationId?: string isOrganization?: boolean // Keep for backward compatibility isLoading?: boolean - billingPortalUrl?: string + email?: string } export { CreditManagementSkeleton } @@ -27,11 +33,40 @@ export function CreditManagementSection({ organizationId, isOrganization = false, isLoading = false, - billingPortalUrl, + email, }: CreditManagementSectionProps) { // Determine if we're in organization context const isOrgContext = context === 'organization' || isOrganization + const fallbackPortalUrl = email + ? `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` + : env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL + + const billingPortalMutation = useMutation({ + mutationFn: async () => { + const res = await fetch('/api/user/billing-portal', { + method: 'POST', + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' })) + throw new Error(error.error || 'Failed to open billing portal') + } + const data = await res.json() + return data.url as string + }, + onSuccess: (url) => { + window.open(url, '_blank', 'noopener,noreferrer') + }, + onError: () => { + // Fall back to the prefilled email portal URL on error + window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer') + toast({ + title: 'Note', + description: 'Opened billing portal - you may need to sign in.', + }) + }, + }) + if (isLoading) { return } @@ -41,15 +76,24 @@ export function CreditManagementSection({

Buy Credits

- {billingPortalUrl && ( - billingPortalMutation.mutate()} + disabled={billingPortalMutation.isPending} + className="text-sm text-primary underline underline-offset-4 hover:text-primary/90 p-0 h-auto" > - Billing Portal → - + {billingPortalMutation.isPending ? ( + <> + + Opening... + + ) : ( + <>Billing Portal + )} + )}
{ + const res = await fetch(`/api/orgs/${organizationId}/billing/portal`, { + method: 'POST', + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' })) + throw new Error(error.error || 'Failed to open billing portal') + } + const data = await res.json() + return data.url as string + }, + onSuccess: (url) => { + window.open(url, '_blank', 'noopener,noreferrer') + }, + onError: (err: Error) => { + toast({ + title: 'Error', + description: err.message || 'Failed to open billing portal', + variant: 'destructive', + }) + }, + }) + const { data: billingStatus, isLoading, @@ -233,23 +258,26 @@ export function BillingStatus({
{/* Billing Portal Link */} - {billingStatus.billingPortalUrl && ( + {billingStatus.organization && (
)} From 810d33f166c2fa839533fcdf6f0a6aa714f26829 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 4 Feb 2026 20:43:06 -0800 Subject: [PATCH 81/83] refactor: consolidate billing portal buttons into single button in UsageSection title --- .../components/subscription-section.tsx | 85 ++++--------------- .../app/profile/components/usage-section.tsx | 58 ++++++++++++- .../credits/CreditManagementSection.tsx | 60 +------------ 3 files changed, 73 insertions(+), 130 deletions(-) diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx index 8c61829418..e748439c95 100644 --- a/web/src/app/profile/components/subscription-section.tsx +++ b/web/src/app/profile/components/subscription-section.tsx @@ -1,11 +1,9 @@ 'use client' import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' -import { env } from '@codebuff/common/env' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AlertTriangle, - ExternalLink, Loader2, } from 'lucide-react' import Link from 'next/link' @@ -51,37 +49,11 @@ function ProgressBar({ percentAvailable, label }: { percentAvailable: number; la ) } -function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; email: string }) { +function SubscriptionActive({ data }: { data: ActiveSubscriptionResponse }) { const { subscription, rateLimit, fallbackToALaCarte } = data const isCanceling = subscription.cancelAtPeriodEnd - const fallbackPortalUrl = `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` const queryClient = useQueryClient() - const billingPortalMutation = useMutation({ - mutationFn: async () => { - const res = await fetch('/api/user/billing-portal', { - method: 'POST', - }) - if (!res.ok) { - const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' })) - throw new Error(error.error || 'Failed to open billing portal') - } - const data = await res.json() - return data.url as string - }, - onSuccess: (url) => { - window.open(url, '_blank', 'noopener,noreferrer') - }, - onError: (err: Error) => { - // Fall back to the prefilled email portal URL on error - window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer') - toast({ - title: 'Note', - description: 'Opened billing portal - you may need to sign in.', - }) - }, - }) - const updatePreferenceMutation = useMutation({ mutationFn: async (newValue: boolean) => { const res = await fetch('/api/user/preferences', { @@ -122,42 +94,23 @@ function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; return ( -
- - 💪 - {SUBSCRIPTION_DISPLAY_NAME} - - ${subscription.tier}/mo + + 💪 + {SUBSCRIPTION_DISPLAY_NAME} + + ${subscription.tier}/mo + + {isCanceling && ( + + Canceling - {isCanceling && ( - - Canceling - - )} - {subscription.scheduledTier != null && ( - - Renewing at ${subscription.scheduledTier}/mo - - )} - - -
+ )} + {subscription.scheduledTier != null && ( + + Renewing at ${subscription.scheduledTier}/mo + + )} +
{rateLimit.limited && ( @@ -276,7 +229,5 @@ export function SubscriptionSection() { return } - const email = session?.user?.email || '' - - return + return } diff --git a/web/src/app/profile/components/usage-section.tsx b/web/src/app/profile/components/usage-section.tsx index 7929b915f7..01edf4383d 100644 --- a/web/src/app/profile/components/usage-section.tsx +++ b/web/src/app/profile/components/usage-section.tsx @@ -3,6 +3,7 @@ import { env } from '@codebuff/common/env' import { loadStripe } from '@stripe/stripe-js' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { ExternalLink, Loader2 } from 'lucide-react' import { useSession } from 'next-auth/react' import { useState } from 'react' @@ -10,13 +11,13 @@ import { SubscriptionSection } from './subscription-section' import { UsageDisplay } from './usage-display' import { CreditManagementSection } from '@/components/credits/CreditManagementSection' +import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { CreditConfetti } from '@/components/ui/credit-confetti' import { toast } from '@/components/ui/use-toast' const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => { const { data: session } = useSession() - const email = session?.user?.email || '' const queryClient = useQueryClient() const [showConfetti, setShowConfetti] = useState(false) const [purchasedAmount, setPurchasedAmount] = useState(0) @@ -84,7 +85,6 @@ const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => { isPurchasePending={buyCreditsMutation.isPending} showAutoTopup={true} isLoading={isLoading} - email={email} />
@@ -120,13 +120,63 @@ export function UsageSection() { const isUsageOrProfileLoading = isLoadingUsage || (status === 'authenticated' && !usageData) + const email = session?.user?.email || '' + const fallbackPortalUrl = email + ? `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` + : env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL + + const billingPortalMutation = useMutation({ + mutationFn: async () => { + const res = await fetch('/api/user/billing-portal', { + method: 'POST', + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' })) + throw new Error(error.error || 'Failed to open billing portal') + } + const data = await res.json() + return data.url as string + }, + onSuccess: (url) => { + window.open(url, '_blank', 'noopener,noreferrer') + }, + onError: () => { + // Fall back to the prefilled email portal URL on error + window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer') + toast({ + title: 'Note', + description: 'Opened billing portal - you may need to sign in.', + }) + }, + }) + return (
- {' '} -
+

Track your credit usage and purchase additional credits as needed.

+ {status === 'authenticated' && ( + + )}
{status === 'authenticated' && } {isUsageError && ( diff --git a/web/src/components/credits/CreditManagementSection.tsx b/web/src/components/credits/CreditManagementSection.tsx index 1b293ba618..98c64cdb31 100644 --- a/web/src/components/credits/CreditManagementSection.tsx +++ b/web/src/components/credits/CreditManagementSection.tsx @@ -1,14 +1,8 @@ -import { env } from '@codebuff/common/env' -import { useMutation } from '@tanstack/react-query' -import { ExternalLink, Loader2 } from 'lucide-react' - import { CreditManagementSkeleton } from './CreditManagementSkeleton' import { CreditPurchaseSection } from './CreditPurchaseSection' import { AutoTopupSettings } from '@/components/auto-topup/AutoTopupSettings' import { OrgAutoTopupSettings } from '@/components/auto-topup/OrgAutoTopupSettings' -import { Button } from '@/components/ui/button' -import { toast } from '@/components/ui/use-toast' export interface CreditManagementSectionProps { onPurchase: (credits: number) => void @@ -19,7 +13,6 @@ export interface CreditManagementSectionProps { organizationId?: string isOrganization?: boolean // Keep for backward compatibility isLoading?: boolean - email?: string } export { CreditManagementSkeleton } @@ -33,40 +26,10 @@ export function CreditManagementSection({ organizationId, isOrganization = false, isLoading = false, - email, }: CreditManagementSectionProps) { // Determine if we're in organization context const isOrgContext = context === 'organization' || isOrganization - const fallbackPortalUrl = email - ? `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` - : env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL - - const billingPortalMutation = useMutation({ - mutationFn: async () => { - const res = await fetch('/api/user/billing-portal', { - method: 'POST', - }) - if (!res.ok) { - const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' })) - throw new Error(error.error || 'Failed to open billing portal') - } - const data = await res.json() - return data.url as string - }, - onSuccess: (url) => { - window.open(url, '_blank', 'noopener,noreferrer') - }, - onError: () => { - // Fall back to the prefilled email portal URL on error - window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer') - toast({ - title: 'Note', - description: 'Opened billing portal - you may need to sign in.', - }) - }, - }) - if (isLoading) { return } @@ -74,28 +37,7 @@ export function CreditManagementSection({ return (
-
-

Buy Credits

- {/* Only show billing portal button for user context - orgs have their own button */} - {!isOrgContext && ( - - )} -
+

Buy Credits

Date: Wed, 4 Feb 2026 20:46:57 -0800 Subject: [PATCH 82/83] test: add unit tests for org billing portal endpoint using dependency injection --- .../__tests__/org-billing-portal.test.ts | 333 ++++++++++++++++++ .../api/orgs/[orgId]/billing/portal/_post.ts | 116 ++++++ .../api/orgs/[orgId]/billing/portal/route.ts | 102 ++---- 3 files changed, 484 insertions(+), 67 deletions(-) create mode 100644 web/src/app/api/orgs/[orgId]/billing/portal/__tests__/org-billing-portal.test.ts create mode 100644 web/src/app/api/orgs/[orgId]/billing/portal/_post.ts diff --git a/web/src/app/api/orgs/[orgId]/billing/portal/__tests__/org-billing-portal.test.ts b/web/src/app/api/orgs/[orgId]/billing/portal/__tests__/org-billing-portal.test.ts new file mode 100644 index 0000000000..5e6c3a3bc8 --- /dev/null +++ b/web/src/app/api/orgs/[orgId]/billing/portal/__tests__/org-billing-portal.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, mock, test } from 'bun:test' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +import { postOrgBillingPortal } from '../_post' + +import type { + CreateBillingPortalSessionFn, + GetMembershipFn, + GetSessionFn, + OrgMembership, + Session, +} from '../_post' + +const createMockLogger = (errorFn = mock(() => {})): Logger => ({ + error: errorFn, + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), +}) + +const createMockGetSession = (session: Session): GetSessionFn => + mock(() => Promise.resolve(session)) + +const createMockGetMembership = ( + result: OrgMembership | null +): GetMembershipFn => mock(() => Promise.resolve(result)) + +const createMockCreateBillingPortalSession = ( + result: { url: string } | Error = { url: 'https://billing.stripe.com/session/test_123' } +): CreateBillingPortalSessionFn => { + if (result instanceof Error) { + return mock(() => Promise.reject(result)) + } + return mock(() => Promise.resolve(result)) +} + +const defaultOrg = { + id: 'org-123', + name: 'Test Org', + slug: 'test-org', + stripe_customer_id: 'cus_org_123', +} + +const buildReturnUrl = (orgSlug: string) => `https://codebuff.com/orgs/${orgSlug}/settings` + +describe('/api/orgs/[orgId]/billing/portal POST endpoint', () => { + const orgId = 'org-123' + + describe('Feature flag', () => { + test('returns 503 when org billing is disabled', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: false, + buildReturnUrl, + }) + + expect(response.status).toBe(503) + const body = await response.json() + expect(body).toEqual({ error: 'Organization billing is temporarily disabled' }) + }) + }) + + describe('Authentication', () => { + test('returns 401 when session is null', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession(null), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user is null', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: null }), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user.id is missing', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: {} as any }), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + }) + + describe('Organization membership', () => { + test('returns 404 when user is not a member of the organization', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(404) + const body = await response.json() + expect(body).toEqual({ error: 'Organization not found' }) + }) + + test('calls getMembership with correct parameters', async () => { + const mockGetMembership = createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }) + + await postOrgBillingPortal({ + orgId: 'org-456', + getSession: createMockGetSession({ user: { id: 'user-789' } }), + getMembership: mockGetMembership, + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(mockGetMembership).toHaveBeenCalledTimes(1) + expect(mockGetMembership).toHaveBeenCalledWith({ + orgId: 'org-456', + userId: 'user-789', + }) + }) + }) + + describe('Permissions', () => { + test('returns 403 when user is a member (not owner or admin)', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'member', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body).toEqual({ error: 'Insufficient permissions' }) + }) + + test('allows owner to access billing portal', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(200) + }) + + test('allows admin to access billing portal', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'admin', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(200) + }) + }) + + describe('Stripe customer validation', () => { + test('returns 400 when organization has no stripe_customer_id', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: { ...defaultOrg, stripe_customer_id: null }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'No Stripe customer ID found for organization' }) + }) + }) + + describe('Successful portal session creation', () => { + test('returns 200 with portal URL on success', async () => { + const expectedUrl = 'https://billing.stripe.com/session/org_abc123' + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession({ url: expectedUrl }), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ url: expectedUrl }) + }) + + test('calls createBillingPortalSession with correct parameters', async () => { + const mockCreateSession = createMockCreateBillingPortalSession() + + await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'admin', + organization: { + ...defaultOrg, + slug: 'my-org', + stripe_customer_id: 'cus_my_org_456', + }, + }), + createBillingPortalSession: mockCreateSession, + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl: (slug) => `https://example.com/orgs/${slug}/billing`, + }) + + expect(mockCreateSession).toHaveBeenCalledTimes(1) + expect(mockCreateSession).toHaveBeenCalledWith({ + customer: 'cus_my_org_456', + return_url: 'https://example.com/orgs/my-org/billing', + }) + }) + }) + + describe('Error handling', () => { + test('returns 500 when Stripe API throws an error', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession( + new Error('Stripe API error') + ), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: 'Failed to create billing portal session' }) + }) + + test('logs error when Stripe API fails', async () => { + const mockLoggerError = mock(() => {}) + const testError = new Error('Stripe connection failed') + + await postOrgBillingPortal({ + orgId: 'org-error-test', + getSession: createMockGetSession({ user: { id: 'user-error' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(testError), + logger: createMockLogger(mockLoggerError), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(mockLoggerError).toHaveBeenCalledTimes(1) + expect(mockLoggerError).toHaveBeenCalledWith( + { userId: 'user-error', orgId: 'org-error-test', error: testError }, + 'Failed to create org billing portal session' + ) + }) + }) +}) diff --git a/web/src/app/api/orgs/[orgId]/billing/portal/_post.ts b/web/src/app/api/orgs/[orgId]/billing/portal/_post.ts new file mode 100644 index 0000000000..8a222b44d4 --- /dev/null +++ b/web/src/app/api/orgs/[orgId]/billing/portal/_post.ts @@ -0,0 +1,116 @@ +import { NextResponse } from 'next/server' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export type OrgMemberRole = 'owner' | 'admin' | 'member' + +export type Organization = { + id: string + name: string + slug: string + stripe_customer_id: string | null +} + +export type OrgMembership = { + role: OrgMemberRole + organization: Organization +} + +export type SessionUser = { + id: string +} + +export type Session = { + user?: SessionUser | null +} | null + +export type GetSessionFn = () => Promise + +export type GetMembershipFn = (params: { + orgId: string + userId: string +}) => Promise + +export type CreateBillingPortalSessionFn = (params: { + customer: string + return_url: string +}) => Promise<{ url: string }> + +export type PostOrgBillingPortalParams = { + orgId: string + getSession: GetSessionFn + getMembership: GetMembershipFn + createBillingPortalSession: CreateBillingPortalSessionFn + logger: Logger + orgBillingEnabled: boolean + buildReturnUrl: (orgSlug: string) => string +} + +export async function postOrgBillingPortal(params: PostOrgBillingPortalParams) { + const { + orgId, + getSession, + getMembership, + createBillingPortalSession, + logger, + orgBillingEnabled, + buildReturnUrl, + } = params + + if (!orgBillingEnabled) { + return NextResponse.json( + { error: 'Organization billing is temporarily disabled' }, + { status: 503 } + ) + } + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const membership = await getMembership({ orgId, userId }) + + if (!membership) { + return NextResponse.json( + { error: 'Organization not found' }, + { status: 404 } + ) + } + + const { role, organization } = membership + + if (role !== 'owner' && role !== 'admin') { + return NextResponse.json( + { error: 'Insufficient permissions' }, + { status: 403 } + ) + } + + if (!organization.stripe_customer_id) { + return NextResponse.json( + { error: 'No Stripe customer ID found for organization' }, + { status: 400 } + ) + } + + try { + const portalSession = await createBillingPortalSession({ + customer: organization.stripe_customer_id, + return_url: buildReturnUrl(organization.slug), + }) + + return NextResponse.json({ url: portalSession.url }) + } catch (error) { + logger.error( + { userId, orgId, error }, + 'Failed to create org billing portal session' + ) + return NextResponse.json( + { error: 'Failed to create billing portal session' }, + { status: 500 } + ) + } +} diff --git a/web/src/app/api/orgs/[orgId]/billing/portal/route.ts b/web/src/app/api/orgs/[orgId]/billing/portal/route.ts index c686b46de8..84fc75aba9 100644 --- a/web/src/app/api/orgs/[orgId]/billing/portal/route.ts +++ b/web/src/app/api/orgs/[orgId]/billing/portal/route.ts @@ -3,7 +3,6 @@ import * as schema from '@codebuff/internal/db/schema' import { env } from '@codebuff/internal/env' import { stripeServer } from '@codebuff/internal/util/stripe' import { eq, and } from 'drizzle-orm' -import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import type { NextRequest } from 'next/server' @@ -12,82 +11,51 @@ import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' import { ORG_BILLING_ENABLED } from '@/lib/billing-config' import { logger } from '@/util/logger' +import { postOrgBillingPortal } from './_post' + +import type { GetMembershipFn } from './_post' + interface RouteParams { params: Promise<{ orgId: string }> } -export async function POST(req: NextRequest, { params }: RouteParams) { - if (!ORG_BILLING_ENABLED) { - return NextResponse.json( - { error: 'Organization billing is temporarily disabled' }, - { status: 503 } +const getMembership: GetMembershipFn = async ({ orgId, userId }) => { + const membership = await db + .select({ + role: schema.orgMember.role, + organization: schema.org, + }) + .from(schema.orgMember) + .innerJoin(schema.org, eq(schema.orgMember.org_id, schema.org.id)) + .where( + and( + eq(schema.orgMember.org_id, orgId), + eq(schema.orgMember.user_id, userId), + ), ) - } + .limit(1) - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (membership.length === 0) { + return null } - const { orgId } = await params - - try { - // Check if user has access to this organization - const membership = await db - .select({ - role: schema.orgMember.role, - organization: schema.org, - }) - .from(schema.orgMember) - .innerJoin(schema.org, eq(schema.orgMember.org_id, schema.org.id)) - .where( - and( - eq(schema.orgMember.org_id, orgId), - eq(schema.orgMember.user_id, session.user.id), - ), - ) - .limit(1) - - if (membership.length === 0) { - return NextResponse.json( - { error: 'Organization not found' }, - { status: 404 }, - ) - } - - const { role, organization } = membership[0] - - // Check if user has permission to access billing - if (role !== 'owner' && role !== 'admin') { - return NextResponse.json( - { error: 'Insufficient permissions' }, - { status: 403 }, - ) - } - - if (!organization.stripe_customer_id) { - return NextResponse.json( - { error: 'No Stripe customer ID found for organization' }, - { status: 400 }, - ) - } + return membership[0] +} - const portalSession = await stripeServer.billingPortal.sessions.create({ - customer: organization.stripe_customer_id, - return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/orgs/${organization.slug}/settings`, - }) +export async function POST(req: NextRequest, { params }: RouteParams) { + const { orgId } = await params - return NextResponse.json({ url: portalSession.url }) - } catch (error) { - logger.error( - { userId: session.user.id, orgId, error }, - 'Failed to create org billing portal session', - ) - return NextResponse.json( - { error: 'Failed to create billing portal session' }, - { status: 500 }, - ) - } + return postOrgBillingPortal({ + orgId, + getSession: () => getServerSession(authOptions), + getMembership, + createBillingPortalSession: (params) => + stripeServer.billingPortal.sessions.create(params), + logger, + orgBillingEnabled: ORG_BILLING_ENABLED, + buildReturnUrl: (orgSlug) => + `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/orgs/${orgSlug}/settings`, + }) } From fe5324a7f5e4f626c783906e227297cea602f51b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 4 Feb 2026 22:04:46 -0800 Subject: [PATCH 83/83] Let users upgrade/downgrade from pricing page (linked from hitting limit in cli) --- common/src/types/subscription.ts | 2 +- web/src/app/api/user/billing-portal/_post.ts | 29 ++- web/src/app/api/user/billing-portal/route.ts | 24 +- web/src/app/api/user/subscription/route.ts | 1 + web/src/app/pricing/pricing-client.tsx | 226 ++++++++++++++----- 5 files changed, 216 insertions(+), 66 deletions(-) diff --git a/common/src/types/subscription.ts b/common/src/types/subscription.ts index 3da553d1c1..714bdf24ec 100644 --- a/common/src/types/subscription.ts +++ b/common/src/types/subscription.ts @@ -2,6 +2,7 @@ * Core subscription information for an active subscription. */ export interface SubscriptionInfo { + id: string status: string billingPeriodEnd: string cancelAtPeriodEnd: boolean @@ -64,4 +65,3 @@ export interface ActiveSubscriptionResponse { * Use `hasSubscription` to narrow the type. */ export type SubscriptionResponse = NoSubscriptionResponse | ActiveSubscriptionResponse - diff --git a/web/src/app/api/user/billing-portal/_post.ts b/web/src/app/api/user/billing-portal/_post.ts index a8c7bd81eb..3dfb7ebad8 100644 --- a/web/src/app/api/user/billing-portal/_post.ts +++ b/web/src/app/api/user/billing-portal/_post.ts @@ -13,20 +13,33 @@ export type Session = { export type GetSessionFn = () => Promise -export type CreateBillingPortalSessionFn = (params: { +export type BillingPortalFlowData = { + type: 'subscription_update' + subscription_update: { + subscription: string + } +} + +export type CreateBillingPortalSessionParams = { customer: string return_url: string -}) => Promise<{ url: string }> + flow_data?: BillingPortalFlowData +} + +export type CreateBillingPortalSessionFn = ( + params: CreateBillingPortalSessionParams +) => Promise<{ url: string }> export type PostBillingPortalParams = { getSession: GetSessionFn createBillingPortalSession: CreateBillingPortalSessionFn logger: Logger returnUrl: string + flowData?: BillingPortalFlowData } export async function postBillingPortal(params: PostBillingPortalParams) { - const { getSession, createBillingPortalSession, logger, returnUrl } = params + const { getSession, createBillingPortalSession, logger, returnUrl, flowData } = params const session = await getSession() if (!session?.user?.id) { @@ -42,10 +55,16 @@ export async function postBillingPortal(params: PostBillingPortalParams) { } try { - const portalSession = await createBillingPortalSession({ + const portalParams: CreateBillingPortalSessionParams = { customer: stripeCustomerId, return_url: returnUrl, - }) + } + + if (flowData) { + portalParams.flow_data = flowData + } + + const portalSession = await createBillingPortalSession(portalParams) return NextResponse.json({ url: portalSession.url }) } catch (error) { diff --git a/web/src/app/api/user/billing-portal/route.ts b/web/src/app/api/user/billing-portal/route.ts index 491d78d22b..69091e4152 100644 --- a/web/src/app/api/user/billing-portal/route.ts +++ b/web/src/app/api/user/billing-portal/route.ts @@ -2,17 +2,37 @@ import { env } from '@codebuff/internal/env' import { stripeServer } from '@codebuff/internal/util/stripe' import { getServerSession } from 'next-auth' +import type { NextRequest } from 'next/server' + import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' import { logger } from '@/util/logger' import { postBillingPortal } from './_post' -export async function POST() { +import type { BillingPortalFlowData } from './_post' + +export async function POST(req: NextRequest) { + // Parse optional subscriptionId from request body for deep-linking to subscription update + let flowData: BillingPortalFlowData | undefined + const body = await req.json().catch(() => null) + if (body?.subscriptionId) { + flowData = { + type: 'subscription_update', + subscription_update: { + subscription: body.subscriptionId, + }, + } + } + + // Determine return URL - use provided returnUrl or default to /pricing + const returnUrl = body?.returnUrl || `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pricing` + return postBillingPortal({ getSession: () => getServerSession(authOptions), createBillingPortalSession: (params) => stripeServer.billingPortal.sessions.create(params), logger, - returnUrl: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile`, + returnUrl, + flowData, }) } diff --git a/web/src/app/api/user/subscription/route.ts b/web/src/app/api/user/subscription/route.ts index 9d09b36723..ada3158e53 100644 --- a/web/src/app/api/user/subscription/route.ts +++ b/web/src/app/api/user/subscription/route.ts @@ -51,6 +51,7 @@ export async function GET() { hasSubscription: true, displayName: SUBSCRIPTION_DISPLAY_NAME, subscription: { + id: subscription.stripe_subscription_id, status: subscription.status, billingPeriodEnd: subscription.billing_period_end.toISOString(), cancelAtPeriodEnd: subscription.cancel_at_period_end, diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index e85039523d..67d17fe6b0 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -13,6 +13,7 @@ import { Gift, Shield, Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' import { useState } from 'react' +import { useQuery, useMutation } from '@tanstack/react-query' import { BlockColor } from '@/components/ui/decorative-blocks' import { Section } from '@/components/ui/section' @@ -21,29 +22,97 @@ import { FeatureSection } from '@/components/ui/landing/feature' import { toast } from '@/components/ui/use-toast' import { cn } from '@/lib/utils' +import type { SubscriptionResponse } from '@codebuff/common/types/subscription' + const USAGE_MULTIPLIER: Record = { 100: '1×', 200: '3×', 500: '8×', } +type ButtonAction = 'subscribe' | 'current' | 'upgrade' | 'downgrade' + +function getButtonAction(tierPrice: number, currentTier: number | null): ButtonAction { + if (currentTier === null) return 'subscribe' + if (tierPrice === currentTier) return 'current' + if (tierPrice > currentTier) return 'upgrade' + return 'downgrade' +} + +function getButtonLabel(action: ButtonAction): string { + switch (action) { + case 'current': + return 'Current Plan' + case 'upgrade': + return 'Upgrade' + case 'downgrade': + return 'Downgrade' + default: + return 'Subscribe' + } +} + function SubscribeButton({ className, tier, + currentTier, + subscriptionId, + isHighlighted, }: { className?: string - tier?: number + tier: number + currentTier: number | null + subscriptionId: string | null + isHighlighted: boolean }) { const { status } = useSession() const router = useRouter() const [isLoading, setIsLoading] = useState(false) - const handleSubscribe = async () => { + const action = getButtonAction(tier, currentTier) + const isCurrent = action === 'current' + + // Mutation to open billing portal for upgrades/downgrades + const billingPortalMutation = useMutation({ + mutationFn: async () => { + const res = await fetch('/api/user/billing-portal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subscriptionId }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error || 'Failed to open billing portal') + } + return res.json() + }, + onSuccess: (data: { url: string }) => { + window.location.href = data.url + }, + onError: (err: Error) => { + toast({ + title: 'Error', + description: err.message, + variant: 'destructive', + }) + }, + }) + + const handleClick = async () => { if (status !== 'authenticated') { router.push('/login?callbackUrl=/pricing') return } + if (isCurrent) return + + // If user has a subscription, redirect to billing portal for confirmation + if (currentTier !== null && subscriptionId) { + billingPortalMutation.mutate() + return + } + + // Otherwise, create new subscription setIsLoading(true) try { const res = await fetch('/api/stripe/create-subscription', { @@ -72,26 +141,116 @@ function SubscribeButton({ } } + const isLoadingState = isLoading || billingPortalMutation.isPending + return ( ) } +function PricingCardsGrid() { + const { status } = useSession() + + const { data: subscriptionData } = 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', + staleTime: 30_000, + }) + + const currentTier = subscriptionData?.hasSubscription + ? subscriptionData.subscription.tier + : null + + const subscriptionId = subscriptionData?.hasSubscription + ? subscriptionData.subscription.id + : null + + return ( + +
+ {Object.entries(SUBSCRIPTION_TIERS).map(([key, tier]) => { + const price = Number(key) as SubscriptionTierPrice + const isCurrentPlan = currentTier === price + const isHighlighted = currentTier === null ? price === 200 : isCurrentPlan + + return ( +
+ {isCurrentPlan && ( +
+ + Your Plan + +
+ )} +
+ + ${tier.monthlyPrice} + + + /mo + +
+ +

+ {USAGE_MULTIPLIER[price]} usage +

+ + +
+ ) + })} +
+
+ ) +} + function StrongHeroSection() { return (
- {/* Pricing cards grid with decorative blocks */} - -
- {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 -

- - -
- ) - })} -
-
+ - - ) }