diff --git a/.specs/kilo-pass.md b/.specs/kilo-pass.md index 9182a912ca..d88275e808 100644 --- a/.specs/kilo-pass.md +++ b/.specs/kilo-pass.md @@ -242,18 +242,23 @@ with welcome-promo overrides. Yearly subscriptions use a flat 50% monthly bonus. ### Projections and UI -38. The Kilo Pass state read path MUST compute current-period and next-period UI bonus projections from tier, cadence, - streak, first-time-subscriber status, provider, and stored initial welcome-promo reason. +38. The Kilo Pass state read path MUST keep actual issuance amounts and projected amounts separate. It MUST compute + current-period and next-period projections from tier, cadence, streak, first-time-subscriber status, provider, and + stored initial welcome-promo reason. 39. Current-period Kilo Pass state projection MUST use current streak. Next-period Kilo Pass state projection MUST use current streak plus one. -40. Current-period unlock state MUST report whether the latest issuance contains any bonus-like item. Current-period - projected dollars remain formula-based and do not substitute an existing `referral_bonus` item's actual amount. -41. Renewal UI without a scheduled change MUST display the server-projected next-period bonus. -42. Renewal UI with a scheduled change MUST recompute bonus against the displayed refill's selected tier and cadence on +40. The latest current-period issuance MUST report the actual kind and amount of its bonus-like item. When that item has + been issued, current-period UI and the available-credit total MUST use its actual amount rather than a projection. +41. Before a current-period bonus-like item is issued, UI MAY show the projected amount only when a normal `bonus` remains + available to be issued. A `referral_bonus` MUST be labeled "Referral bonus". Existing bonus-like items MUST continue to + suppress another bonus grant; these rules MUST NOT be read to permit double bonuses in one issuance. +42. Renewal UI without a scheduled change MUST display the server-projected next-period bonus as an unissued amount, using + "up to" or equivalent wording. It MUST remain a next-period projection even when the current-period issuance has an + actual bonus-like amount. +43. Renewal UI with a scheduled change MUST recompute bonus against the displayed refill's selected tier and cadence on the client. For monthly subscriptions it applies the target tier and cadence. For yearly subscriptions it applies - the target only when the scheduled effective instant matches the displayed refill instant. -43. Scheduled-change client recomputation does not apply the stored Stripe welcome-promo reason. It MUST NOT be - described as guaranteed equal to eventual issuance. + target only when the scheduled effective instant matches the displayed refill instant. The result is a next-period + projection, MUST use "up to" or equivalent wording, and MUST NOT be described as guaranteed equal to eventual issuance. 44. KiloClaw pending-balance projection MUST run only after effective threshold crossing and MUST return zero unless selected state is `active`. For monthly cadence it uses the monthly ramp only; for yearly cadence it uses flat 50%. 45. KiloClaw pending-balance projection does not inspect issuance headers, base issuance items, existing bonus-like diff --git a/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.logic.ts b/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.logic.ts index 95c8df5485..2b84b07651 100644 --- a/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.logic.ts +++ b/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.logic.ts @@ -4,7 +4,7 @@ import { computeYearlyCadenceMonthlyBonusUsd, getMonthlyPriceUsd, } from '@/lib/kilo-pass/bonus'; -import { KiloPassCadence } from '@/lib/kilo-pass/enums'; +import { KiloPassCadence, KiloPassIssuanceItemKind } from '@/lib/kilo-pass/enums'; import type { KiloPassTier } from '@/lib/kilo-pass/enums'; import { getTierName } from './utils'; import type { inferRouterOutputs } from '@trpc/server'; @@ -16,6 +16,50 @@ type RouterOutputs = inferRouterOutputs; export type KiloPassScheduledChange = RouterOutputs['kiloPass']['getScheduledChange']['scheduledChange']; +/** + * Router-provided current-period bonus state. A referral bonus replaces the + * normal bonus for that issuance, so an issued bonus carries its actual granted + * amount while an available bonus only carries the formula projection. + */ +export type KiloPassCurrentPeriodBonus = KiloPassSubscription['currentPeriodBonus']; + +export type CurrentPeriodBonusModel = { + /** Amount to display and to use as the bonus share of the usage total. */ + amountUsd: number; + label: 'Referral bonus' | 'Free bonus' | 'Available free bonus'; + isIssued: boolean; +}; + +/** + * Resolves the current-period bonus amount and label for the usage progress UI. + * Issued bonuses display the actual granted amount (a referral bonus may be lower + * or higher than the formula projection); available bonuses display the formula + * projection. Missing or non-positive amounts return null so the card hides the + * section instead of fabricating credits. + */ +export function computeCurrentPeriodBonusModel( + currentPeriodBonus: KiloPassCurrentPeriodBonus | null | undefined +): CurrentPeriodBonusModel | null { + if (!currentPeriodBonus) return null; + + if (currentPeriodBonus.status === 'issued') { + const amountUsd = currentPeriodBonus.actualAmountUsd; + if (typeof amountUsd !== 'number' || amountUsd <= 0) return null; + return { + amountUsd, + label: + currentPeriodBonus.kind === KiloPassIssuanceItemKind.ReferralBonus + ? 'Referral bonus' + : 'Free bonus', + isIssued: true, + }; + } + + const amountUsd = currentPeriodBonus.projectedAmountUsd; + if (typeof amountUsd !== 'number' || amountUsd <= 0) return null; + return { amountUsd, label: 'Available free bonus', isIssued: false }; +} + export type KiloPassActiveSubscriptionCardLogicSubscription = Pick< KiloPassSubscription, | 'cadence' diff --git a/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.test.ts b/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.test.ts index 82726fd64c..d72f8854a4 100644 --- a/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.test.ts +++ b/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.test.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from '@jest/globals'; -import { KiloPassCadence, KiloPassTier } from '@/lib/kilo-pass/enums'; +import { KiloPassCadence, KiloPassIssuanceItemKind, KiloPassTier } from '@/lib/kilo-pass/enums'; import { getMonthlyPriceUsd } from '@/lib/kilo-pass/bonus'; import { + computeCurrentPeriodBonusModel, computeRenewInfoRowModel, computeUsageProgressModel, computeNextBillingDateRowDateLabel, } from './KiloPassActiveSubscriptionCard.logic'; import type { KiloPassActiveSubscriptionCardLogicSubscription, + KiloPassCurrentPeriodBonus, KiloPassScheduledChange, } from './KiloPassActiveSubscriptionCard.logic'; import { KiloPassScheduledChangeStatus } from '@/lib/kilo-pass/enums'; @@ -395,6 +397,156 @@ describe('KiloPassActiveSubscriptionCard.logic', () => { }); }); + describe('computeCurrentPeriodBonusModel()', () => { + function buildCurrentPeriodBonus( + overrides: Partial + ): KiloPassCurrentPeriodBonus { + return { + status: 'issued', + kind: KiloPassIssuanceItemKind.Bonus, + actualAmountUsd: 7.6, + projectedAmountUsd: 7.6, + ...overrides, + }; + } + + test('returns null when currentPeriodBonus is null or undefined', () => { + expect(computeCurrentPeriodBonusModel(null)).toBeNull(); + expect(computeCurrentPeriodBonusModel(undefined)).toBeNull(); + }); + + test('issued referral bonus lower than the normal projection displays the actual amount', () => { + // Referral bonus snapshots 50% of the referee's tier, which can be lower + // than the subscriber's own projected bonus (e.g. tier_199 subscriber, + // tier_19 referee: $9.50 actual vs $79.60 projected). + const model = computeCurrentPeriodBonusModel( + buildCurrentPeriodBonus({ + kind: KiloPassIssuanceItemKind.ReferralBonus, + actualAmountUsd: 9.5, + projectedAmountUsd: 79.6, + }) + ); + + expect(model).toEqual({ + amountUsd: 9.5, + label: 'Referral bonus', + isIssued: true, + }); + }); + + test('issued referral bonus higher than the normal projection displays the actual amount', () => { + // Streak month 1 projection is 5% ($0.95 at tier_19); the referral bonus + // replaces it at $9.50. + const model = computeCurrentPeriodBonusModel( + buildCurrentPeriodBonus({ + kind: KiloPassIssuanceItemKind.ReferralBonus, + actualAmountUsd: 9.5, + projectedAmountUsd: 0.95, + }) + ); + + expect(model).toEqual({ + amountUsd: 9.5, + label: 'Referral bonus', + isIssued: true, + }); + }); + + test.each([ + KiloPassIssuanceItemKind.Bonus, + KiloPassIssuanceItemKind.PromoFirstMonth50Pct, + null, + ] as const)('issued non-referral kind %s is labeled Free bonus', kind => { + const model = computeCurrentPeriodBonusModel(buildCurrentPeriodBonus({ kind })); + + expect(model?.label).toBe('Free bonus'); + expect(model?.amountUsd).toBe(7.6); + expect(model?.isIssued).toBe(true); + }); + + test.each([null, 0, -1])( + 'issued bonus with missing or non-positive actual amount returns null instead of fabricating credits', + actualAmountUsd => { + expect( + computeCurrentPeriodBonusModel(buildCurrentPeriodBonus({ actualAmountUsd })) + ).toBeNull(); + } + ); + + test('available bonus displays the projection labeled Available free bonus', () => { + const model = computeCurrentPeriodBonusModel( + buildCurrentPeriodBonus({ + status: 'available', + kind: KiloPassIssuanceItemKind.Bonus, + actualAmountUsd: null, + projectedAmountUsd: 19.6, + }) + ); + + expect(model).toEqual({ + amountUsd: 19.6, + label: 'Available free bonus', + isIssued: false, + }); + }); + + test.each([null, 0, -1])( + 'available bonus with missing or non-positive projection returns null instead of fabricating credits', + projectedAmountUsd => { + expect( + computeCurrentPeriodBonusModel( + buildCurrentPeriodBonus({ + status: 'available', + actualAmountUsd: null, + projectedAmountUsd, + }) + ) + ).toBeNull(); + } + ); + + test('usage progress denominator uses the actual amount once a referral bonus is issued', () => { + const bonus = computeCurrentPeriodBonusModel( + buildCurrentPeriodBonus({ + kind: KiloPassIssuanceItemKind.ReferralBonus, + actualAmountUsd: 9.5, + projectedAmountUsd: 79.6, + }) + ); + + const model = computeUsageProgressModel({ + baseUsd: 199, + bonusUsd: bonus?.amountUsd, + usageUsd: 0, + isBonusUnlocked: true, + isBonusAvailableToUnlock: false, + }); + + expect(model?.totalAvailableUsd).toBeCloseTo(208.5, 5); + }); + + test('usage progress denominator uses the projection only while the bonus is available to unlock', () => { + const bonus = computeCurrentPeriodBonusModel( + buildCurrentPeriodBonus({ + status: 'available', + kind: KiloPassIssuanceItemKind.Bonus, + actualAmountUsd: null, + projectedAmountUsd: 0.95, + }) + ); + + const model = computeUsageProgressModel({ + baseUsd: 19, + bonusUsd: bonus?.amountUsd, + usageUsd: 0, + isBonusUnlocked: false, + isBonusAvailableToUnlock: true, + }); + + expect(model?.totalAvailableUsd).toBeCloseTo(19.95, 5); + }); + }); + describe('computeNextBillingDateRowDateLabel()', () => { test('returns null for non-yearly cadence', () => { const label = computeNextBillingDateRowDateLabel({ diff --git a/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.tsx b/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.tsx index a12cb47728..8f893e4b56 100644 --- a/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.tsx +++ b/apps/web/src/components/profile/kilo-pass/KiloPassActiveSubscriptionCard.tsx @@ -26,6 +26,7 @@ import { import { KiloPassCadence } from '@/lib/kilo-pass/enums'; import { getTierName } from './utils'; import { + computeCurrentPeriodBonusModel, computeNextBillingDateRowDateLabel, computeRenewInfoRowModel, computeUsageProgressModel, @@ -114,15 +115,15 @@ function RenewInfoRow() {
- {row.labelPrefix}: adds{' '} + {row.labelPrefix}:{' '} {formatDollars(row.baseUsd)} {' '} - paid +{' '} + paid + up to{' '} {formatDollars(row.bonusUsd)} {' '} - free bonus credits + bonus credits
{dateLabel} @@ -217,13 +218,15 @@ function UsageProgressOrBonusUnlocked() { const baseUsd = subscription.currentPeriodBaseCreditsUsd; const usageUsd = subscription.currentPeriodUsageUsd; - // This is the bonus for the *current* period (unlocked after consuming the base credits). - const bonusUsd = subscription.currentPeriodBonusCreditsUsd; + // Bonus for the *current* period: the actual granted amount once issued (a + // referral bonus replaces the normal bonus), the projection while available. + const bonus = computeCurrentPeriodBonusModel(subscription.currentPeriodBonus); + if (!bonus) return null; const model = computeUsageProgressModel({ baseUsd, usageUsd, - bonusUsd, + bonusUsd: bonus.amountUsd, isBonusUnlocked: subscription.isBonusUnlocked, isBonusAvailableToUnlock: subscription.isBonusAvailableToUnlock, }); @@ -291,7 +294,7 @@ function UsageProgressOrBonusUnlocked() {
- Free bonus + {bonus.label}
diff --git a/apps/web/src/routers/kilo-pass-router.test.ts b/apps/web/src/routers/kilo-pass-router.test.ts index 9bd061da8e..c5e16821ee 100644 --- a/apps/web/src/routers/kilo-pass-router.test.ts +++ b/apps/web/src/routers/kilo-pass-router.test.ts @@ -161,6 +161,12 @@ type KiloPassCaller = { currentPeriodUsageUsd: number; currentPeriodHostingCostUsd: number; currentPeriodBonusCreditsUsd: number | null; + currentPeriodBonus: { + status: 'available' | 'issued'; + kind: KiloPassIssuanceItemKind | null; + actualAmountUsd: number | null; + projectedAmountUsd: number | null; + }; isBonusUnlocked: boolean; isBonusAvailableToUnlock: boolean; refillAt: string | null; @@ -453,6 +459,7 @@ async function insertBaseCreditsIssuance(params: { | KiloPassIssuanceItemKind.Bonus | KiloPassIssuanceItemKind.PromoFirstMonth50Pct | KiloPassIssuanceItemKind.ReferralBonus; + bonusAmountUsd?: number; }): Promise { const issuedMonth = new Date().toISOString().slice(0, 7); const issueMonth = params.issueMonth ?? `${issuedMonth}-01`; @@ -527,7 +534,7 @@ async function insertBaseCreditsIssuance(params: { kilo_pass_issuance_id: issuance.id, kind: params.bonusKind, credit_transaction_id: bonusCreditTxn.id, - amount_usd: 5, + amount_usd: params.bonusAmountUsd ?? 5, bonus_percent_applied: 0.5, created_at: params.createdAt, }); @@ -1110,8 +1117,80 @@ describe('kiloPassRouter', () => { expect(result.subscription?.currentPeriodBonusCreditsUsd).toBeGreaterThan(0); expect(result.subscription?.isBonusUnlocked).toBe(true); expect(result.subscription?.isBonusAvailableToUnlock).toBe(false); + if (!result.subscription) { + throw new Error('Expected an active subscription state'); + } + expect(result.subscription?.currentPeriodBonus).toEqual({ + status: 'issued', + kind: KiloPassIssuanceItemKind.Bonus, + actualAmountUsd: 5, + projectedAmountUsd: result.subscription.currentPeriodBonusCreditsUsd, + }); }); + it.each([ + { + label: 'lower', + amountUsd: 0.5, + compare: (actual: number, projected: number) => actual < projected, + }, + { + label: 'higher', + amountUsd: 8, + compare: (actual: number, projected: number) => actual > projected, + }, + ])( + 'reports the actual referral bonus amount when it is $label than projection', + async ({ amountUsd, compare }) => { + const stripeMock = getStripeMock(); + const currentPeriodEndSeconds = 1_700_123_456; + stripeMock.subscriptions.retrieve.mockResolvedValue({ + id: `sub_test_referral_${amountUsd}`, + status: 'active', + items: { + data: [ + { + current_period_end: currentPeriodEndSeconds, + current_period_start: currentPeriodEndSeconds - 2_592_000, + }, + ], + }, + }); + + const user = await insertTestUser({ + google_user_email: `kilo-pass-get-state-referral-${amountUsd}@example.com`, + }); + const { id: subscriptionId } = await insertSubscription({ + kiloUserId: user.id, + stripeSubscriptionId: `sub_test_referral_${amountUsd}`, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + status: 'active', + currentStreakMonths: 1, + }); + await insertBaseCreditsIssuance({ + subscriptionId, + kiloUserId: user.id, + bonusKind: KiloPassIssuanceItemKind.ReferralBonus, + bonusAmountUsd: amountUsd, + }); + + const result = await (await createCallerForUser(user.id)).kiloPass.getState(); + const projectedAmountUsd = result.subscription?.currentPeriodBonusCreditsUsd; + + expect(result.subscription?.currentPeriodBonus).toEqual({ + status: 'issued', + kind: KiloPassIssuanceItemKind.ReferralBonus, + actualAmountUsd: amountUsd, + projectedAmountUsd, + }); + expect(typeof projectedAmountUsd).toBe('number'); + expect(compare(amountUsd, projectedAmountUsd ?? 0)).toBe(true); + expect(result.subscription?.isBonusUnlocked).toBe(true); + expect(result.subscription?.isBonusAvailableToUnlock).toBe(false); + } + ); + it('keeps first-month current bonus visible for first-time subscribers with a new card', async () => { const stripeMock = getStripeMock(); const currentPeriodEndSeconds = 1_700_123_456; diff --git a/apps/web/src/routers/kilo-pass-router.ts b/apps/web/src/routers/kilo-pass-router.ts index 4fff17c97d..0fa999a29f 100644 --- a/apps/web/src/routers/kilo-pass-router.ts +++ b/apps/web/src/routers/kilo-pass-router.ts @@ -140,6 +140,11 @@ const KiloPassSubscriptionStatusSchema = z.union([ z.literal('unpaid'), ]); +type KiloPassBonusKind = + | KiloPassIssuanceItemKind.Bonus + | KiloPassIssuanceItemKind.ReferralBonus + | KiloPassIssuanceItemKind.PromoFirstMonth50Pct; + const KiloPassSubscriptionStateBaseSchema = z.object({ subscriptionId: z.string(), stripeSubscriptionId: z.string().nullable(), @@ -167,6 +172,18 @@ const KiloPassSubscriptionStateSchema = KiloPassSubscriptionStateBaseSchema.exte currentPeriodUsageUsd: z.number(), currentPeriodHostingCostUsd: z.number(), currentPeriodBonusCreditsUsd: z.number().nullable(), + currentPeriodBonus: z.object({ + status: z.enum(['available', 'issued']), + kind: z + .enum([ + KiloPassIssuanceItemKind.Bonus, + KiloPassIssuanceItemKind.ReferralBonus, + KiloPassIssuanceItemKind.PromoFirstMonth50Pct, + ]) + .nullable(), + actualAmountUsd: z.number().nullable(), + projectedAmountUsd: z.number().nullable(), + }), isBonusUnlocked: z.boolean(), isBonusAvailableToUnlock: z.boolean(), refillAt: z.string().nullable(), @@ -440,26 +457,50 @@ function assertStripeManagedSubscription( } } -async function getIsBonusUnlockedForSubscriptionId(subscriptionId: string): Promise { +async function getLatestBonusLikeItemForSubscriptionId(subscriptionId: string): Promise<{ + kind: KiloPassBonusKind; + amountUsd: number; +} | null> { const lastIssuance = await db .select({ id: kilo_pass_issuances.id }) .from(kilo_pass_issuances) .where(eq(kilo_pass_issuances.kilo_pass_subscription_id, subscriptionId)) - .orderBy(desc(kilo_pass_issuances.issue_month)) + // Keep the existing latest-issuance interpretation; the id tie-breaker + // makes duplicate issue months deterministic. + .orderBy(desc(kilo_pass_issuances.issue_month), desc(kilo_pass_issuances.id)) .limit(1); const issuanceId = lastIssuance[0]?.id; - if (!issuanceId) return false; + if (!issuanceId) return null; - const unlockedItem = await db.query.kilo_pass_issuance_items.findFirst({ - columns: { id: true }, - where: and( - eq(kilo_pass_issuance_items.kilo_pass_issuance_id, issuanceId), - inArray(kilo_pass_issuance_items.kind, KILO_PASS_BONUS_LIKE_ITEM_KINDS) - ), - }); + const unlockedItems = await db + .select({ + kind: kilo_pass_issuance_items.kind, + amountUsd: kilo_pass_issuance_items.amount_usd, + }) + .from(kilo_pass_issuance_items) + .where( + and( + eq(kilo_pass_issuance_items.kilo_pass_issuance_id, issuanceId), + inArray(kilo_pass_issuance_items.kind, KILO_PASS_BONUS_LIKE_ITEM_KINDS) + ) + ) + .orderBy(asc(kilo_pass_issuance_items.kind)) + .limit(1); - return Boolean(unlockedItem); + const unlockedItem = unlockedItems[0]; + if (!unlockedItem) { + return null; + } + + switch (unlockedItem.kind) { + case KiloPassIssuanceItemKind.Bonus: + case KiloPassIssuanceItemKind.ReferralBonus: + case KiloPassIssuanceItemKind.PromoFirstMonth50Pct: + return { kind: unlockedItem.kind, amountUsd: unlockedItem.amountUsd }; + default: + return null; + } } async function getHasKiloPassThreshold(kiloUserId: string): Promise { @@ -750,7 +791,7 @@ async function buildActiveKiloPassSubscriptionState(params: { const baseAmountUsd = getMonthlyPriceUsd(params.subscription.tier); const [ isFirstTimeSubscriberEver, - isBonusUnlocked, + latestBonusLikeItem, hasKiloPassThreshold, latestBaseCredits, initialWelcomePromoContext, @@ -759,13 +800,14 @@ async function buildActiveKiloPassSubscriptionState(params: { kiloUserId: params.kiloUserId, subscriptionId: params.subscription.subscriptionId, }), - getIsBonusUnlockedForSubscriptionId(params.subscription.subscriptionId), + getLatestBonusLikeItemForSubscriptionId(params.subscription.subscriptionId), getHasKiloPassThreshold(params.kiloUserId), getLatestBaseCreditsForSubscription(params.subscription.subscriptionId), getInitialWelcomePromoContextForSubscription(db, { subscriptionId: params.subscription.subscriptionId, }), ]); + const isBonusUnlocked = latestBonusLikeItem !== null; const isBonusAvailableToUnlock = hasKiloPassThreshold && !isBonusUnlocked; const welcomePromoPolicy = getKiloPassWelcomePromoPolicy({ paymentProvider: params.subscription.paymentProvider, @@ -785,6 +827,16 @@ async function buildActiveKiloPassSubscriptionState(params: { usageBaselineMicrodollars: latestBaseCredits?.usageBaselineMicrodollars ?? null, }); + const projectedAmountUsd = + isBonusUnlocked || isBonusAvailableToUnlock + ? getCurrentKiloPassBonusCreditsUsd({ + subscription: params.subscription, + isFirstTimeSubscriberEver, + welcomePromoPolicy, + welcomePromoEligibilityReason, + }) + : null; + return { ...params.subscription, nextBonusCreditsUsd: getNextKiloPassBonusCreditsUsd({ @@ -798,15 +850,13 @@ async function buildActiveKiloPassSubscriptionState(params: { currentPeriodBaseCreditsUsd: baseAmountUsd, currentPeriodUsageUsd, currentPeriodHostingCostUsd, - currentPeriodBonusCreditsUsd: - isBonusUnlocked || isBonusAvailableToUnlock - ? getCurrentKiloPassBonusCreditsUsd({ - subscription: params.subscription, - isFirstTimeSubscriberEver, - welcomePromoPolicy, - welcomePromoEligibilityReason, - }) - : null, + currentPeriodBonusCreditsUsd: projectedAmountUsd, + currentPeriodBonus: { + status: isBonusUnlocked ? 'issued' : 'available', + kind: latestBonusLikeItem?.kind ?? null, + actualAmountUsd: latestBonusLikeItem?.amountUsd ?? null, + projectedAmountUsd, + }, isBonusUnlocked, isBonusAvailableToUnlock, refillAt: @@ -837,6 +887,12 @@ async function buildEndedKiloPassSubscriptionState(params: { currentPeriodUsageUsd: 0, currentPeriodHostingCostUsd: 0, currentPeriodBonusCreditsUsd: null, + currentPeriodBonus: { + status: 'available', + kind: null, + actualAmountUsd: null, + projectedAmountUsd: null, + }, isBonusUnlocked: false, isBonusAvailableToUnlock: false, refillAt: null,