Skip to content

Commit d762eef

Browse files
Merge pull request #42 from InstaNode-dev/pricing/p2-annual-toggle-fresh
BillingPage: monthly/yearly toggle + save-badge per plan (P2)
2 parents 024d4cf + 915c9ab commit d762eef

5 files changed

Lines changed: 596 additions & 104 deletions

File tree

src/api/index.test.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -311,17 +311,25 @@ describe('createCheckout()', () => {
311311
expect(r).toEqual({ ok: true, short_url: 'https://rzp.io/i/abc', subscription_id: 'sub_123' })
312312
})
313313

314-
it('POSTs to /api/v1/billing/checkout with the plan in the body', async () => {
314+
it('POSTs to /api/v1/billing/checkout with the plan and default monthly frequency', async () => {
315315
const m = installFetch()
316316
m.mockResolvedValueOnce(jsonResponse({ ok: true, short_url: 'https://rzp.io/i/abc' }))
317317
await createCheckout('pro')
318318
const [url, init] = m.mock.calls[0]
319319
expect(String(url)).toContain('/api/v1/billing/checkout')
320320
expect(init.method).toBe('POST')
321-
expect(init.body).toBe(JSON.stringify({ plan: 'pro' }))
321+
expect(init.body).toBe(JSON.stringify({ plan: 'pro', plan_frequency: 'monthly' }))
322322
expect((init.headers as Headers).get('Content-Type')).toBe('application/json')
323323
})
324324

325+
it('sends plan_frequency: yearly when the caller opts into annual billing', async () => {
326+
const m = installFetch()
327+
m.mockResolvedValueOnce(jsonResponse({ ok: true, short_url: 'https://rzp.io/i/year' }))
328+
await createCheckout('pro', 'yearly')
329+
const init = m.mock.calls[0][1]
330+
expect(init.body).toBe(JSON.stringify({ plan: 'pro', plan_frequency: 'yearly' }))
331+
})
332+
325333
it('omits subscription_id when the API does not return one', async () => {
326334
const m = installFetch()
327335
m.mockResolvedValueOnce(jsonResponse({ ok: true, short_url: 'https://rzp.io/i/abc' }))
@@ -347,17 +355,19 @@ describe('createCheckout()', () => {
347355
m.mockResolvedValueOnce(jsonResponse({ ok: true, short_url: 'https://rzp.io/i/xyz' }))
348356
await createCheckout('team')
349357
const init = m.mock.calls[0][1]
350-
expect(init.body).toBe(JSON.stringify({ plan: 'team' }))
358+
expect(init.body).toBe(JSON.stringify({ plan: 'team', plan_frequency: 'monthly' }))
351359
})
352360

353361
// P3: opts.promotion_code only appears in the body when actually passed.
362+
// Merged signature is (plan, planFrequency, opts) — frequency defaults
363+
// to 'monthly' so plan_frequency always appears in the body.
354364
it('includes promotion_code in the body when supplied (P3)', async () => {
355365
const m = installFetch()
356366
m.mockResolvedValueOnce(jsonResponse({ ok: true, short_url: 'https://rzp.io/i/p3' }))
357-
await createCheckout('pro', { promotion_code: 'TWITTER15' })
367+
await createCheckout('pro', 'monthly', { promotion_code: 'TWITTER15' })
358368
const init = m.mock.calls[0][1]
359369
expect(JSON.parse(init.body as string)).toEqual({
360-
plan: 'pro', promotion_code: 'TWITTER15',
370+
plan: 'pro', plan_frequency: 'monthly', promotion_code: 'TWITTER15',
361371
})
362372
})
363373

@@ -367,14 +377,14 @@ describe('createCheckout()', () => {
367377
await createCheckout('pro')
368378
const init = m.mock.calls[0][1]
369379
const body = JSON.parse(init.body as string)
370-
expect(body).toEqual({ plan: 'pro' })
380+
expect(body).toEqual({ plan: 'pro', plan_frequency: 'monthly' })
371381
expect('promotion_code' in body).toBe(false)
372382
})
373383

374384
it('drops an empty / whitespace-only promotion_code (P3)', async () => {
375385
const m = installFetch()
376386
m.mockResolvedValueOnce(jsonResponse({ ok: true, short_url: 'https://rzp.io/i/p3' }))
377-
await createCheckout('pro', { promotion_code: ' ' })
387+
await createCheckout('pro', 'monthly', { promotion_code: ' ' })
378388
const init = m.mock.calls[0][1]
379389
const body = JSON.parse(init.body as string)
380390
expect('promotion_code' in body).toBe(false)

src/api/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -762,11 +762,20 @@ export async function listInvoices(): Promise<{ ok: true; invoices: Invoice[] }>
762762
return { ok: true, invoices: r.invoices ?? [] }
763763
}
764764

765+
// PlanFrequency selects between the monthly and yearly Razorpay plan_id at
766+
// checkout. The agent API rejects anything other than 'monthly' | 'yearly'
767+
// with 400 invalid_frequency, and returns 503 billing_not_configured when
768+
// the yearly plan_id env var isn't set on the server (operator action
769+
// pending). Defaulting to 'monthly' on omission keeps the upgrade path
770+
// behaving as it did before the toggle shipped.
771+
export type PlanFrequency = 'monthly' | 'yearly'
772+
765773
export async function createCheckout(
766774
plan: string,
775+
planFrequency: PlanFrequency = 'monthly',
767776
opts?: { promotion_code?: string },
768777
): Promise<{ ok: true; short_url: string; subscription_id?: string }> {
769-
const body: Record<string, unknown> = { plan }
778+
const body: Record<string, unknown> = { plan, plan_frequency: planFrequency }
770779
// Only include the promotion_code field when the caller actually passed
771780
// one — sending an empty string would cause the api to treat it as an
772781
// invalid promo and reject the checkout. Trimming guards against UI

src/pages/BillingPage.test.tsx

Lines changed: 116 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -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: /upgrade to team/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: /change plan/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 ────────────────────────────────────────────────────
367339
describe('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: /upgrade to pro/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: /upgrade to team/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: /upgrade to pro/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(/save \$98\/yr on pro/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: /upgrade to pro/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(/\$490\/yr/)
685+
expect(eff.textContent).toMatch(/\$40\.83\/mo/)
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: /change plan/i })).toBeTruthy()
707+
})
708+
expect(screen.queryByTestId('billing-frequency-toggle')).toBeNull()
709+
})
710+
})
711+
648712
describe('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: /upgrade to pro/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: /upgrade to pro/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

Comments
 (0)