Skip to content
Merged
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
23 changes: 14 additions & 9 deletions .specs/kilo-pass.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +16,50 @@ type RouterOutputs = inferRouterOutputs<RootRouter>;
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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -395,6 +397,156 @@ describe('KiloPassActiveSubscriptionCard.logic', () => {
});
});

describe('computeCurrentPeriodBonusModel()', () => {
function buildCurrentPeriodBonus(
overrides: Partial<KiloPassCurrentPeriodBonus>
): 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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { KiloPassCadence } from '@/lib/kilo-pass/enums';
import { getTierName } from './utils';
import {
computeCurrentPeriodBonusModel,
computeNextBillingDateRowDateLabel,
computeRenewInfoRowModel,
computeUsageProgressModel,
Expand Down Expand Up @@ -114,15 +115,15 @@ function RenewInfoRow() {
<div className="flex items-start gap-2">
<Calendar className="mt-0.5 h-4 w-4 text-white/40" />
<div className="text-muted-foreground">
{row.labelPrefix}: adds{' '}
{row.labelPrefix}:{' '}
<span className="font-mono font-semibold text-amber-300">
{formatDollars(row.baseUsd)}
</span>{' '}
paid +{' '}
paid + up to{' '}
<span className="font-mono font-semibold text-emerald-300">
{formatDollars(row.bonusUsd)}
</span>{' '}
free bonus credits
bonus credits
</div>
</div>
<span className="text-muted-foreground">{dateLabel}</span>
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -291,7 +294,7 @@ function UsageProgressOrBonusUnlocked() {
</div>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-sm bg-emerald-400/80" />
Free bonus
{bonus.label}
</div>
</div>
</div>
Expand Down
Loading
Loading