Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion common/src/constants/grant-priorities.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { GrantType } from '@codebuff/common/types/grant'

// Lower = consumed first
export const GRANT_PRIORITIES: Record<GrantType, number> = {
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,
Expand Down
2 changes: 2 additions & 0 deletions common/src/types/grant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type GrantType =
| 'free'
| 'referral'
| 'referral_legacy'
| 'purchase'
| 'admin'
| 'organization'
Expand All @@ -9,6 +10,7 @@ export type GrantType =
export const GrantTypeValues = [
'free',
'referral',
'referral_legacy',
'purchase',
'admin',
'organization',
Expand Down
4 changes: 2 additions & 2 deletions packages/billing/src/__tests__/usage-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, paid: 500, referral: 0, referral_legacy: 0, purchase: 0, admin: 0, organization: 0, ad: 0 },
principals: { free: 500, paid: 500, referral: 0, referral_legacy: 0, purchase: 0, admin: 0, organization: 0, ad: 0 },
}

describe('usage-service', () => {
Expand Down
32 changes: 18 additions & 14 deletions packages/billing/src/grant-credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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: 'Monthly 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,
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading