Skip to content

Commit deabe0b

Browse files
Merge remote-tracking branch 'origin/main' into pricing/u2-context-prompts-fresh
# Conflicts: # src/pages/BillingPage.tsx
2 parents 2df8ebd + c097702 commit deabe0b

4 files changed

Lines changed: 664 additions & 3 deletions

File tree

src/api/index.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
fetchStackFamily,
3131
listDeployments,
3232
getDeployment,
33+
validatePromotion,
3334
} from './index'
3435
// §10.21: FIXTURE_BILLING / FIXTURE_INVOICES imports retired. The 503
3536
// fallback paths in fetchBilling() and listInvoices() were removed —
@@ -347,6 +348,120 @@ describe('createCheckout()', () => {
347348
const init = m.mock.calls[0][1]
348349
expect(init.body).toBe(JSON.stringify({ plan: 'team' }))
349350
})
351+
352+
// P3: opts.promotion_code only appears in the body when actually passed.
353+
it('includes promotion_code in the body when supplied (P3)', async () => {
354+
const m = installFetch()
355+
m.mockResolvedValueOnce(jsonResponse({ ok: true, short_url: 'https://rzp.io/i/p3' }))
356+
await createCheckout('pro', { promotion_code: 'TWITTER15' })
357+
const init = m.mock.calls[0][1]
358+
expect(JSON.parse(init.body as string)).toEqual({
359+
plan: 'pro', promotion_code: 'TWITTER15',
360+
})
361+
})
362+
363+
it('drops promotion_code from the body when not supplied (P3)', async () => {
364+
const m = installFetch()
365+
m.mockResolvedValueOnce(jsonResponse({ ok: true, short_url: 'https://rzp.io/i/p3' }))
366+
await createCheckout('pro')
367+
const init = m.mock.calls[0][1]
368+
const body = JSON.parse(init.body as string)
369+
expect(body).toEqual({ plan: 'pro' })
370+
expect('promotion_code' in body).toBe(false)
371+
})
372+
373+
it('drops an empty / whitespace-only promotion_code (P3)', async () => {
374+
const m = installFetch()
375+
m.mockResolvedValueOnce(jsonResponse({ ok: true, short_url: 'https://rzp.io/i/p3' }))
376+
await createCheckout('pro', { promotion_code: ' ' })
377+
const init = m.mock.calls[0][1]
378+
const body = JSON.parse(init.body as string)
379+
expect('promotion_code' in body).toBe(false)
380+
})
381+
})
382+
383+
// ─── validatePromotion() (P3) ────────────────────────────────────────────
384+
// Until api ships POST /api/v1/billing/promotion/validate, this helper
385+
// falls back to a small set of seed codes on a 404. The mock + fallback
386+
// path together must:
387+
// - return a Promotion shape when the api responds 200
388+
// - return the seed Promotion for a known seed code when the api 404s
389+
// - throw promotion_not_found for an unknown code when the api 404s
390+
// - propagate non-404 errors (e.g. 410 expired) untouched
391+
describe('validatePromotion() (P3)', () => {
392+
it('returns the api Promotion on a 200 response', async () => {
393+
const m = installFetch()
394+
m.mockResolvedValueOnce(jsonResponse({
395+
ok: true,
396+
code: 'PARTNER25',
397+
discount: { kind: 'percent_off', value: 25, applies_to: 6, unit: 'months' },
398+
valid_until: '2026-12-31T00:00:00Z',
399+
}))
400+
const r = await validatePromotion('PARTNER25', 'pro')
401+
expect(r.promotion.code).toBe('PARTNER25')
402+
expect(r.promotion.discount).toEqual({ kind: 'percent_off', value: 25, applies_to: 6, unit: 'months' })
403+
expect(r.promotion.valid_until).toBe('2026-12-31T00:00:00Z')
404+
})
405+
406+
it('POSTs {code, plan} to /api/v1/billing/promotion/validate (uppercased + trimmed)', async () => {
407+
const m = installFetch()
408+
m.mockResolvedValueOnce(jsonResponse({
409+
ok: true,
410+
code: 'TWITTER15',
411+
discount: { kind: 'percent_off', value: 15, applies_to: 3, unit: 'months' },
412+
valid_until: '2026-09-01T00:00:00Z',
413+
}))
414+
await validatePromotion(' twitter15 ', 'pro')
415+
const [url, init] = m.mock.calls[0]
416+
expect(String(url)).toContain('/api/v1/billing/promotion/validate')
417+
expect(init.method).toBe('POST')
418+
expect(JSON.parse(init.body as string)).toEqual({ code: 'TWITTER15', plan: 'pro' })
419+
})
420+
421+
it('falls back to the seed table when the api 404s on a known seed code', async () => {
422+
const m = installFetch()
423+
m.mockResolvedValueOnce(jsonResponse(
424+
{ error: 'not_found', message: 'no such route' },
425+
{ status: 404, statusText: 'Not Found' },
426+
))
427+
const r = await validatePromotion('TWITTER15', 'pro')
428+
expect(r.promotion.code).toBe('TWITTER15')
429+
expect(r.promotion.discount.kind).toBe('percent_off')
430+
expect(r.promotion.discount.value).toBe(15)
431+
})
432+
433+
it('throws promotion_not_found on 404 for an unknown code', async () => {
434+
const m = installFetch()
435+
m.mockResolvedValueOnce(jsonResponse(
436+
{ error: 'not_found' },
437+
{ status: 404, statusText: 'Not Found' },
438+
))
439+
await expect(validatePromotion('NONEXISTENT', 'pro')).rejects.toMatchObject({
440+
status: 404,
441+
code: 'promotion_not_found',
442+
})
443+
})
444+
445+
it('propagates 410 expired errors with the api message', async () => {
446+
const m = installFetch()
447+
m.mockResolvedValueOnce(jsonResponse(
448+
{ error: 'promotion_expired', message: 'This code has expired.' },
449+
{ status: 410, statusText: 'Gone' },
450+
))
451+
await expect(validatePromotion('OLDCODE', 'pro')).rejects.toMatchObject({
452+
status: 410,
453+
code: 'promotion_expired',
454+
})
455+
})
456+
457+
it('rejects with promotion_invalid for an empty input (no api call)', async () => {
458+
const m = installFetch()
459+
await expect(validatePromotion(' ', 'pro')).rejects.toMatchObject({
460+
status: 400,
461+
code: 'promotion_invalid',
462+
})
463+
expect(m).not.toHaveBeenCalled()
464+
})
350465
})
351466

