Skip to content

Commit 6450ec8

Browse files
committed
feat(billing): add appliesTo plan restriction for coupon codes
1 parent 7b6149d commit 6450ec8

1 file changed

Lines changed: 94 additions & 1 deletion

File tree

  • apps/sim/app/api/v1/admin/referral-campaigns

apps/sim/app/api/v1/admin/referral-campaigns/route.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@
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

2527
import { createLogger } from '@sim/logger'
2628
import { NextResponse } from 'next/server'
2729
import type Stripe from 'stripe'
30+
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
31+
import { getPlans } from '@/lib/billing/plans'
2832
import { requireStripeClient } from '@/lib/billing/stripe-client'
2933
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
3034
import {
@@ -38,6 +42,17 @@ const logger = createLogger('AdminPromoCodes')
3842
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
3943
type 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+
4156
interface 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+
88146
export 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

Comments
 (0)