Skip to content

Commit 1d1539f

Browse files
authored
fix(kilo-pass): keep first month bonus for new subscribers (#3141)
1 parent 67e24ea commit 1d1539f

7 files changed

Lines changed: 75 additions & 65 deletions

File tree

apps/web/src/components/profile/kilo-pass/KiloPassSubscribeCard.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ export function KiloPassSubscribeCard(props: {
4747
KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString()
4848
);
4949
const monthlyPromoDescription =
50-
cadence === KiloPassCadence.Monthly && showSecondMonthPromo
51-
? `First-time subscribers receive 50% free bonus credits for the first two months. Offer expires ${promoCutoffLabel}.`
50+
cadence === KiloPassCadence.Monthly && showFirstMonthPromo
51+
? showSecondMonthPromo
52+
? `First-time subscribers receive 50% free bonus credits for the first two months when they start before ${promoCutoffLabel}.`
53+
: 'First-time subscribers receive 50% free bonus credits for the first month.'
5254
: null;
5355
const cadenceOptions = [
5456
{ value: KiloPassCadence.Monthly, label: 'Monthly' },

apps/web/src/components/subscriptions/kilo-pass/KiloPassDetail.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ export function KiloPassDetail() {
103103
return promoPercent === KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT;
104104
}, [subscription]);
105105

106+
const showSecondMonthPromoInDialog = useMemo(() => {
107+
if (!subscription || subscription.cadence !== 'monthly') return false;
108+
if (subscription.currentStreakMonths > 2) return false;
109+
const month2Percent = computeMonthlyCadenceBonusPercent({
110+
tier: subscription.tier,
111+
streakMonths: 2,
112+
isFirstTimeSubscriberEver: subscription.isFirstTimeSubscriberEver,
113+
subscriptionStartedAtIso: subscription.startedAt,
114+
});
115+
return month2Percent === KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT;
116+
}, [subscription]);
117+
106118
async function refreshData() {
107119
await Promise.all([
108120
queryClient.invalidateQueries({ queryKey: trpc.kiloPass.getState.queryKey() }),
@@ -236,7 +248,7 @@ export function KiloPassDetail() {
236248
<KiloPassBonusRampDialog
237249
tier={subscription.tier}
238250
showFirstMonthPromo={showFirstMonthPromoInDialog}
239-
showSecondMonthPromo={subscription.currentStreakMonths === 1}
251+
showSecondMonthPromo={showSecondMonthPromoInDialog}
240252
streakMonths={subscription.currentStreakMonths}
241253
subscriptionStartedAtIso={subscription.startedAt ?? undefined}
242254
/>

apps/web/src/lib/kilo-pass/bonus.test.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ describe('kilo pass bonus utilities', () => {
165165
});
166166

167167
describe('computeMonthlyCadenceBonusPercent', () => {
168+
it('keeps the second-month grandfather cutoff at midnight May 9 UTC', () => {
169+
expect(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString()).toBe(
170+
'2026-05-09T00:00:00.000Z'
171+
);
172+
});
173+
168174
it('applies the 50% promo for streak months 1 and 2 when eligible (strictly before cutoff)', () => {
169175
expect(
170176
computeMonthlyCadenceBonusPercent({
@@ -197,6 +203,19 @@ describe('kilo pass bonus utilities', () => {
197203
);
198204
});
199205

206+
it('applies the first-month promo for first-time subscribers after the grandfather cutoff', () => {
207+
expect(
208+
computeMonthlyCadenceBonusPercent({
209+
tier: KiloPassTier.Tier19,
210+
streakMonths: 1,
211+
isFirstTimeSubscriberEver: true,
212+
subscriptionStartedAtIso: new Date(
213+
KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.valueOf() + 1
214+
).toISOString(),
215+
})
216+
).toBeCloseTo(KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT);
217+
});
218+
200219
it('does not apply the override when isFirstTimeSubscriberEver is false', () => {
201220
expect(
202221
computeMonthlyCadenceBonusPercent({
@@ -223,26 +242,29 @@ describe('kilo pass bonus utilities', () => {
223242
});
224243
};
225244

226-
it('is ineligible at cutoff and returns the fallback value (month 1)', () => {
245+
it('applies the first-month promo at the second-month grandfather cutoff', () => {
227246
expect(
228247
computeMonthlyCadenceBonusPercent({
229248
tier,
230249
streakMonths: 1,
231250
isFirstTimeSubscriberEver: true,
232251
subscriptionStartedAtIso: KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString(),
233252
})
234-
).toBe(computeFallback({ streakMonths: 1, isFirstTimeSubscriberEver: true }));
253+
).toBe(KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT);
235254
});
236255

237-
it('is ineligible at cutoff and returns the fallback value (month 2)', () => {
256+
it('does not apply the second-month promo at the grandfather cutoff', () => {
238257
expect(
239258
computeMonthlyCadenceBonusPercent({
240259
tier,
241260
streakMonths: 2,
242261
isFirstTimeSubscriberEver: true,
243262
subscriptionStartedAtIso: KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toISOString(),
244263
})
245-
).toBe(computeFallback({ streakMonths: 2, isFirstTimeSubscriberEver: true }));
264+
).toBeCloseTo(
265+
KILO_PASS_TIER_CONFIG.tier_49.monthlyBaseBonusPercent +
266+
KILO_PASS_TIER_CONFIG.tier_49.monthlyStepBonusPercent
267+
);
246268
});
247269

248270
it('does not apply promo when isFirstTimeSubscriberEver is false', () => {

apps/web/src/lib/kilo-pass/bonus.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,13 @@ export const computeMonthlyCadenceBonusPercent = (params: {
3636
throw new Error('streakMonths must be >= 1');
3737
}
3838

39-
// Limited-time promo: first-time subscribers who started strictly before the cutoff
40-
// get a 50% bonus for streak months 1 and 2.
41-
if (streakMonths <= 2 && isFirstTimeSubscriberEver) {
39+
if (streakMonths === 1 && isFirstTimeSubscriberEver) {
40+
return KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT;
41+
}
42+
43+
// Limited-time grandfathered promo: first-time subscribers who started strictly before the
44+
// cutoff keep the 50% bonus for streak month 2.
45+
if (streakMonths === 2 && isFirstTimeSubscriberEver) {
4246
const startedAt = subscriptionStartedAtIso ?? null;
4347
if (startedAt != null) {
4448
const startedAtUtc = dayjs(startedAt).utc();
@@ -50,11 +54,6 @@ export const computeMonthlyCadenceBonusPercent = (params: {
5054
return KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_BONUS_PERCENT;
5155
}
5256
}
53-
54-
// Back-compat: if we don't have a start timestamp, still show the first month promo.
55-
if (streakMonths === 1) {
56-
return KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT;
57-
}
5857
}
5958

6059
const config = KILO_PASS_TIER_CONFIG[tier];

apps/web/src/lib/kilo-pass/constants.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ type KiloPassTierConfig = {
1010

1111
export const KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT = 0.5;
1212

13-
// First-time subscribers receive a 50% bonus for the first 2 months if they started
14-
// strictly before this cutoff. (For PST, Incorporating DST)
15-
export const KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF = dayjs('2026-05-07T15:53:43Z').utc();
13+
// First-time subscribers receive a 50% bonus for month 2 only if they started
14+
// strictly before this grandfather cutoff. Month 1 remains 50% for new subscribers.
15+
export const KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF = dayjs('2026-05-09T00:00:00Z').utc();
1616

1717
export const KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_BONUS_PERCENT = 0.5;
1818

apps/web/src/routers/kilo-pass-router.test.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,7 @@ describe('kiloPassRouter', () => {
297297

298298
expect(result).toEqual({
299299
subscription: null,
300-
isEligibleForFirstMonthPromo:
301-
new Date() < KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toDate(),
300+
isEligibleForFirstMonthPromo: true,
302301
});
303302
});
304303

@@ -744,28 +743,20 @@ describe('kiloPassRouter', () => {
744743
const caller = await createCallerForUser(user.id);
745744
const result = await caller.kiloPass.getState();
746745

747-
expect(result.isEligibleForFirstMonthPromo).toBe(
748-
new Date() < KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toDate()
749-
);
746+
expect(result.isEligibleForFirstMonthPromo).toBe(true);
750747
expect(result.subscription).toBeNull();
751748
});
752749

753-
it('returns isEligibleForFirstMonthPromo=false after the promo cutoff', async () => {
754-
// If the current time is at or after the cutoff, even a user with no subscriptions
755-
// should see isEligibleForFirstMonthPromo=false.
756-
const now = new Date();
757-
const cutoff = KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF.toDate();
758-
if (now >= cutoff) {
759-
const user = await insertTestUser({
760-
google_user_email: 'kilo-pass-promo-cutoff-ineligible@example.com',
761-
});
762-
763-
const caller = await createCallerForUser(user.id);
764-
const result = await caller.kiloPass.getState();
765-
766-
expect(result.isEligibleForFirstMonthPromo).toBe(false);
767-
expect(result.subscription).toBeNull();
768-
}
750+
it('keeps isEligibleForFirstMonthPromo=true for a never-subscribed user', async () => {
751+
const user = await insertTestUser({
752+
google_user_email: 'kilo-pass-promo-cutoff-still-eligible@example.com',
753+
});
754+
755+
const caller = await createCallerForUser(user.id);
756+
const result = await caller.kiloPass.getState();
757+
758+
expect(result.isEligibleForFirstMonthPromo).toBe(true);
759+
expect(result.subscription).toBeNull();
769760
});
770761

771762
it('returns isEligibleForFirstMonthPromo=false when user has a canceled subscription', async () => {

apps/web/src/routers/kilo-pass-router.ts

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,7 @@ import { KiloPassError } from '@/lib/kilo-pass/errors';
3232
import { isStripeSubscriptionEnded } from '@/lib/kilo-pass/stripe-subscription-status';
3333
import { releaseScheduledChangeForSubscription } from '@/lib/kilo-pass/scheduled-change-release';
3434
import { appendKiloPassAuditLog } from '@/lib/kilo-pass/issuance';
35-
import {
36-
KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT,
37-
KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF,
38-
KILO_PASS_TIER_CONFIG,
39-
} from '@/lib/kilo-pass/constants';
35+
import { KILO_PASS_TIER_CONFIG } from '@/lib/kilo-pass/constants';
4036
import { fromMicrodollars } from '@/lib/utils';
4137
import { timedUsageQuery } from '@/lib/usage-query';
4238
import {
@@ -131,10 +127,6 @@ const GetAverageMonthlyUsageLast3MonthsOutputSchema = z.object({
131127
averageMonthlyUsageUsd: z.number(),
132128
});
133129

134-
function isTwoMonthPromoOfferActive(): boolean {
135-
return dayjs().utc().isBefore(KILO_PASS_MONTHLY_FIRST_2_MONTHS_PROMO_CUTOFF);
136-
}
137-
138130
function roundToCents(usd: number): number {
139131
return Math.round(usd * 100) / 100;
140132
}
@@ -384,7 +376,7 @@ export const kiloPassRouter = createTRPCRouter({
384376
getState: baseProcedure.output(GetStateOutputSchema).query(async ({ ctx }) => {
385377
const subscriptionBase = await getKiloPassStateForUser(db, ctx.user.id);
386378
if (!subscriptionBase) {
387-
return { subscription: null, isEligibleForFirstMonthPromo: isTwoMonthPromoOfferActive() };
379+
return { subscription: null, isEligibleForFirstMonthPromo: true };
388380
}
389381

390382
const stripeCustomerId = ctx.user.stripe_customer_id;
@@ -478,22 +470,14 @@ export const kiloPassRouter = createTRPCRouter({
478470
currentPeriodBonusCreditsUsd = roundToCents(usd);
479471
} else {
480472
const streakMonths = Math.max(1, subscriptionBase.currentStreakMonths);
481-
const shouldShowFirstMonthPromo =
482-
streakMonths === 1 && isFirstTimeSubscriberEver && isTwoMonthPromoOfferActive();
483-
484-
if (shouldShowFirstMonthPromo) {
485-
const cents = Math.round(baseAmountUsd * KILO_PASS_FIRST_MONTH_PROMO_BONUS_PERCENT * 100);
486-
currentPeriodBonusCreditsUsd = cents / 100;
487-
} else {
488-
const bonusPercentApplied = computeMonthlyCadenceBonusPercent({
489-
tier: subscriptionBase.tier,
490-
streakMonths,
491-
isFirstTimeSubscriberEver,
492-
subscriptionStartedAtIso: subscriptionBase.startedAt,
493-
});
494-
const cents = Math.round(baseAmountUsd * bonusPercentApplied * 100);
495-
currentPeriodBonusCreditsUsd = cents / 100;
496-
}
473+
const bonusPercentApplied = computeMonthlyCadenceBonusPercent({
474+
tier: subscriptionBase.tier,
475+
streakMonths,
476+
isFirstTimeSubscriberEver,
477+
subscriptionStartedAtIso: subscriptionBase.startedAt,
478+
});
479+
const cents = Math.round(baseAmountUsd * bonusPercentApplied * 100);
480+
currentPeriodBonusCreditsUsd = cents / 100;
497481
}
498482

499483
const nowUtc = dayjs().utc();

0 commit comments

Comments
 (0)