diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 1d2f98cbdc..93e62acb0d 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -145,7 +145,7 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { {!activeSubscription && renewalDate && ( <> - · Renews: + · Cycle: {renewalDate} )} diff --git a/common/src/constants/limits.ts b/common/src/constants/limits.ts index 515eaa4adc..14b419ed40 100644 --- a/common/src/constants/limits.ts +++ b/common/src/constants/limits.ts @@ -5,8 +5,12 @@ export const MAX_DATE = new Date(86399999999999) export const BILLING_PERIOD_DAYS = 30 export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 // 30 days export const SESSION_TIME_WINDOW_MS = 30 * 60 * 1000 // 30 minutes - used for matching sessions created around fingerprint creation -// Default number of free credits granted per cycle -export const DEFAULT_FREE_CREDITS_GRANT = 500 +// New Codebuff accounts receive a one-time free credit grant on signup. +export const SIGNUP_FREE_CREDITS_GRANT = 500 + +// New accounts do not receive monthly free credits; grandfathered monthly grants +// are based on previous expiring free grants instead of this default. +export const DEFAULT_FREE_CREDITS_GRANT = 0 // Credit pricing configuration export const CREDIT_PRICING = { diff --git a/packages/billing/src/__tests__/grant-credits.test.ts b/packages/billing/src/__tests__/grant-credits.test.ts index 6de3ecaa66..863135f551 100644 --- a/packages/billing/src/__tests__/grant-credits.test.ts +++ b/packages/billing/src/__tests__/grant-credits.test.ts @@ -4,7 +4,6 @@ import { } from '@codebuff/common/testing/mock-modules' import { afterEach, describe, expect, it } from 'bun:test' - import type { Logger } from '@codebuff/common/types/contracts/logger' const logger: Logger = { @@ -17,10 +16,12 @@ const logger: Logger = { const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days from now const _pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30 days ago -const createTxMock = (user: { - next_quota_reset: Date | null - auto_topup_enabled: boolean | null -} | null) => ({ +const createTxMock = ( + user: { + next_quota_reset: Date | null + auto_topup_enabled: boolean | null + } | null, +) => ({ query: { user: { findFirst: async () => user, @@ -47,7 +48,8 @@ const createTxMock = (user: { limit: () => [], }), // Make this thenable for the .where().then() pattern used in grant-credits.ts - then: (resolve: any, reject?: any) => Promise.resolve([]).then(resolve, reject), + then: (resolve: any, reject?: any) => + Promise.resolve([]).then(resolve, reject), } }, }), @@ -76,10 +78,12 @@ const createDbMock = (options: { } } -const createTransactionMock = (user: { - next_quota_reset: Date | null - auto_topup_enabled: boolean | null -} | null) => ({ +const createTransactionMock = ( + user: { + next_quota_reset: Date | null + auto_topup_enabled: boolean | null + } | null, +) => ({ withAdvisoryLockTransaction: async ({ callback, }: { @@ -92,6 +96,61 @@ describe('grant-credits', () => { clearMockedModules() }) + describe('grantSignupCredits', () => { + it('grants 500 non-expiring free credits with a deterministic operation id', async () => { + const grantCalls: any[] = [] + + await mockModule('@codebuff/internal/db/transaction', () => ({ + withAdvisoryLockTransaction: async ({ + callback, + }: { + callback: (tx: any) => Promise + }) => ({ + result: await callback({ + select: () => ({ + from: () => ({ + where: () => ({ + then: (resolve: any, reject?: any) => + Promise.resolve([]).then(resolve, reject), + }), + }), + }), + insert: () => ({ + values: (values: any) => { + grantCalls.push(values) + return { + onConflictDoNothing: () => ({ + returning: () => + Promise.resolve([{ id: values.operation_id }]), + }), + } + }, + }), + }), + lockWaitMs: 0, + }), + })) + + const { grantSignupCredits } = await import('../grant-credits') + + await grantSignupCredits({ + userId: 'new-user', + logger, + }) + + expect(grantCalls).toHaveLength(1) + expect(grantCalls[0]).toMatchObject({ + operation_id: 'signup-free-new-user', + user_id: 'new-user', + principal: 500, + balance: 500, + type: 'free', + description: 'Signup free credits', + expires_at: null, + }) + }) + }) + describe('calculateTotalLegacyReferralBonus', () => { const createDbMockForReferralQuery = (totalCredits: string | null) => ({ select: () => ({ @@ -114,7 +173,8 @@ describe('grant-credits', () => { default: createDbMockForReferralQuery('500'), })) - const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + const { calculateTotalLegacyReferralBonus } = + await import('../grant-credits') const result = await calculateTotalLegacyReferralBonus({ userId: 'user-123', @@ -129,7 +189,8 @@ describe('grant-credits', () => { default: createDbMockForReferralQuery('500'), })) - const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + const { calculateTotalLegacyReferralBonus } = + await import('../grant-credits') const result = await calculateTotalLegacyReferralBonus({ userId: 'referred-user', @@ -144,7 +205,8 @@ describe('grant-credits', () => { default: createDbMockForReferralQuery('750'), })) - const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + const { calculateTotalLegacyReferralBonus } = + await import('../grant-credits') const result = await calculateTotalLegacyReferralBonus({ userId: 'user-with-both', @@ -160,7 +222,8 @@ describe('grant-credits', () => { default: createDbMockForReferralQuery('0'), })) - const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + const { calculateTotalLegacyReferralBonus } = + await import('../grant-credits') const result = await calculateTotalLegacyReferralBonus({ userId: 'user-with-only-new-referrals', @@ -175,7 +238,8 @@ describe('grant-credits', () => { default: createDbMockForReferralQuery('0'), })) - const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + const { calculateTotalLegacyReferralBonus } = + await import('../grant-credits') const result = await calculateTotalLegacyReferralBonus({ userId: 'user-with-no-referrals', @@ -190,7 +254,8 @@ describe('grant-credits', () => { default: createDbMockForReferralQuery(null), })) - const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + const { calculateTotalLegacyReferralBonus } = + await import('../grant-credits') const result = await calculateTotalLegacyReferralBonus({ userId: 'user-null-result', @@ -211,7 +276,8 @@ describe('grant-credits', () => { }, })) - const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + const { calculateTotalLegacyReferralBonus } = + await import('../grant-credits') const result = await calculateTotalLegacyReferralBonus({ userId: 'user-empty-result', @@ -235,7 +301,8 @@ describe('grant-credits', () => { }, } - const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + const { calculateTotalLegacyReferralBonus } = + await import('../grant-credits') const result = await calculateTotalLegacyReferralBonus({ userId: 'user-db-error', @@ -255,7 +322,8 @@ describe('grant-credits', () => { default: createDbMockForReferralQuery('999999'), })) - const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + const { calculateTotalLegacyReferralBonus } = + await import('../grant-credits') const result = await calculateTotalLegacyReferralBonus({ userId: 'power-referrer', @@ -281,7 +349,8 @@ describe('grant-credits', () => { ) // Need to re-import after mocking - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const { triggerMonthlyResetAndGrant: fn } = + await import('../grant-credits') const result = await fn({ userId: 'user-123', @@ -304,7 +373,8 @@ describe('grant-credits', () => { createTransactionMock(user), ) - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const { triggerMonthlyResetAndGrant: fn } = + await import('../grant-credits') const result = await fn({ userId: 'user-123', @@ -326,7 +396,8 @@ describe('grant-credits', () => { createTransactionMock(user), ) - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const { triggerMonthlyResetAndGrant: fn } = + await import('../grant-credits') const result = await fn({ userId: 'user-123', @@ -344,7 +415,8 @@ describe('grant-credits', () => { createTransactionMock(null), ) - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const { triggerMonthlyResetAndGrant: fn } = + await import('../grant-credits') await expect( fn({ @@ -368,7 +440,8 @@ describe('grant-credits', () => { createTransactionMock(user), ) - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const { triggerMonthlyResetAndGrant: fn } = + await import('../grant-credits') const result = await fn({ userId: 'user-123', @@ -383,10 +456,13 @@ describe('grant-credits', () => { // Track grant operations to verify type and expiration let grantCalls: any[] = [] - const createTxMockWithGrants = (user: { - next_quota_reset: Date | null - auto_topup_enabled: boolean | null - } | null, legacyReferralBonus: number) => { + const createTxMockWithGrants = ( + user: { + next_quota_reset: Date | null + auto_topup_enabled: boolean | null + } | null, + legacyReferralBonus: number, + ) => { grantCalls = [] return { query: { @@ -419,7 +495,8 @@ describe('grant-credits', () => { limit: () => [], }), // Make this thenable for the .where().then() pattern used in grant-credits.ts - then: (resolve: any, reject?: any) => Promise.resolve(result).then(resolve, reject), + then: (resolve: any, reject?: any) => + Promise.resolve(result).then(resolve, reject), } }, }), @@ -428,15 +505,23 @@ describe('grant-credits', () => { } } - const createTransactionMockWithGrants = (user: { - next_quota_reset: Date | null - auto_topup_enabled: boolean | null - } | null, legacyReferralBonus: number) => ({ + const createTransactionMockWithGrants = ( + user: { + next_quota_reset: Date | null + auto_topup_enabled: boolean | null + } | null, + legacyReferralBonus: number, + ) => ({ withAdvisoryLockTransaction: async ({ callback, }: { callback: (tx: any) => Promise - }) => ({ result: await callback(createTxMockWithGrants(user, legacyReferralBonus)), lockWaitMs: 0 }), + }) => ({ + result: await callback( + createTxMockWithGrants(user, legacyReferralBonus), + ), + lockWaitMs: 0, + }), }) it('should grant referral_legacy type when user has legacy referrals and quota needs reset', async () => { @@ -447,9 +532,6 @@ describe('grant-credits', () => { } const legacyReferralBonus = 500 - // Mock db for both getPreviousFreeGrantAmount and calculateTotalLegacyReferralBonus - // getPreviousFreeGrantAmount uses: db.select().from().where().orderBy().limit() - // calculateTotalLegacyReferralBonus uses: db.select().from().where() (returns Promise) let queryCount = 0 await mockModule('@codebuff/internal/db', () => ({ default: { @@ -457,17 +539,16 @@ describe('grant-credits', () => { from: () => ({ where: () => { queryCount++ - // First query is getPreviousFreeGrantAmount (needs orderBy chain) - // Second query is calculateTotalLegacyReferralBonus (returns Promise directly) if (queryCount === 1) { return { orderBy: () => ({ - limit: () => [], // No previous free grant, use default + limit: () => [], // No grandfathered monthly free grant. }), } } - // Return referral bonus for calculateTotalLegacyReferralBonus - return Promise.resolve([{ totalCredits: String(legacyReferralBonus) }]) + return Promise.resolve([ + { totalCredits: String(legacyReferralBonus) }, + ]) }, }), }), @@ -477,23 +558,28 @@ describe('grant-credits', () => { createTransactionMockWithGrants(user, legacyReferralBonus), ) - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const { triggerMonthlyResetAndGrant: fn } = + await import('../grant-credits') await fn({ userId: 'user-with-legacy-referrals', logger, }) - // Should have made 2 grant calls (free + referral_legacy) - expect(grantCalls.length).toBe(2) + // Should only grant the legacy recurring referral bonus, not monthly free credits. + expect(grantCalls.length).toBe(1) // Find the referral grant - const referralGrant = grantCalls.find((call) => call.type === 'referral_legacy') + const referralGrant = grantCalls.find( + (call) => call.type === 'referral_legacy', + ) expect(referralGrant).toBeDefined() expect(referralGrant.principal).toBe(legacyReferralBonus) expect(referralGrant.balance).toBe(legacyReferralBonus) expect(referralGrant.expires_at).toBeDefined() // Legacy referrals expire at next reset - expect(referralGrant.description).toBe('Monthly referral bonus (legacy)') + expect(referralGrant.description).toBe( + 'Monthly referral bonus (legacy)', + ) }) it('should NOT grant referral credits when user has no legacy referrals', async () => { @@ -504,7 +590,6 @@ describe('grant-credits', () => { } const legacyReferralBonus = 0 // No legacy referrals - // Mock db for both getPreviousFreeGrantAmount and calculateTotalLegacyReferralBonus let queryCount = 0 await mockModule('@codebuff/internal/db', () => ({ default: { @@ -512,17 +597,16 @@ describe('grant-credits', () => { from: () => ({ where: () => { queryCount++ - // First query is getPreviousFreeGrantAmount (needs orderBy chain) - // Second query is calculateTotalLegacyReferralBonus (returns Promise directly) if (queryCount === 1) { return { orderBy: () => ({ - limit: () => [], // No previous free grant, use default + limit: () => [], // No grandfathered monthly free grant. }), } } - // Return 0 referral bonus for calculateTotalLegacyReferralBonus - return Promise.resolve([{ totalCredits: String(legacyReferralBonus) }]) + return Promise.resolve([ + { totalCredits: String(legacyReferralBonus) }, + ]) }, }), }), @@ -532,18 +616,66 @@ describe('grant-credits', () => { createTransactionMockWithGrants(user, legacyReferralBonus), ) - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const { triggerMonthlyResetAndGrant: fn } = + await import('../grant-credits') await fn({ userId: 'user-without-legacy-referrals', logger, }) - // Should only have made 1 grant call (free only, no referral) - expect(grantCalls.length).toBe(1) + // No legacy referral bonus means the reset only advances the cycle. + expect(grantCalls.length).toBe(0) + }) + + it('should grant monthly free credits for grandfathered users', async () => { + const pastResetDate = new Date(Date.now() - 24 * 60 * 60 * 1000) + const user = { + next_quota_reset: pastResetDate, + auto_topup_enabled: false, + } + const grandfatheredFreeCredits = 500 + + let queryCount = 0 + await mockModule('@codebuff/internal/db', () => ({ + default: { + select: () => ({ + from: () => ({ + where: () => { + queryCount++ + if (queryCount === 1) { + return { + orderBy: () => ({ + limit: () => [{ principal: grandfatheredFreeCredits }], + }), + } + } + return Promise.resolve([{ totalCredits: '0' }]) + }, + }), + }), + }, + })) + await mockModule('@codebuff/internal/db/transaction', () => + createTransactionMockWithGrants(user, 0), + ) + + const { triggerMonthlyResetAndGrant: fn } = + await import('../grant-credits') - // The only grant should be 'free' type - expect(grantCalls[0].type).toBe('free') + await fn({ + userId: 'grandfathered-user', + logger, + }) + + expect(grantCalls.length).toBe(1) + expect(grantCalls[0]).toMatchObject({ + type: 'free', + principal: grandfatheredFreeCredits, + balance: grandfatheredFreeCredits, + description: 'Monthly free credits (grandfathered)', + }) + expect(grantCalls[0].expires_at).toBeDefined() }) }) }) diff --git a/packages/billing/src/billing.knowledge.md b/packages/billing/src/billing.knowledge.md index a0dfc34afc..ee156c0a52 100644 --- a/packages/billing/src/billing.knowledge.md +++ b/packages/billing/src/billing.knowledge.md @@ -47,7 +47,7 @@ Only last grant can go negative. No maximum debt limit enforced in code. ## Grant Types and Priorities -- free (20): Monthly free credits +- free (20): Signup free credits and grandfathered monthly free credits - referral (30): Referral bonus credits (one-time bonuses, consumed before renewable ad credits) - ad (40): Ad impression credits (renewable source, consumed after referral) - admin (60): Admin-granted credits diff --git a/packages/billing/src/grant-credits.knowledge.md b/packages/billing/src/grant-credits.knowledge.md index 0cd764183e..bb67e1d8f8 100644 --- a/packages/billing/src/grant-credits.knowledge.md +++ b/packages/billing/src/grant-credits.knowledge.md @@ -14,7 +14,7 @@ Where: **Time sources**: -- Monthly grants: Use next reset date (ensures one grant per cycle) +- Grandfathered monthly free grants and legacy monthly referral grants: Use next reset date (ensures one grant per cycle) - Auto-topup: Use current time (allows multiple top-ups per day) **Idempotency**: diff --git a/packages/billing/src/grant-credits.ts b/packages/billing/src/grant-credits.ts index bb16b51676..cdfc28a026 100644 --- a/packages/billing/src/grant-credits.ts +++ b/packages/billing/src/grant-credits.ts @@ -1,14 +1,14 @@ import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { GRANT_PRIORITIES } from '@codebuff/common/constants/grant-priorities' -import { DEFAULT_FREE_CREDITS_GRANT } from '@codebuff/common/old-constants' +import { SIGNUP_FREE_CREDITS_GRANT } from '@codebuff/common/constants/limits' import { getNextQuotaReset } from '@codebuff/common/util/dates' import { withRetry } from '@codebuff/common/util/promise' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' import { withAdvisoryLockTransaction } from '@codebuff/internal/db/transaction' import { logSyncFailure } from '@codebuff/internal/util/sync-failure' -import { and, desc, eq, gt, isNull, lte, or, sql } from 'drizzle-orm' +import { and, desc, eq, gt, isNull, like, lte, or, sql } from 'drizzle-orm' import { generateOperationIdTimestamp } from './utils' @@ -23,15 +23,10 @@ type DbTransaction = Parameters[0] extends ( : never /** - * Finds the amount of the most recent expired 'free' grant for a user. - * Finds the amount of the most recent expired 'free' grant for a user, - * excluding migration grants (operation_id starting with 'migration-'). - * If there is a previous grant, caps the amount at 2000 credits. - * If no expired 'free' grant is found, returns the default free limit. - * @param userId The ID of the user. - * @returns The amount of the last expired free grant (capped at 2000) or the default. + * Finds the grandfathered monthly free credit amount for a user. + * Only users with a previous expiring free grant continue to receive monthly free credits. */ -export async function getPreviousFreeGrantAmount(params: { +export async function getGrandfatheredFreeGrantAmount(params: { userId: string logger: Logger }): Promise { @@ -47,27 +42,27 @@ export async function getPreviousFreeGrantAmount(params: { and( eq(schema.creditLedger.user_id, userId), eq(schema.creditLedger.type, 'free'), - lte(schema.creditLedger.expires_at, now), // Grant has expired + like(schema.creditLedger.operation_id, `free-${userId}-%`), + lte(schema.creditLedger.expires_at, now), ), ) - .orderBy(desc(schema.creditLedger.expires_at)) // Most recent expiry first + .orderBy(desc(schema.creditLedger.expires_at)) .limit(1) - if (lastExpiredFreeGrant.length > 0) { - // TODO: remove this once it's past May 22nd, after all users have been migrated over - const cappedAmount = Math.min(lastExpiredFreeGrant[0].principal, 2000) - logger.debug( - { userId, amount: lastExpiredFreeGrant[0].principal }, - 'Found previous expired free grant amount.', - ) - return cappedAmount - } else { + if (lastExpiredFreeGrant.length === 0) { logger.debug( - { userId, defaultAmount: DEFAULT_FREE_CREDITS_GRANT }, - 'No previous expired free grant found. Using default.', + { userId }, + 'No previous expired free grant found. Skipping monthly free grant.', ) - return DEFAULT_FREE_CREDITS_GRANT // Default if no previous grant found + return 0 } + + const cappedAmount = Math.min(lastExpiredFreeGrant[0].principal, 2000) + logger.debug( + { userId, amount: lastExpiredFreeGrant[0].principal, cappedAmount }, + 'Found previous expired free grant amount.', + ) + return cappedAmount } /** @@ -100,7 +95,10 @@ export async function calculateTotalLegacyReferralBonus(params: { ) const totalBonus = parseInt(result[0]?.totalCredits ?? '0') - logger.debug({ userId, totalBonus }, 'Calculated total legacy referral bonus.') + logger.debug( + { userId, totalBonus }, + 'Calculated total legacy referral bonus.', + ) return totalBonus } catch (error) { logger.error( @@ -328,6 +326,23 @@ export async function processAndGrantCredit(params: { } } +export async function grantSignupCredits(params: { + userId: string + logger: Logger +}): Promise { + const { userId, logger } = params + + await processAndGrantCredit({ + userId, + amount: SIGNUP_FREE_CREDITS_GRANT, + type: 'free', + description: 'Signup free credits', + expiresAt: null, + operationId: `signup-free-${userId}`, + logger, + }) +} + /** * Revokes credits from a specific grant by operation ID. * This sets the balance to 0 and updates the description to indicate a refund. @@ -356,9 +371,7 @@ export async function revokeGrantByOperationId(params: { } // Determine lock key based on whether this is a user or org grant - const lockKey = grant.org_id - ? `org:${grant.org_id}` - : `user:${grant.user_id}` + const lockKey = grant.org_id ? `org:${grant.org_id}` : `user:${grant.user_id}` const { result } = await withAdvisoryLockTransaction({ callback: async (tx) => { @@ -414,10 +427,9 @@ export async function revokeGrantByOperationId(params: { } /** - * Checks if a user's quota needs to be reset, and if so: - * 1. Calculates their new monthly grant amount - * 2. Issues the grant with the appropriate expiry - * 3. Updates their next_quota_reset date + * Checks if a user's quota cycle needs to advance, and if so: + * 1. Issues grandfathered monthly free credits and legacy recurring referral credits + * 2. Updates their next_quota_reset date * All of this is done in a single transaction with advisory lock to ensure consistency. * * @param userId The ID of the user @@ -462,9 +474,8 @@ export async function triggerMonthlyResetAndGrant(params: { // Calculate new reset date const newResetDate = getNextQuotaReset(currentResetDate) - // Calculate grant amounts separately const [freeGrantAmount, referralBonus] = await Promise.all([ - getPreviousFreeGrantAmount(params), + getGrandfatheredFreeGrantAmount(params), calculateTotalLegacyReferralBonus(params), ]) @@ -479,16 +490,17 @@ export async function triggerMonthlyResetAndGrant(params: { .set({ next_quota_reset: newResetDate }) .where(eq(schema.user.id, userId)) - // Always grant free credits - use executeGrantCreditOperation with tx since we already hold the lock - await executeGrantCreditOperation({ - ...params, - amount: freeGrantAmount, - type: 'free', - description: 'Monthly free credits', - expiresAt: newResetDate, // Free credits expire at next reset - operationId: freeOperationId, - tx, - }) + if (freeGrantAmount > 0) { + await executeGrantCreditOperation({ + ...params, + amount: freeGrantAmount, + type: 'free', + description: 'Monthly free credits (grandfathered)', + expiresAt: newResetDate, + operationId: freeOperationId, + tx, + }) + } // Only grant legacy referral credits if there are any (for grandfathered users) if (referralBonus > 0) { @@ -513,7 +525,7 @@ export async function triggerMonthlyResetAndGrant(params: { newResetDate, previousResetDate: currentResetDate, }, - 'Processed monthly credit grants and reset', + 'Processed credit quota reset', ) return { quotaResetDate: newResetDate, autoTopupEnabled } diff --git a/web/src/app/api/auth/[...nextauth]/auth-options.ts b/web/src/app/api/auth/[...nextauth]/auth-options.ts index 9a7e8958bf..6da111f14d 100644 --- a/web/src/app/api/auth/[...nextauth]/auth-options.ts +++ b/web/src/app/api/auth/[...nextauth]/auth-options.ts @@ -1,4 +1,5 @@ import { DrizzleAdapter } from '@auth/drizzle-adapter' +import { grantSignupCredits } from '@codebuff/billing' import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { SESSION_MAX_AGE_SECONDS } from '@codebuff/common/old-constants' @@ -157,7 +158,17 @@ export const authOptions: NextAuthOptions = { userId: userData.id, }) - // New codebuff accounts do not receive a signup bonus. + try { + await grantSignupCredits({ + userId: userData.id, + logger, + }) + } catch (error) { + logger.error( + { userId: userData.id, error }, + 'Failed to grant signup credits.', + ) + } await loops.sendSignupEventToLoops({ ...userData, 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 6f98c96a39..d2c84fb6b9 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 @@ -7,7 +7,7 @@ import { FREEBUFF_GLM_MODEL_ID, isFreebuffDeploymentHours, } from '@codebuff/common/constants/freebuff-models' -import { formatQuotaResetCountdown, postChatCompletions } from '../_post' +import { postChatCompletions } from '../_post' import { checkFreeModeRateLimit, resetFreeModeRateLimits, @@ -517,8 +517,8 @@ describe('/api/v1/chat/completions POST endpoint', () => { expect(response.status).toBe(402) const body = await response.json() - const expectedResetCountdown = formatQuotaResetCountdown(nextQuotaReset) - expect(body.message).toContain(expectedResetCountdown) + expect(body.message).toContain('Out of credits. Please add credits at') + expect(body.message).toContain('/usage.') expect(body.message).not.toContain(nextQuotaReset) }) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index c8df3a7ae5..838b65c67e 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -594,10 +594,9 @@ export async function postChatCompletions(params: { }, logger, }) - const resetCountdown = formatQuotaResetCountdown(nextQuotaReset) return NextResponse.json( { - message: `Out of credits. Please add credits at ${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage. Your free credits reset ${resetCountdown}.`, + message: `Out of credits. Please add credits at ${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage.`, }, { status: 402 }, ) diff --git a/web/src/app/pricing/page.tsx b/web/src/app/pricing/page.tsx index 4523bc154b..f0ea7394c5 100644 --- a/web/src/app/pricing/page.tsx +++ b/web/src/app/pricing/page.tsx @@ -1,10 +1,10 @@ import { env } from '@codebuff/common/env' +import { SIGNUP_FREE_CREDITS_GRANT } from '@codebuff/common/constants/limits' import PricingClient from './pricing-client' import type { Metadata } from 'next' - export async function generateMetadata(): Promise { const canonicalUrl = `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pricing` @@ -52,8 +52,8 @@ function ProductJsonLd() { additionalProperty: [ { '@type': 'PropertyValue', - name: 'Free Monthly Credits', - value: '500', + name: 'Free Signup Credits', + value: String(SIGNUP_FREE_CREDITS_GRANT), }, { '@type': 'PropertyValue', @@ -67,7 +67,7 @@ function ProductJsonLd() { name: 'Free Tier', price: '0', priceCurrency: 'USD', - description: '500 free credits monthly for individual developers', + description: `${SIGNUP_FREE_CREDITS_GRANT} free credits on signup for individual developers`, availability: 'https://schema.org/InStock', priceValidUntil: '2026-12-31', url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pricing`, diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index 80cb0589d1..faf09e32a9 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -1,6 +1,6 @@ 'use client' -import { DEFAULT_FREE_CREDITS_GRANT } from '@codebuff/common/old-constants' +import { SIGNUP_FREE_CREDITS_GRANT } from '@codebuff/common/constants/limits' import { SUBSCRIPTION_TIERS, SUBSCRIPTION_DISPLAY_NAME, @@ -420,9 +420,7 @@ function CreditVisual() {
- - {DEFAULT_FREE_CREDITS_GRANT} credits is typically enough for - {' '} + {SIGNUP_FREE_CREDITS_GRANT} credits is typically enough for{' '} a few hours of coding on a new project
@@ -533,12 +531,12 @@ export default function PricingClient() { Usage-Based Pricing} - description="After free credits, 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." + description="After your signup credits, 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]} textColor="text-white" tagline="PAY AS YOU GO" - highlightText="500 free credits monthly" + highlightText={`${SIGNUP_FREE_CREDITS_GRANT} free credits on signup`} illustration={} learnMoreText={status === 'authenticated' ? 'My Usage' : 'Get Started'} learnMoreLink={status === 'authenticated' ? '/usage' : '/login'} diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index 6358982dba..83a932882f 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -50,8 +50,8 @@ const grantTypeInfo: Record< text: 'text-blue-600 dark:text-blue-400', gradient: 'from-blue-500/70 to-blue-600/70', icon: , - label: 'Monthly Free', - description: 'Your monthly allowance', + label: 'Free', + description: 'Signup or grandfathered credits', }, subscription: { bg: 'bg-indigo-500', @@ -188,7 +188,7 @@ const CreditBranch = ({ }: CreditBranchProps) => { const [isOpen, setIsOpen] = React.useState(false) const leftAmount = totalAmount - usedAmount - const isRenewable = title === 'Renewable Credits' + const isRenewing = title === 'Renewing Credits' return (
@@ -207,7 +207,7 @@ const CreditBranch = ({
{title} - {isRenewable && nextQuotaReset && ( + {isRenewing && nextQuotaReset && ( Renews{' '} {nextQuotaReset.toLocaleDateString(undefined, { @@ -270,9 +270,17 @@ export const UsageDisplay = ({ }) // Group credits by expiration type (excluding organization) - // referral_legacy and subscription renew monthly, referral (one-time) never expires - const expiringTypes: FilteredGrantType[] = ['free', 'referral_legacy', 'subscription'] - const nonExpiringTypes: FilteredGrantType[] = ['referral', 'admin', 'purchase', 'ad'] + // referral_legacy and subscription renew periodically. Free credits can be + // one-time signup credits or grandfathered monthly credits, so keep them in + // the source-based group below. + const expiringTypes: FilteredGrantType[] = ['referral_legacy', 'subscription'] + const nonExpiringTypes: FilteredGrantType[] = [ + 'free', + 'referral', + 'admin', + 'purchase', + 'ad', + ] const expiringTotal = expiringTypes.reduce( (acc, type) => acc + (principals?.[type] || breakdown[type] || 0), @@ -300,7 +308,7 @@ export const UsageDisplay = ({ Credit Balance
- We'll use your renewable credits before non-renewable ones + Credits are consumed by grant priority, then expiration date
{totalDebt > 500 && ( @@ -317,7 +325,7 @@ export const UsageDisplay = ({ {/* Credit Categories with expandable details */}