@@ -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() ────────────────────────────────────────────────
0 commit comments