352467
// ─── cancelSubscription() ────────────────────────────────────────────────

src/api/index.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,14 +732,108 @@ export async function listInvoices(): Promise<{ ok: true; invoices: Invoice[] }>
732732

733733
export async function createCheckout(
734734
plan: string,
735+
opts?: { promotion_code?: string },
735736
): Promise<{ ok: true; short_url: string; subscription_id?: string }> {
737+
const body: Record<string, unknown> = { plan }
738+
// Only include the promotion_code field when the caller actually passed
739+
// one — sending an empty string would cause the api to treat it as an
740+
// invalid promo and reject the checkout. Trimming guards against UI
741+
// whitespace leaks (the dashboard already trims, this is belt+braces).
742+
const code = opts?.promotion_code?.trim()
743+
if (code) body.promotion_code = code
736744
const r = await call<{ ok: boolean; short_url: string; subscription_id?: string }>(
737745
'/api/v1/billing/checkout',
738-
{ method: 'POST', body: JSON.stringify({ plan }) },
746+
{ method: 'POST', body: JSON.stringify(body) },
739747
)
740748
return { ok: true, short_url: r.short_url, subscription_id: r.subscription_id }
741749
}
742750

751+
// ─── Promotion validation (P3 — mocked until api ships endpoint) ────────
752+
//
753+
// Contract proposed for `POST /api/v1/billing/promotion/validate`:
754+
// request: { code: string, plan: string }
755+
// response: { ok: true, code, discount: { kind: "percent_off" | "amount_off"
756+
// | "free_period",
757+
// value: number,
758+
// applies_to?: number,
759+
// unit?: "months" | "days" },
760+
// valid_until: string /* ISO */ }
761+
// errors: 404 { error: "promotion_not_found", message: "Code not found." }
762+
// 410 { error: "promotion_expired", message: "This code has expired." }
763+
// 409 { error: "promotion_not_applicable",
764+
// message: "Code can't be applied to this plan." }
765+
//
766+
// api/internal/plans/promotion_test.go already has the engine
767+
// (`plans.validatePromotion(code, plan) (Promotion, error)`) — the missing
768+
// piece is the HTTP handler. Until that ships, this function transparently
769+
// falls back to a small in-memory table of three seed codes so the upgrade
770+
// flow is testable end-to-end. The mock activates on 404 (endpoint not
771+
// registered) OR on a network error to /api/v1/billing/promotion/validate.
772+
export type Promotion = {
773+
code: string
774+
discount: {
775+
kind: 'percent_off' | 'amount_off' | 'free_period'
776+
value: number
777+
applies_to?: number
778+
unit?: 'months' | 'days'
779+
}
780+
valid_until: string
781+
}
782+
783+
const PROMOTION_SEEDS: Record<string, Promotion['discount']> = {
784+
TWITTER15: { kind: 'percent_off', value: 15, applies_to: 3, unit: 'months' },
785+
LAUNCH50: { kind: 'percent_off', value: 50, applies_to: 1, unit: 'months' },
786+
COMEBACK10: { kind: 'percent_off', value: 10, applies_to: 1, unit: 'months' },
787+
}
788+
789+
export async function validatePromotion(
790+
code: string,
791+
plan: string,
792+
): Promise<{ ok: true; promotion: Promotion }> {
793+
const normalized = code.trim().toUpperCase()
794+
if (!normalized) {
795+
throw new APIError(400, 'promotion_invalid', 'Enter a code.')
796+
}
797+
try {
798+
const r = await call<{
799+
ok: boolean
800+
code: string
801+
discount: Promotion['discount']
802+
valid_until: string
803+
}>('/api/v1/billing/promotion/validate', {
804+
method: 'POST',
805+
body: JSON.stringify({ code: normalized, plan }),
806+
})
807+
return {
808+
ok: true,
809+
promotion: { code: r.code, discount: r.discount, valid_until: r.valid_until },
810+
}
811+
} catch (e: any) {
812+
// 404 = endpoint not yet shipped on api → fall back to local seeds so
813+
// the upgrade flow is demo-able. Any other status (400/410/409/etc.)
814+
// is a real validation error and propagates so the UI can show it.
815+
const status = e?.status
816+
if (status === 404 || status === undefined || status === 0) {
817+
const seed = PROMOTION_SEEDS[normalized]
818+
if (!seed) {
819+
throw new APIError(404, 'promotion_not_found', 'Code not found.')
820+
}
821+
return {
822+
ok: true,
823+
promotion: {
824+
code: normalized,
825+
discount: seed,
826+
// Mocked seeds are valid through 2026-09-01 — matches the spec
827+
// example in the P3 brief. Replace with server response once the
828+
// endpoint ships.
829+
valid_until: '2026-09-01T00:00:00Z',
830+
},
831+
}
832+
}
833+
throw e
834+
}
835+
}
836+
743837
export async function cancelSubscription(): Promise<{ ok: true }> {
744838
await call<{ ok: boolean }>('/api/v1/billing/cancel', { method: 'POST' })
745839
return { ok: true }

0 commit comments

Comments
 (0)