From 6450ec812d872671d1be51c4a266b9ad9e45c393 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 24 Mar 2026 12:45:42 -0700 Subject: [PATCH 1/2] feat(billing): add appliesTo plan restriction for coupon codes --- .../api/v1/admin/referral-campaigns/route.ts | 95 ++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts index 92f7e2529d..bf17c08bad 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -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,46 @@ 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 { + const plans = getPlans() + const priceIds: string[] = [] + + const broadMatchers: Record 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 productIds = new Set() + await Promise.all( + priceIds.map(async (priceId) => { + try { + const price = await stripe.prices.retrieve(priceId) + const productId = typeof price.product === 'string' ? price.product : price.product.id + productIds.add(productId) + } catch (err) { + logger.warn('Failed to resolve product for price, skipping', { priceId, err }) + } + }) + ) + + return [...productIds] +} + export const GET = withAdminAuth(async (request) => { try { const stripe = requireStripeClient() @@ -125,7 +183,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 +253,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 +316,7 @@ export const POST = withAdminAuth(async (request) => { couponId: coupon.id, percentOff, duration: effectiveDuration, + ...(appliesTo ? { appliesTo } : {}), }) return singleResponse(formatPromoCode(promoCode)) From 903ff2d7c4f4d10f5ab6284f62086eef3a0f6978 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 24 Mar 2026 12:51:28 -0700 Subject: [PATCH 2/2] fix(billing): fail coupon creation on partial product resolution --- .../api/v1/admin/referral-campaigns/route.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts index bf17c08bad..7382420719 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -127,19 +127,27 @@ async function resolveProductIds(stripe: Stripe, targets: AppliesTo[]): Promise< if (plan.annualDiscountPriceId) priceIds.push(plan.annualDiscountPriceId) } - const productIds = new Set() - await Promise.all( + const results = await Promise.allSettled( priceIds.map(async (priceId) => { - try { - const price = await stripe.prices.retrieve(priceId) - const productId = typeof price.product === 'string' ? price.product : price.product.id - productIds.add(productId) - } catch (err) { - logger.warn('Failed to resolve product for price, skipping', { priceId, err }) - } + 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() + for (const r of results) { + if (r.status === 'fulfilled') productIds.add(r.value) + } + return [...productIds] }