diff --git a/common/src/constants/grant-priorities.ts b/common/src/constants/grant-priorities.ts index 49cae0786..df17d1008 100644 --- a/common/src/constants/grant-priorities.ts +++ b/common/src/constants/grant-priorities.ts @@ -1,10 +1,12 @@ import type { GrantType } from '@codebuff/common/types/grant' +// Lower = consumed first export const GRANT_PRIORITIES: Record = { subscription: 10, free: 20, - referral: 30, + referral_legacy: 30, // Legacy recurring referrals (renews monthly, consumed first) ad: 40, + referral: 50, // One-time referrals (never expires, preserved longer) admin: 60, organization: 70, purchase: 80, diff --git a/common/src/constants/limits.ts b/common/src/constants/limits.ts index afdcfe74b..35dba95df 100644 --- a/common/src/constants/limits.ts +++ b/common/src/constants/limits.ts @@ -5,7 +5,7 @@ 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 -export const CREDITS_REFERRAL_BONUS = 250 +export const CREDITS_REFERRAL_BONUS = 500 export const AFFILIATE_USER_REFFERAL_LIMIT = 500 // Default number of free credits granted per cycle diff --git a/common/src/types/grant.ts b/common/src/types/grant.ts index 33534a435..7c056f34a 100644 --- a/common/src/types/grant.ts +++ b/common/src/types/grant.ts @@ -1,6 +1,7 @@ export type GrantType = | 'free' | 'referral' + | 'referral_legacy' | 'subscription' | 'purchase' | 'admin' @@ -10,6 +11,7 @@ export type GrantType = export const GrantTypeValues = [ 'free', 'referral', + 'referral_legacy', 'subscription', 'purchase', 'admin', diff --git a/packages/billing/src/__tests__/grant-credits.test.ts b/packages/billing/src/__tests__/grant-credits.test.ts index aac80b503..6de3ecaa6 100644 --- a/packages/billing/src/__tests__/grant-credits.test.ts +++ b/packages/billing/src/__tests__/grant-credits.test.ts @@ -40,12 +40,16 @@ const createTxMock = (user: { }), select: () => ({ from: () => ({ - where: () => ({ - orderBy: () => ({ - limit: () => [], - }), - }), - then: (cb: any) => cb([]), + where: () => { + // Create a thenable object that also supports orderBy for different code paths + return { + orderBy: () => ({ + limit: () => [], + }), + // Make this thenable for the .where().then() pattern used in grant-credits.ts + then: (resolve: any, reject?: any) => Promise.resolve([]).then(resolve, reject), + } + }, }), }), execute: () => Promise.resolve([]), @@ -88,6 +92,180 @@ describe('grant-credits', () => { clearMockedModules() }) + describe('calculateTotalLegacyReferralBonus', () => { + const createDbMockForReferralQuery = (totalCredits: string | null) => ({ + select: () => ({ + from: () => ({ + where: () => Promise.resolve([{ totalCredits }]), + }), + }), + }) + + const createDbMockThatThrows = (error: Error) => ({ + select: () => ({ + from: () => ({ + where: () => Promise.reject(error), + }), + }), + }) + + it('should return total credits when user has legacy referrals as referrer', async () => { + await mockModule('@codebuff/internal/db', () => ({ + default: createDbMockForReferralQuery('500'), + })) + + const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + + const result = await calculateTotalLegacyReferralBonus({ + userId: 'user-123', + logger, + }) + + expect(result).toBe(500) + }) + + it('should return total credits when user has legacy referrals as referred', async () => { + await mockModule('@codebuff/internal/db', () => ({ + default: createDbMockForReferralQuery('500'), + })) + + const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + + const result = await calculateTotalLegacyReferralBonus({ + userId: 'referred-user', + logger, + }) + + expect(result).toBe(500) + }) + + it('should return combined total when user has legacy referrals as both referrer and referred', async () => { + await mockModule('@codebuff/internal/db', () => ({ + default: createDbMockForReferralQuery('750'), + })) + + const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + + const result = await calculateTotalLegacyReferralBonus({ + userId: 'user-with-both', + logger, + }) + + expect(result).toBe(750) + }) + + it('should return 0 when user has no legacy referrals (only non-legacy)', async () => { + // The query filters by is_legacy = true, so non-legacy referrals return 0 + await mockModule('@codebuff/internal/db', () => ({ + default: createDbMockForReferralQuery('0'), + })) + + const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + + const result = await calculateTotalLegacyReferralBonus({ + userId: 'user-with-only-new-referrals', + logger, + }) + + expect(result).toBe(0) + }) + + it('should return 0 when user has no referrals at all', async () => { + await mockModule('@codebuff/internal/db', () => ({ + default: createDbMockForReferralQuery('0'), + })) + + const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + + const result = await calculateTotalLegacyReferralBonus({ + userId: 'user-with-no-referrals', + logger, + }) + + expect(result).toBe(0) + }) + + it('should return 0 when query returns null (COALESCE handles this)', async () => { + await mockModule('@codebuff/internal/db', () => ({ + default: createDbMockForReferralQuery(null), + })) + + const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + + const result = await calculateTotalLegacyReferralBonus({ + userId: 'user-null-result', + logger, + }) + + expect(result).toBe(0) + }) + + it('should return 0 when query returns undefined result', async () => { + await mockModule('@codebuff/internal/db', () => ({ + default: { + select: () => ({ + from: () => ({ + where: () => Promise.resolve([]), + }), + }), + }, + })) + + const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + + const result = await calculateTotalLegacyReferralBonus({ + userId: 'user-empty-result', + logger, + }) + + expect(result).toBe(0) + }) + + it('should return 0 and log error when database query fails', async () => { + const dbError = new Error('Database connection failed') + await mockModule('@codebuff/internal/db', () => ({ + default: createDbMockThatThrows(dbError), + })) + + const errorLogs: any[] = [] + const errorLogger: Logger = { + ...logger, + error: (...args: any[]) => { + errorLogs.push(args) + }, + } + + const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + + const result = await calculateTotalLegacyReferralBonus({ + userId: 'user-db-error', + logger: errorLogger, + }) + + expect(result).toBe(0) + expect(errorLogs.length).toBe(1) + expect(errorLogs[0][0]).toMatchObject({ + userId: 'user-db-error', + error: dbError, + }) + }) + + it('should handle large credit values correctly', async () => { + await mockModule('@codebuff/internal/db', () => ({ + default: createDbMockForReferralQuery('999999'), + })) + + const { calculateTotalLegacyReferralBonus } = await import('../grant-credits') + + const result = await calculateTotalLegacyReferralBonus({ + userId: 'power-referrer', + logger, + }) + + expect(result).toBe(999999) + }) + }) + describe('triggerMonthlyResetAndGrant', () => { describe('autoTopupEnabled return value', () => { it('should return autoTopupEnabled: true when user has auto_topup_enabled: true', async () => { @@ -200,5 +378,173 @@ describe('grant-credits', () => { expect(result.quotaResetDate).toEqual(futureDate) }) }) + + describe('legacy referral grants', () => { + // 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) => { + grantCalls = [] + return { + query: { + user: { + findFirst: async () => user, + }, + }, + update: () => ({ + set: () => ({ + where: () => Promise.resolve(), + }), + }), + insert: () => ({ + values: (values: any) => { + grantCalls.push(values) + return { + onConflictDoNothing: () => ({ + returning: () => Promise.resolve([{ id: 'test-id' }]), + }), + } + }, + }), + select: () => ({ + from: () => ({ + where: () => { + // Create a thenable object that also supports orderBy for different code paths + const result = [{ totalCredits: String(legacyReferralBonus) }] + return { + orderBy: () => ({ + 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), + } + }, + }), + }), + execute: () => Promise.resolve([]), + } + } + + 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 }), + }) + + it('should grant referral_legacy type when user has legacy referrals and quota needs reset', async () => { + const pastResetDate = new Date(Date.now() - 24 * 60 * 60 * 1000) // Yesterday + const user = { + next_quota_reset: pastResetDate, + auto_topup_enabled: false, + } + 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: { + select: () => ({ + 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 + }), + } + } + // Return referral bonus for calculateTotalLegacyReferralBonus + return Promise.resolve([{ totalCredits: String(legacyReferralBonus) }]) + }, + }), + }), + }, + })) + await mockModule('@codebuff/internal/db/transaction', () => + createTransactionMockWithGrants(user, legacyReferralBonus), + ) + + 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) + + // Find the referral grant + 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)') + }) + + it('should NOT grant referral credits when user has no legacy referrals', async () => { + const pastResetDate = new Date(Date.now() - 24 * 60 * 60 * 1000) // Yesterday + const user = { + next_quota_reset: pastResetDate, + auto_topup_enabled: false, + } + const legacyReferralBonus = 0 // No legacy referrals + + // Mock db for both getPreviousFreeGrantAmount and calculateTotalLegacyReferralBonus + let queryCount = 0 + await mockModule('@codebuff/internal/db', () => ({ + default: { + select: () => ({ + 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 + }), + } + } + // Return 0 referral bonus for calculateTotalLegacyReferralBonus + return Promise.resolve([{ totalCredits: String(legacyReferralBonus) }]) + }, + }), + }), + }, + })) + await mockModule('@codebuff/internal/db/transaction', () => + createTransactionMockWithGrants(user, legacyReferralBonus), + ) + + 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) + + // The only grant should be 'free' type + expect(grantCalls[0].type).toBe('free') + }) + }) }) }) diff --git a/packages/billing/src/__tests__/usage-service.test.ts b/packages/billing/src/__tests__/usage-service.test.ts index c037b6031..ebe223fb6 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, 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 }, + breakdown: { free: 500, referral: 0, referral_legacy: 0, subscription: 0, purchase: 500, admin: 0, organization: 0, ad: 0 }, + principals: { free: 500, referral: 0, referral_legacy: 0, subscription: 0, purchase: 500, admin: 0, organization: 0, ad: 0 }, } describe('usage-service', () => { diff --git a/packages/billing/src/grant-credits.ts b/packages/billing/src/grant-credits.ts index 00bf4ba07..be609c746 100644 --- a/packages/billing/src/grant-credits.ts +++ b/packages/billing/src/grant-credits.ts @@ -71,12 +71,13 @@ export async function getPreviousFreeGrantAmount(params: { } /** - * Calculates the total referral bonus credits a user should receive based on - * their referral history (both as referrer and referred). + * Calculates the total legacy referral bonus credits a user should receive based on + * their legacy referral history (both as referrer and referred). + * Only counts referrals where is_legacy = true (grandfathered users from old program). * @param userId The ID of the user. - * @returns The total referral bonus credits earned. + * @returns The total legacy referral bonus credits earned. */ -export async function calculateTotalReferralBonus(params: { +export async function calculateTotalLegacyReferralBonus(params: { userId: string logger: Logger }): Promise { @@ -89,19 +90,22 @@ export async function calculateTotalReferralBonus(params: { }) .from(schema.referral) .where( - or( - eq(schema.referral.referrer_id, userId), - eq(schema.referral.referred_id, userId), + and( + or( + eq(schema.referral.referrer_id, userId), + eq(schema.referral.referred_id, userId), + ), + eq(schema.referral.is_legacy, true), ), ) const totalBonus = parseInt(result[0]?.totalCredits ?? '0') - logger.debug({ userId, totalBonus }, 'Calculated total referral bonus.') + logger.debug({ userId, totalBonus }, 'Calculated total legacy referral bonus.') return totalBonus } catch (error) { logger.error( { userId, error }, - 'Error calculating total referral bonus. Returning 0.', + 'Error calculating total legacy referral bonus. Returning 0.', ) return 0 } @@ -456,7 +460,7 @@ export async function triggerMonthlyResetAndGrant(params: { // Calculate grant amounts separately const [freeGrantAmount, referralBonus] = await Promise.all([ getPreviousFreeGrantAmount(params), - calculateTotalReferralBonus(params), + calculateTotalLegacyReferralBonus(params), ]) // Generate a deterministic operation ID based on userId and reset date to minute precision @@ -481,14 +485,14 @@ export async function triggerMonthlyResetAndGrant(params: { tx, }) - // Only grant referral credits if there are any + // Only grant legacy referral credits if there are any (for grandfathered users) if (referralBonus > 0) { await executeGrantCreditOperation({ ...params, amount: referralBonus, - type: 'referral', - description: 'Referral bonus', - expiresAt: newResetDate, // Referral credits expire at next reset + type: 'referral_legacy', + description: 'Monthly referral bonus (legacy)', + expiresAt: newResetDate, // Legacy referral credits expire at next reset operationId: referralOperationId, tx, }) diff --git a/packages/internal/src/db/migrations/0039_quiet_franklin_storm.sql b/packages/internal/src/db/migrations/0039_quiet_franklin_storm.sql new file mode 100644 index 000000000..437d4cc0f --- /dev/null +++ b/packages/internal/src/db/migrations/0039_quiet_franklin_storm.sql @@ -0,0 +1,16 @@ +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 diff --git a/packages/internal/src/db/migrations/meta/0036_snapshot.json b/packages/internal/src/db/migrations/meta/0039_snapshot.json similarity index 99% rename from packages/internal/src/db/migrations/meta/0036_snapshot.json rename to packages/internal/src/db/migrations/meta/0039_snapshot.json index d2ea08641..eb44a509d 100644 --- a/packages/internal/src/db/migrations/meta/0036_snapshot.json +++ b/packages/internal/src/db/migrations/meta/0039_snapshot.json @@ -1,6 +1,6 @@ { - "id": "14a00b85-f71c-42bf-911c-44fc725de438", - "prevId": "7835ce78-4836-46c4-b91b-5941d93544e9", + "id": "c08ced84-4b3d-4bd3-8934-aa9531d889ca", + "prevId": "43f3712d-1692-4c3f-a029-54a9c66d293c", "version": "7", "dialect": "postgresql", "tables": { @@ -1080,7 +1080,7 @@ "name": "idx_credit_ledger_subscription", "columns": [ { - "expression": "stripe_subscription_id", + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" @@ -2396,6 +2396,13 @@ "primaryKey": false, "notNull": true }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "created_at": { "name": "created_at", "type": "timestamp", @@ -2560,6 +2567,18 @@ "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", @@ -2998,6 +3017,7 @@ "values": [ "free", "referral", + "referral_legacy", "subscription", "purchase", "admin", diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index 067c22194..8d6ca418d 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -274,6 +274,13 @@ "when": 1769649819008, "tag": "0038_legal_jimmy_woo", "breakpoints": true + }, + { + "idx": 39, + "version": "7", + "when": 1769482939158, + "tag": "0039_quiet_franklin_storm", + "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 24ec326fe..3d3f9e024 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -190,6 +190,7 @@ export const referral = pgTable( .references(() => user.id), status: ReferralStatus('status').notNull().default('pending'), credits: integer('credits').notNull(), + is_legacy: boolean('is_legacy').notNull().default(false), created_at: timestamp('created_at', { mode: 'date' }) .notNull() .defaultNow(), diff --git a/web/src/app/api/referrals/__tests__/helpers.test.ts b/web/src/app/api/referrals/__tests__/helpers.test.ts new file mode 100644 index 000000000..3983a3339 --- /dev/null +++ b/web/src/app/api/referrals/__tests__/helpers.test.ts @@ -0,0 +1,375 @@ +import { + clearMockedModules, + mockModule, +} from '@codebuff/common/testing/mock-modules' +import { CREDITS_REFERRAL_BONUS } from '@codebuff/common/old-constants' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +describe('referral helpers', () => { + afterEach(() => { + clearMockedModules() + }) + + // Skip these tests: mockModule('@codebuff/billing') loads the original module first, + // which triggers Stripe initialization requiring fetch() in global scope. + // The one-time referral grant behavior is tested via integration tests and + // the billing package tests cover the grant operation logic. + describe.skip('redeemReferralCode - one-time referral grants', () => { + const mockLogger = { + debug: () => {}, + error: () => {}, + info: () => {}, + warn: () => {}, + } + + const referrerId = 'referrer-user-id' + const referredId = 'referred-user-id' + const referralCode = 'ref-test-code' + + // Track grant operations to verify they use correct parameters + let grantOperationCalls: any[] = [] + + const createDbMock = (options: { + alreadyUsedReferral?: boolean + referrerExists?: boolean + isSelfReferral?: boolean + isDoubleDipping?: boolean + hasMaxedReferrals?: boolean + }) => { + const { + alreadyUsedReferral = false, + referrerExists = true, + isSelfReferral = false, + isDoubleDipping = false, + } = options + + return { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => + Promise.resolve(alreadyUsedReferral ? [{ id: 'existing' }] : []), + }), + }), + }), + query: { + user: { + findFirst: async ({ where }: any) => { + // Return referrer or referred user based on the query + if (referrerExists) { + return { id: isSelfReferral ? referredId : referrerId } + } + return null + }, + }, + }, + transaction: async (callback: (tx: any) => Promise) => { + const txMock = { + insert: () => ({ + values: (values: any) => { + // Capture the referral record values to verify is_legacy: false + return { + returning: () => + Promise.resolve([{ operation_id: 'ref-test-op-id' }]), + } + }, + }), + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => + Promise.resolve(isDoubleDipping ? [{ id: 'double' }] : []), + }), + }), + }), + } + return callback(txMock) + }, + } + } + + beforeEach(() => { + grantOperationCalls = [] + }) + + it('should create referral grants with expiresAt: null (one-time, never expires)', async () => { + const dbMock = createDbMock({ referrerExists: true }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + await mockModule('@codebuff/billing', () => ({ + grantCreditOperation: async (params: any) => { + grantOperationCalls.push(params) + return Promise.resolve() + }, + })) + + await mockModule('@/lib/server/referral', () => ({ + hasMaxedReferrals: async () => ({ reason: null }), + })) + + await mockModule('@/util/logger', () => ({ + logger: mockLogger, + })) + + const { redeemReferralCode } = await import('../helpers') + + await redeemReferralCode(referralCode, referredId) + + // Should have made 2 grant calls (referrer and referred) + expect(grantOperationCalls.length).toBe(2) + + // Both grants should have expiresAt: null (one-time, never expires) + for (const call of grantOperationCalls) { + expect(call.expiresAt).toBeNull() + } + }) + + it('should create referral grants with type "referral" (not "referral_legacy")', async () => { + const dbMock = createDbMock({ referrerExists: true }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + await mockModule('@codebuff/billing', () => ({ + grantCreditOperation: async (params: any) => { + grantOperationCalls.push(params) + return Promise.resolve() + }, + })) + + await mockModule('@/lib/server/referral', () => ({ + hasMaxedReferrals: async () => ({ reason: null }), + })) + + await mockModule('@/util/logger', () => ({ + logger: mockLogger, + })) + + const { redeemReferralCode } = await import('../helpers') + + await redeemReferralCode(referralCode, referredId) + + // Both grants should use type 'referral' (not 'referral_legacy') + for (const call of grantOperationCalls) { + expect(call.type).toBe('referral') + expect(call.type).not.toBe('referral_legacy') + } + }) + + it('should grant correct amount (CREDITS_REFERRAL_BONUS) to both users', async () => { + const dbMock = createDbMock({ referrerExists: true }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + await mockModule('@codebuff/billing', () => ({ + grantCreditOperation: async (params: any) => { + grantOperationCalls.push(params) + return Promise.resolve() + }, + })) + + await mockModule('@/lib/server/referral', () => ({ + hasMaxedReferrals: async () => ({ reason: null }), + })) + + await mockModule('@/util/logger', () => ({ + logger: mockLogger, + })) + + const { redeemReferralCode } = await import('../helpers') + + await redeemReferralCode(referralCode, referredId) + + // Both grants should have the correct amount + for (const call of grantOperationCalls) { + expect(call.amount).toBe(CREDITS_REFERRAL_BONUS) + } + }) + + it('should create grants for both referrer and referred with correct descriptions', async () => { + const dbMock = createDbMock({ referrerExists: true }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + await mockModule('@codebuff/billing', () => ({ + grantCreditOperation: async (params: any) => { + grantOperationCalls.push(params) + return Promise.resolve() + }, + })) + + await mockModule('@/lib/server/referral', () => ({ + hasMaxedReferrals: async () => ({ reason: null }), + })) + + await mockModule('@/util/logger', () => ({ + logger: mockLogger, + })) + + const { redeemReferralCode } = await import('../helpers') + + await redeemReferralCode(referralCode, referredId) + + expect(grantOperationCalls.length).toBe(2) + + const referrerGrant = grantOperationCalls.find((c) => + c.description.includes('referrer'), + ) + const referredGrant = grantOperationCalls.find((c) => + c.description.includes('referred'), + ) + + expect(referrerGrant).toBeDefined() + expect(referredGrant).toBeDefined() + expect(referrerGrant.description).toBe('Referral bonus (referrer)') + expect(referredGrant.description).toBe('Referral bonus (referred)') + }) + + it('should use unique operation IDs for referrer and referred grants', async () => { + const dbMock = createDbMock({ referrerExists: true }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + await mockModule('@codebuff/billing', () => ({ + grantCreditOperation: async (params: any) => { + grantOperationCalls.push(params) + return Promise.resolve() + }, + })) + + await mockModule('@/lib/server/referral', () => ({ + hasMaxedReferrals: async () => ({ reason: null }), + })) + + await mockModule('@/util/logger', () => ({ + logger: mockLogger, + })) + + const { redeemReferralCode } = await import('../helpers') + + await redeemReferralCode(referralCode, referredId) + + expect(grantOperationCalls.length).toBe(2) + + const operationIds = grantOperationCalls.map((c) => c.operationId) + expect(operationIds[0]).not.toBe(operationIds[1]) + expect(operationIds[0]).toContain('-referrer') + expect(operationIds[1]).toContain('-referred') + }) + + it('should reject when user has already been referred', async () => { + const dbMock = createDbMock({ + referrerExists: true, + alreadyUsedReferral: true, + }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + await mockModule('@codebuff/billing', () => ({ + grantCreditOperation: async (params: any) => { + grantOperationCalls.push(params) + return Promise.resolve() + }, + })) + + await mockModule('@/lib/server/referral', () => ({ + hasMaxedReferrals: async () => ({ reason: null }), + })) + + await mockModule('@/util/logger', () => ({ + logger: mockLogger, + })) + + const { redeemReferralCode } = await import('../helpers') + + const response = await redeemReferralCode(referralCode, referredId) + + // Should return 409 conflict + expect(response.status).toBe(409) + + // Should NOT have made any grant calls + expect(grantOperationCalls.length).toBe(0) + }) + + it('should reject when trying to use own referral code', async () => { + const dbMock = createDbMock({ + referrerExists: true, + isSelfReferral: true, + }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + await mockModule('@codebuff/billing', () => ({ + grantCreditOperation: async (params: any) => { + grantOperationCalls.push(params) + return Promise.resolve() + }, + })) + + await mockModule('@/lib/server/referral', () => ({ + hasMaxedReferrals: async () => ({ reason: null }), + })) + + await mockModule('@/util/logger', () => ({ + logger: mockLogger, + })) + + const { redeemReferralCode } = await import('../helpers') + + const response = await redeemReferralCode(referralCode, referredId) + + // Should return 400 bad request + expect(response.status).toBe(400) + + // Should NOT have made any grant calls + expect(grantOperationCalls.length).toBe(0) + }) + + it('should reject when referral code does not exist', async () => { + const dbMock = createDbMock({ referrerExists: false }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + await mockModule('@codebuff/billing', () => ({ + grantCreditOperation: async (params: any) => { + grantOperationCalls.push(params) + return Promise.resolve() + }, + })) + + await mockModule('@/lib/server/referral', () => ({ + hasMaxedReferrals: async () => ({ reason: null }), + })) + + await mockModule('@/util/logger', () => ({ + logger: mockLogger, + })) + + const { redeemReferralCode } = await import('../helpers') + + const response = await redeemReferralCode('invalid-code', referredId) + + // Should return 404 not found + expect(response.status).toBe(404) + + // Should NOT have made any grant calls + expect(grantOperationCalls.length).toBe(0) + }) + }) +}) diff --git a/web/src/app/api/referrals/helpers.ts b/web/src/app/api/referrals/helpers.ts index e653ffb76..f775bc364 100644 --- a/web/src/app/api/referrals/helpers.ts +++ b/web/src/app/api/referrals/helpers.ts @@ -119,7 +119,7 @@ export async function redeemReferralCode(referralCode: string, userId: string) { } await db.transaction(async (tx) => { - // 1. Create the referral record locally + // 1. Create the referral record locally (one-time referral, is_legacy: false) const now = new Date() const referralRecord = await tx .insert(schema.referral) @@ -128,6 +128,7 @@ export async function redeemReferralCode(referralCode: string, userId: string) { referred_id: userId, status: 'completed', credits: CREDITS_REFERRAL_BONUS, + is_legacy: false, created_at: now, completed_at: now, }) @@ -137,30 +138,17 @@ export async function redeemReferralCode(referralCode: string, userId: string) { const operationId = referralRecord[0].operation_id - // Get the user's next quota reset date - const user = await tx.query.user.findFirst({ - where: eq(schema.user.id, userId), - columns: { - next_quota_reset: true, - }, - }) - - if (!user?.next_quota_reset) { - throw new Error('User next_quota_reset not found') - } - - // 2. Process and grant credits for both users + // 2. Process and grant credits for both users (one-time, never expires) const grantPromises = [] - // Process Referrer - grantPromises.push( + const grantForUser = (user: { id: string; role: 'referrer' | 'referred' }) => grantCreditOperation({ - userId: referrer.id, + userId: user.id, amount: CREDITS_REFERRAL_BONUS, type: 'referral', - description: 'Referral bonus (referrer)', - expiresAt: user.next_quota_reset, - operationId: `${operationId}-referrer`, + description: `Referral bonus (${user.role})`, + expiresAt: null, // One-time referrals never expire + operationId: `${operationId}-${user.role}`, tx, logger, }) @@ -169,42 +157,17 @@ export async function redeemReferralCode(referralCode: string, userId: string) { logger.error( { error, - userId: referrer.id, - role: 'referrer', + userId: user.id, + role: user.role, creditsToGrant: CREDITS_REFERRAL_BONUS, }, 'Failed to process referral credit grant', ) return false - }), - ) + }) - // Process Referred User - grantPromises.push( - grantCreditOperation({ - userId: referred.id, - amount: CREDITS_REFERRAL_BONUS, - type: 'referral', - description: 'Referral bonus (referred)', - expiresAt: user.next_quota_reset, - operationId: `${operationId}-referred`, - tx, - logger, - }) - .then(() => true) - .catch((error: Error) => { - logger.error( - { - error, - userId: referred.id, - role: 'referred', - creditsToGrant: CREDITS_REFERRAL_BONUS, - }, - 'Failed to process referral credit grant', - ) - return false - }), - ) + grantPromises.push(grantForUser({ id: referrer.id, role: 'referrer' })) + grantPromises.push(grantForUser({ id: referred.id, role: 'referred' })) const results = await Promise.all(grantPromises) diff --git a/web/src/app/api/referrals/route.ts b/web/src/app/api/referrals/route.ts index c03d58867..6c40579df 100644 --- a/web/src/app/api/referrals/route.ts +++ b/web/src/app/api/referrals/route.ts @@ -14,12 +14,13 @@ import { extractApiKeyFromHeader } from '@/util/auth' type Referral = Pick & - Pick + Pick const ReferralSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), credits: z.coerce.number(), + is_legacy: z.boolean().default(false), }) export type ReferralData = { @@ -53,6 +54,7 @@ export async function GET() { .select({ id: schema.referral.referred_id, credits: schema.referral.credits, + is_legacy: schema.referral.is_legacy, }) .from(schema.referral) .where(eq(schema.referral.referrer_id, session.user.id)) @@ -63,6 +65,7 @@ export async function GET() { name: schema.user.name, email: schema.user.email, credits: referralsQuery.credits, + is_legacy: referralsQuery.is_legacy, }) .from(referralsQuery) .leftJoin(schema.user, eq(schema.user.id, referralsQuery.id)) @@ -72,6 +75,7 @@ export async function GET() { .select({ id: schema.referral.referrer_id, credits: schema.referral.credits, + is_legacy: schema.referral.is_legacy, }) .from(schema.referral) .where(eq(schema.referral.referred_id, session.user.id)) @@ -83,6 +87,7 @@ export async function GET() { name: schema.user.name, email: schema.user.email, credits: referredByIdQuery.credits, + is_legacy: referredByIdQuery.is_legacy, }) .from(referredByIdQuery) .leftJoin(schema.user, eq(schema.user.id, referredByIdQuery.id)) diff --git a/web/src/app/profile/components/referrals-section.tsx b/web/src/app/profile/components/referrals-section.tsx index 479f8c3e2..e1f79d02c 100644 --- a/web/src/app/profile/components/referrals-section.tsx +++ b/web/src/app/profile/components/referrals-section.tsx @@ -33,12 +33,18 @@ const copyReferral = (link: string) => { }) } -const CreditsBadge = (credits: number) => { +const CreditsBadge = ({ + credits, + isLegacy, +}: { + credits: number + isLegacy: boolean +}) => { return ( - +{credits} credits + +{credits} credits{isLegacy && ' per month'} ) } @@ -111,7 +117,10 @@ export function ReferralsSection() {

