2020 * - durationInMonths: number (required when duration is 'repeating')
2121 * - maxRedemptions: number (optional) — Total redemption cap
2222 * - expiresAt: ISO 8601 string (optional) — Promotion code expiry
23+ * - appliesTo: ('pro' | 'team' | 'pro_6000' | 'pro_25000' | 'team_6000' | 'team_25000')[] (optional)
24+ * Restrict coupon to specific plans. Broad values ('pro', 'team') match all tiers.
2325 */
2426
2527import { createLogger } from '@sim/logger'
2628import { NextResponse } from 'next/server'
2729import type Stripe from 'stripe'
30+ import { isPro , isTeam } from '@/lib/billing/plan-helpers'
31+ import { getPlans } from '@/lib/billing/plans'
2832import { requireStripeClient } from '@/lib/billing/stripe-client'
2933import { withAdminAuth } from '@/app/api/v1/admin/middleware'
3034import {
@@ -38,6 +42,17 @@ const logger = createLogger('AdminPromoCodes')
3842const VALID_DURATIONS = [ 'once' , 'repeating' , 'forever' ] as const
3943type Duration = ( typeof VALID_DURATIONS ) [ number ]
4044
45+ /** Broad categories match all tiers; specific plan names match exactly. */
46+ const VALID_APPLIES_TO = [
47+ 'pro' ,
48+ 'team' ,
49+ 'pro_6000' ,
50+ 'pro_25000' ,
51+ 'team_6000' ,
52+ 'team_25000' ,
53+ ] as const
54+ type AppliesTo = ( typeof VALID_APPLIES_TO ) [ number ]
55+
4156interface PromoCodeResponse {
4257 id : string
4358 code : string
@@ -46,6 +61,7 @@ interface PromoCodeResponse {
4661 percentOff : number
4762 duration : string
4863 durationInMonths : number | null
64+ appliesToProductIds : string [ ] | null
4965 maxRedemptions : number | null
5066 expiresAt : string | null
5167 active : boolean
@@ -62,6 +78,7 @@ function formatPromoCode(promo: {
6278 percent_off : number | null
6379 duration : string
6480 duration_in_months : number | null
81+ applies_to ?: { products : string [ ] }
6582 }
6683 max_redemptions : number | null
6784 expires_at : number | null
@@ -77,6 +94,7 @@ function formatPromoCode(promo: {
7794 percentOff : promo . coupon . percent_off ?? 0 ,
7895 duration : promo . coupon . duration ,
7996 durationInMonths : promo . coupon . duration_in_months ,
97+ appliesToProductIds : promo . coupon . applies_to ?. products ?? null ,
8098 maxRedemptions : promo . max_redemptions ,
8199 expiresAt : promo . expires_at ? new Date ( promo . expires_at * 1000 ) . toISOString ( ) : null ,
82100 active : promo . active ,
@@ -85,6 +103,46 @@ function formatPromoCode(promo: {
85103 }
86104}
87105
106+ /**
107+ * Resolve appliesTo values to unique Stripe product IDs.
108+ * Broad categories ('pro', 'team') match all tiers via isPro/isTeam.
109+ * Specific plan names ('pro_6000', 'team_25000') match exactly.
110+ */
111+ async function resolveProductIds ( stripe : Stripe , targets : AppliesTo [ ] ) : Promise < string [ ] > {
112+ const plans = getPlans ( )
113+ const priceIds : string [ ] = [ ]
114+
115+ const broadMatchers : Record < string , ( name : string ) => boolean > = {
116+ pro : isPro ,
117+ team : isTeam ,
118+ }
119+
120+ for ( const plan of plans ) {
121+ const matches = targets . some ( ( target ) => {
122+ const matcher = broadMatchers [ target ]
123+ return matcher ? matcher ( plan . name ) : plan . name === target
124+ } )
125+ if ( ! matches ) continue
126+ if ( plan . priceId ) priceIds . push ( plan . priceId )
127+ if ( plan . annualDiscountPriceId ) priceIds . push ( plan . annualDiscountPriceId )
128+ }
129+
130+ const productIds = new Set < string > ( )
131+ await Promise . all (
132+ priceIds . map ( async ( priceId ) => {
133+ try {
134+ const price = await stripe . prices . retrieve ( priceId )
135+ const productId = typeof price . product === 'string' ? price . product : price . product . id
136+ productIds . add ( productId )
137+ } catch ( err ) {
138+ logger . warn ( 'Failed to resolve product for price, skipping' , { priceId, err } )
139+ }
140+ } )
141+ )
142+
143+ return [ ...productIds ]
144+ }
145+
88146export const GET = withAdminAuth ( async ( request ) => {
89147 try {
90148 const stripe = requireStripeClient ( )
@@ -125,7 +183,16 @@ export const POST = withAdminAuth(async (request) => {
125183 const stripe = requireStripeClient ( )
126184 const body = await request . json ( )
127185
128- const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
186+ const {
187+ name,
188+ percentOff,
189+ code,
190+ duration,
191+ durationInMonths,
192+ maxRedemptions,
193+ expiresAt,
194+ appliesTo,
195+ } = body
129196
130197 if ( ! name || typeof name !== 'string' || name . trim ( ) . length === 0 ) {
131198 return badRequestResponse ( 'name is required and must be a non-empty string' )
@@ -186,11 +253,36 @@ export const POST = withAdminAuth(async (request) => {
186253 }
187254 }
188255
256+ if ( appliesTo !== undefined && appliesTo !== null ) {
257+ if ( ! Array . isArray ( appliesTo ) || appliesTo . length === 0 ) {
258+ return badRequestResponse ( 'appliesTo must be a non-empty array' )
259+ }
260+ const invalid = appliesTo . filter (
261+ ( v : unknown ) => typeof v !== 'string' || ! VALID_APPLIES_TO . includes ( v as AppliesTo )
262+ )
263+ if ( invalid . length > 0 ) {
264+ return badRequestResponse (
265+ `appliesTo contains invalid values: ${ invalid . join ( ', ' ) } . Valid values: ${ VALID_APPLIES_TO . join ( ', ' ) } `
266+ )
267+ }
268+ }
269+
270+ let appliesToProducts : string [ ] | undefined
271+ if ( appliesTo ?. length ) {
272+ appliesToProducts = await resolveProductIds ( stripe , appliesTo as AppliesTo [ ] )
273+ if ( appliesToProducts . length === 0 ) {
274+ return badRequestResponse (
275+ 'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.'
276+ )
277+ }
278+ }
279+
189280 const coupon = await stripe . coupons . create ( {
190281 name : name . trim ( ) ,
191282 percent_off : percentOff ,
192283 duration : effectiveDuration ,
193284 ...( effectiveDuration === 'repeating' ? { duration_in_months : durationInMonths } : { } ) ,
285+ ...( appliesToProducts ? { applies_to : { products : appliesToProducts } } : { } ) ,
194286 } )
195287
196288 let promoCode
@@ -224,6 +316,7 @@ export const POST = withAdminAuth(async (request) => {
224316 couponId : coupon . id ,
225317 percentOff,
226318 duration : effectiveDuration ,
319+ ...( appliesTo ? { appliesTo } : { } ) ,
227320 } )
228321
229322 return singleResponse ( formatPromoCode ( promoCode ) )
0 commit comments