@@ -76,9 +76,8 @@ vi.mock('../api', async () => {
7676 fetchBillingUsage : vi . fn ( ) ,
7777 createCheckout : vi . fn ( ) ,
7878 cancelSubscription : vi . fn ( ) ,
79- // P3: discount-code validation. Tests provide per-code responses; the
80- // default mock returned by mockHappyBilling resolves to "not found" so
81- // a test that forgets to set it up gets an honest red state.
79+ // P3: discount-code path validates with the api before applying the
80+ // code to checkout. Mocked so tests can drive both ok + error shapes.
8281 validatePromotion : vi . fn ( ) ,
8382 }
8483} )
@@ -297,33 +296,6 @@ describe('BillingPage — initial render', () => {
297296 expect ( screen . getByRole ( 'button' , { name : / u p g r a d e t o t e a m / i } ) ) . toBeTruthy ( )
298297 } )
299298
300- // U2: the BillingPage CTA stays generic (this *is* the primary billing
301- // surface) but should expose a bulleted list of what the next tier unlocks
302- // above the button so the user knows what they're paying for.
303- it ( 'renders the "what <next-tier> unlocks" bullet list for hobby users' , async ( ) => {
304- mockTier = 'hobby'
305- mockHappyBilling ( )
306- render ( < BillingPage /> )
307- await waitForLoaded ( )
308- const panel = screen . getByTestId ( 'next-tier-unlocks' )
309- expect ( panel ) . toBeTruthy ( )
310- // At least one feature from the next-tier plan is rendered as a li.
311- expect ( panel . querySelectorAll ( 'li' ) . length ) . toBeGreaterThan ( 0 )
312- // Heading copy includes the next-tier label (lowercase).
313- expect ( panel . textContent ?. toLowerCase ( ) ) . toContain ( 'what pro unlocks' )
314- } )
315-
316- it ( 'hides the unlocks panel for team-tier users (no nextTier)' , async ( ) => {
317- mockTier = 'team'
318- mockHappyBilling ( )
319- render ( < BillingPage /> )
320- // Team-tier's CTA reads "Change plan" (disabled), not "Upgrade to …".
321- await waitFor ( ( ) => {
322- expect ( screen . getByRole ( 'button' , { name : / c h a n g e p l a n / i } ) ) . toBeTruthy ( )
323- } )
324- expect ( screen . queryByTestId ( 'next-tier-unlocks' ) ) . toBeNull ( )
325- } )
326-
327299 it ( 'renders the payment method line from billing.payment_last4' , async ( ) => {
328300 mockTier = 'hobby'
329301 mockHappyBilling ( )
@@ -365,7 +337,7 @@ describe('BillingPage — initial render', () => {
365337
366338// ─── handleChangePlan ────────────────────────────────────────────────────
367339describe ( 'BillingPage — handleChangePlan (upgrade flow)' , ( ) => {
368- it ( 'calls api.createCheckout("pro") when user is on hobby and clicks Upgrade' , async ( ) => {
340+ it ( 'calls api.createCheckout("pro", "monthly" ) when user is on hobby and clicks Upgrade' , async ( ) => {
369341 mockTier = 'hobby'
370342 mockHappyBilling ( )
371343 ; ( api . createCheckout as any ) . mockResolvedValue ( {
@@ -375,10 +347,12 @@ describe('BillingPage — handleChangePlan (upgrade flow)', () => {
375347 await waitForLoaded ( )
376348 fireEvent . click ( screen . getByRole ( 'button' , { name : / u p g r a d e t o p r o / i } ) )
377349 await waitFor ( ( ) => expect ( api . createCheckout ) . toHaveBeenCalledTimes ( 1 ) )
378- expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'pro' )
350+ // P2: BillingPage passes plan_frequency through to api.createCheckout.
351+ // Monthly is the default unless the toggle was switched.
352+ expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'pro' , 'monthly' )
379353 } )
380354
381- it ( 'calls api.createCheckout("team") when user is on pro' , async ( ) => {
355+ it ( 'calls api.createCheckout("team", "monthly" ) when user is on pro' , async ( ) => {
382356 mockTier = 'pro'
383357 mockHappyBilling ( )
384358 ; ( api . createCheckout as any ) . mockResolvedValue ( {
@@ -387,7 +361,7 @@ describe('BillingPage — handleChangePlan (upgrade flow)', () => {
387361 render ( < BillingPage /> )
388362 await waitForLoaded ( )
389363 fireEvent . click ( screen . getByRole ( 'button' , { name : / u p g r a d e t o t e a m / i } ) )
390- await waitFor ( ( ) => expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'team' ) )
364+ await waitFor ( ( ) => expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'team' , 'monthly' ) )
391365 } )
392366
393367 it ( 'redirects via window.location.href when short_url is returned' , async ( ) => {
@@ -520,7 +494,7 @@ describe('BillingPage — userEvent integration', () => {
520494 render ( < BillingPage /> )
521495 await waitForLoaded ( )
522496 await user . click ( screen . getByRole ( 'button' , { name : / u p g r a d e t o p r o / i } ) )
523- await waitFor ( ( ) => expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'pro' ) )
497+ await waitFor ( ( ) => expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'pro' , 'monthly' ) )
524498 await waitFor ( ( ) => expect ( hrefSetTo ) . toBe ( 'https://rzp.io/i/ue' ) )
525499 } )
526500} )
@@ -636,15 +610,105 @@ describe('BillingPage — §10.8 leak fixes', () => {
636610 } )
637611} )
638612
639- // ─── P3: discount-code input on the upgrade flow ─────────────────────────
640- // Covers the contract:
641- // - "Have a discount code?" toggle renders for tiers with a next-tier
642- // - valid code (mocked api.validatePromotion success) → green applied state
643- // - invalid code → red error state surfaces the api message
644- // - applied code persists into the createCheckout body as promotion_code
645- //
646- // Cancellation continues to be support-only (covered above); these tests
647- // don't loosen that contract.
613+ // ─── P2: monthly/yearly billing toggle ──────────────────────────────────
614+ describe ( 'BillingPage — monthly/yearly toggle' , ( ) => {
615+ // The toggle persists in localStorage. Clear between tests so state
616+ // from one case doesn't bleed into the next.
617+ beforeEach ( ( ) => {
618+ try { window . localStorage . removeItem ( 'instant.billing.plan_frequency' ) } catch { }
619+ } )
620+
621+ it ( 'renders the toggle with monthly selected by default' , async ( ) => {
622+ mockTier = 'hobby'
623+ mockHappyBilling ( )
624+ render ( < BillingPage /> )
625+ await waitForLoaded ( )
626+ const toggle = screen . getByTestId ( 'billing-frequency-toggle' )
627+ expect ( toggle ) . toBeTruthy ( )
628+ const monthly = screen . getByTestId ( 'frequency-monthly' )
629+ const yearly = screen . getByTestId ( 'frequency-yearly' )
630+ expect ( monthly . getAttribute ( 'aria-checked' ) ) . toBe ( 'true' )
631+ expect ( yearly . getAttribute ( 'aria-checked' ) ) . toBe ( 'false' )
632+ } )
633+
634+ it ( 'renders the save-$X/yr badge for the nextTier when the toggle is shown' , async ( ) => {
635+ mockTier = 'hobby'
636+ mockHappyBilling ( )
637+ render ( < BillingPage /> )
638+ await waitForLoaded ( )
639+ const badge = screen . getByTestId ( 'frequency-save-badge' )
640+ expect ( badge . textContent ) . toMatch ( / s a v e \$ 9 8 \/ y r o n p r o / i)
641+ } )
642+
643+ it ( 'passes plan_frequency=yearly to createCheckout when yearly is selected' , async ( ) => {
644+ mockTier = 'hobby'
645+ mockHappyBilling ( )
646+ ; ( api . createCheckout as any ) . mockResolvedValue ( {
647+ ok : true , short_url : 'https://rzp.io/i/yr' ,
648+ } )
649+ render ( < BillingPage /> )
650+ await waitForLoaded ( )
651+ fireEvent . click ( screen . getByTestId ( 'frequency-yearly' ) )
652+ fireEvent . click ( screen . getByRole ( 'button' , { name : / u p g r a d e t o p r o / i } ) )
653+ await waitFor ( ( ) => expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'pro' , 'yearly' ) )
654+ } )
655+
656+ it ( 'persists the selected frequency in localStorage so it sticks across refreshes' , async ( ) => {
657+ mockTier = 'hobby'
658+ mockHappyBilling ( )
659+ render ( < BillingPage /> )
660+ await waitForLoaded ( )
661+ fireEvent . click ( screen . getByTestId ( 'frequency-yearly' ) )
662+ expect ( window . localStorage . getItem ( 'instant.billing.plan_frequency' ) ) . toBe ( 'yearly' )
663+ fireEvent . click ( screen . getByTestId ( 'frequency-monthly' ) )
664+ expect ( window . localStorage . getItem ( 'instant.billing.plan_frequency' ) ) . toBe ( 'monthly' )
665+ } )
666+
667+ it ( 'rehydrates yearly from localStorage on subsequent mounts' , async ( ) => {
668+ mockTier = 'hobby'
669+ mockHappyBilling ( )
670+ window . localStorage . setItem ( 'instant.billing.plan_frequency' , 'yearly' )
671+ render ( < BillingPage /> )
672+ await waitForLoaded ( )
673+ const yearly = screen . getByTestId ( 'frequency-yearly' )
674+ expect ( yearly . getAttribute ( 'aria-checked' ) ) . toBe ( 'true' )
675+ } )
676+
677+ it ( 'shows the effective per-month + annual total when yearly is active' , async ( ) => {
678+ mockTier = 'hobby'
679+ mockHappyBilling ( )
680+ window . localStorage . setItem ( 'instant.billing.plan_frequency' , 'yearly' )
681+ render ( < BillingPage /> )
682+ await waitForLoaded ( )
683+ const eff = screen . getByTestId ( 'frequency-effective-price' )
684+ expect ( eff . textContent ) . toMatch ( / \$ 4 9 0 \/ y r / )
685+ expect ( eff . textContent ) . toMatch ( / \$ 4 0 \. 8 3 \/ m o / )
686+ } )
687+
688+ it ( 'renames the Upgrade button to include "(yearly)" when yearly is selected' , async ( ) => {
689+ mockTier = 'hobby'
690+ mockHappyBilling ( )
691+ window . localStorage . setItem ( 'instant.billing.plan_frequency' , 'yearly' )
692+ render ( < BillingPage /> )
693+ await waitForLoaded ( )
694+ const btn = screen . getByTestId ( 'upgrade-button' )
695+ expect ( btn . textContent ?. toLowerCase ( ) ) . toContain ( 'yearly' )
696+ } )
697+
698+ it ( 'does not render the toggle when there is no nextTier (team-tier user)' , async ( ) => {
699+ mockTier = 'team'
700+ mockHappyBilling ( )
701+ render ( < BillingPage /> )
702+ // Team users have no "Upgrade to" button (plan.nextTier is undefined),
703+ // so waitForLoaded() can't find one — wait for the Change plan button
704+ // instead, which is rendered in the same place.
705+ await waitFor ( ( ) => {
706+ expect ( screen . queryByRole ( 'button' , { name : / c h a n g e p l a n / i } ) ) . toBeTruthy ( )
707+ } )
708+ expect ( screen . queryByTestId ( 'billing-frequency-toggle' ) ) . toBeNull ( )
709+ } )
710+ } )
711+
648712describe ( 'BillingPage — discount code on checkout flow (P3)' , ( ) => {
649713 it ( 'renders the "Have a discount code?" toggle when a next-tier exists' , async ( ) => {
650714 mockTier = 'hobby'
@@ -786,7 +850,9 @@ describe('BillingPage — discount code on checkout flow (P3)', () => {
786850 await waitFor ( ( ) => expect ( screen . queryByTestId ( 'promo-applied' ) ) . toBeTruthy ( ) )
787851 fireEvent . click ( screen . getByRole ( 'button' , { name : / u p g r a d e t o p r o / i } ) )
788852 await waitFor ( ( ) => expect ( api . createCheckout ) . toHaveBeenCalledTimes ( 1 ) )
789- expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'pro' , { promotion_code : 'TWITTER15' } )
853+ // Merged signature: (plan, plan_frequency, opts). Frequency defaults
854+ // to 'monthly' (P2 toggle is not touched in this test).
855+ expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'pro' , 'monthly' , { promotion_code : 'TWITTER15' } )
790856 } )
791857
792858 it ( 'does NOT pass promotion_code to createCheckout when no code is applied' , async ( ) => {
@@ -800,10 +866,11 @@ describe('BillingPage — discount code on checkout flow (P3)', () => {
800866 // Click upgrade without ever touching the discount-code toggle.
801867 fireEvent . click ( screen . getByRole ( 'button' , { name : / u p g r a d e t o p r o / i } ) )
802868 await waitFor ( ( ) => expect ( api . createCheckout ) . toHaveBeenCalledTimes ( 1 ) )
803- // Strict single-arg signature — the pre-P3 contract holds when no
804- // promo is applied. This guards against a regression where every
805- // upgrade silently grows a second arg even if it's empty.
806- expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'pro' )
869+ // Strict signature when no promo is applied — frequency defaults to
870+ // 'monthly' (P2 merge). No opts third arg, so the call shape is
871+ // exactly two positional args. Guards against a regression where
872+ // every upgrade silently grows an empty opts object.
873+ expect ( api . createCheckout ) . toHaveBeenCalledWith ( 'pro' , 'monthly' )
807874 } )
808875
809876 it ( 'Remove clears the applied code and lets the user enter a different one' , async ( ) => {
0 commit comments