Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(billing): add appliesTo plan restriction for coupon codes#3744
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Uh oh!
There was an error while loading. Please reload this page.
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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] | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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)) | ||
Oops, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.