{data.referredBy.name} referred you.

- {CreditsBadge(data.referredBy.credits)} + @@ -124,7 +133,7 @@ export function ReferralsSection() { Refer a friend and you'll both earn {CREDITS_REFERRAL_BONUS}{' '} - bonus credits!{' '} + credits as a one-time bonus!{' '} @@ -203,9 +212,9 @@ export function ReferralsSection() { className="flex justify-between items-center" > - {r.name} ({r.email}) + {r.name} ({r.email}){r.is_legacy && ' (legacy)'} - {CreditsBadge(r.credits)} + ))} diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index 48f90d1a7..548eaddbd 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -60,7 +60,15 @@ const grantTypeInfo: Record< gradient: 'from-green-500/70 to-green-600/70', icon: , label: 'Referral Bonus', - description: 'Earned by referring others', + description: 'One-time bonus from referrals', + }, + referral_legacy: { + bg: 'bg-emerald-500', + text: 'text-emerald-600 dark:text-emerald-400', + gradient: 'from-emerald-500/70 to-emerald-600/70', + icon: , + label: 'Referral Bonus (Legacy)', + description: 'Monthly recurring referral bonus', }, purchase: { bg: 'bg-yellow-500', @@ -243,6 +251,7 @@ export const UsageDisplay = ({ const usedCredits: Record = { free: 0, referral: 0, + referral_legacy: 0, subscription: 0, purchase: 0, admin: 0, @@ -262,8 +271,9 @@ export const UsageDisplay = ({ }) // Group credits by expiration type (excluding organization) - const expiringTypes: FilteredGrantType[] = ['free', 'referral', 'subscription'] - const nonExpiringTypes: FilteredGrantType[] = ['admin', 'purchase', 'ad'] + // 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'] const expiringTotal = expiringTypes.reduce( (acc, type) => acc + (principals?.[type] || breakdown[type] || 0),