feat(billing): add appliesTo plan restriction for coupon codes (#3744)

* feat(billing): add appliesTo plan restriction for coupon codes

* fix(billing): fail coupon creation on partial product resolution
This commit is contained in:
Waleed
2026-03-24 13:04:55 -07:00
committed by GitHub
parent 34ea99e99d
commit 77eafabb63

View File

@@ -20,11 +20,15 @@
* - durationInMonths: number (required when duration is 'repeating')
* - maxRedemptions: number (optional) — Total redemption cap
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
* - appliesTo: ('pro' | 'team' | 'pro_6000' | 'pro_25000' | 'team_6000' | 'team_25000')[] (optional)
* Restrict coupon to specific plans. Broad values ('pro', 'team') match all tiers.
*/
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type Stripe from 'stripe'
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
import { getPlans } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
@@ -38,6 +42,17 @@ const logger = createLogger('AdminPromoCodes')
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
type Duration = (typeof VALID_DURATIONS)[number]
/** Broad categories match all tiers; specific plan names match exactly. */
const VALID_APPLIES_TO = [
'pro',
'team',
'pro_6000',
'pro_25000',
'team_6000',
'team_25000',
] as const
type AppliesTo = (typeof VALID_APPLIES_TO)[number]
interface PromoCodeResponse {
id: string
code: string
@@ -46,6 +61,7 @@ interface PromoCodeResponse {
percentOff: number
duration: string
durationInMonths: number | null
appliesToProductIds: string[] | null
maxRedemptions: number | null
expiresAt: string | null
active: boolean
@@ -62,6 +78,7 @@ function formatPromoCode(promo: {
percent_off: number | null
duration: string
duration_in_months: number | null
applies_to?: { products: string[] }
}
max_redemptions: number | null
expires_at: number | null
@@ -77,6 +94,7 @@ function formatPromoCode(promo: {
percentOff: promo.coupon.percent_off ?? 0,
duration: promo.coupon.duration,
durationInMonths: promo.coupon.duration_in_months,
appliesToProductIds: promo.coupon.applies_to?.products ?? null,
maxRedemptions: promo.max_redemptions,
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
active: promo.active,
@@ -85,6 +103,54 @@ function formatPromoCode(promo: {
}
}
/**
* Resolve appliesTo values to unique Stripe product IDs.
* Broad categories ('pro', 'team') match all tiers via isPro/isTeam.
* Specific plan names ('pro_6000', 'team_25000') match exactly.
*/
async function resolveProductIds(stripe: Stripe, targets: AppliesTo[]): Promise<string[]> {
const plans = getPlans()
const priceIds: string[] = []
const broadMatchers: Record<string, (name: string) => boolean> = {
pro: isPro,
team: isTeam,
}
for (const plan of plans) {
const matches = targets.some((target) => {
const matcher = broadMatchers[target]
return matcher ? matcher(plan.name) : plan.name === target
})
if (!matches) continue
if (plan.priceId) priceIds.push(plan.priceId)
if (plan.annualDiscountPriceId) priceIds.push(plan.annualDiscountPriceId)
}
const results = await Promise.allSettled(
priceIds.map(async (priceId) => {
const price = await stripe.prices.retrieve(priceId)
return typeof price.product === 'string' ? price.product : price.product.id
})
)
const failures = results.filter((r) => r.status === 'rejected')
if (failures.length > 0) {
logger.error('Failed to resolve all Stripe products for appliesTo', {
failed: failures.length,
total: priceIds.length,
})
throw new Error('Could not resolve all Stripe products for the specified plan categories.')
}
const productIds = new Set<string>()
for (const r of results) {
if (r.status === 'fulfilled') productIds.add(r.value)
}
return [...productIds]
}
export const GET = withAdminAuth(async (request) => {
try {
const stripe = requireStripeClient()
@@ -125,7 +191,16 @@ export const POST = withAdminAuth(async (request) => {
const stripe = requireStripeClient()
const body = await request.json()
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
const {
name,
percentOff,
code,
duration,
durationInMonths,
maxRedemptions,
expiresAt,
appliesTo,
} = body
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return badRequestResponse('name is required and must be a non-empty string')
@@ -186,11 +261,36 @@ export const POST = withAdminAuth(async (request) => {
}
}
if (appliesTo !== undefined && appliesTo !== null) {
if (!Array.isArray(appliesTo) || appliesTo.length === 0) {
return badRequestResponse('appliesTo must be a non-empty array')
}
const invalid = appliesTo.filter(
(v: unknown) => typeof v !== 'string' || !VALID_APPLIES_TO.includes(v as AppliesTo)
)
if (invalid.length > 0) {
return badRequestResponse(
`appliesTo contains invalid values: ${invalid.join(', ')}. Valid values: ${VALID_APPLIES_TO.join(', ')}`
)
}
}
let appliesToProducts: string[] | undefined
if (appliesTo?.length) {
appliesToProducts = await resolveProductIds(stripe, appliesTo as AppliesTo[])
if (appliesToProducts.length === 0) {
return badRequestResponse(
'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.'
)
}
}
const coupon = await stripe.coupons.create({
name: name.trim(),
percent_off: percentOff,
duration: effectiveDuration,
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
...(appliesToProducts ? { applies_to: { products: appliesToProducts } } : {}),
})
let promoCode
@@ -224,6 +324,7 @@ export const POST = withAdminAuth(async (request) => {
couponId: coupon.id,
percentOff,
duration: effectiveDuration,
...(appliesTo ? { appliesTo } : {}),
})
return singleResponse(formatPromoCode(promoCode))