d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
261 lines
8.6 KiB
TypeScript
261 lines
8.6 KiB
TypeScript
/**
|
||
* GET /api/v1/admin/referral-campaigns
|
||
*
|
||
* List Stripe promotion codes with cursor-based pagination.
|
||
*
|
||
* Query Parameters:
|
||
* - limit: number (default: 50, max: 100)
|
||
* - starting_after: string (cursor — Stripe promotion code ID)
|
||
* - active: 'true' | 'false' (optional filter)
|
||
*
|
||
* POST /api/v1/admin/referral-campaigns
|
||
*
|
||
* Create a Stripe coupon and an associated promotion code.
|
||
*
|
||
* Body:
|
||
* - name: string (required) — Display name for the coupon
|
||
* - percentOff: number (required, 1–100) — Percentage discount
|
||
* - code: string | null (optional, min 6 chars, auto-uppercased) — Desired code
|
||
* - duration: 'once' | 'repeating' | 'forever' (default: 'once')
|
||
* - 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 {
|
||
type AdminV1PromoCode,
|
||
type AdminV1ReferralCampaignAppliesTo,
|
||
type AdminV1ReferralCampaignDuration,
|
||
adminV1CreateReferralCampaignContract,
|
||
adminV1ListReferralCampaignsContract,
|
||
} from '@/lib/api/contracts/v1/admin'
|
||
import { parseRequest } from '@/lib/api/server'
|
||
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
|
||
import { getPlans } from '@/lib/billing/plans'
|
||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||
import {
|
||
adminValidationErrorResponse,
|
||
badRequestResponse,
|
||
internalErrorResponse,
|
||
singleResponse,
|
||
} from '@/app/api/v1/admin/responses'
|
||
|
||
const logger = createLogger('AdminPromoCodes')
|
||
|
||
function formatPromoCode(promo: {
|
||
id: string
|
||
code: string
|
||
coupon: {
|
||
id: string
|
||
name: string | null
|
||
percent_off: number | null
|
||
duration: string
|
||
duration_in_months: number | null
|
||
applies_to?: { products: string[] }
|
||
}
|
||
max_redemptions: number | null
|
||
expires_at: number | null
|
||
active: boolean
|
||
times_redeemed: number
|
||
created: number
|
||
}): AdminV1PromoCode {
|
||
return {
|
||
id: promo.id,
|
||
code: promo.code,
|
||
couponId: promo.coupon.id,
|
||
name: promo.coupon.name ?? '',
|
||
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,
|
||
timesRedeemed: promo.times_redeemed,
|
||
createdAt: new Date(promo.created * 1000).toISOString(),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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: AdminV1ReferralCampaignAppliesTo[]
|
||
): 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 = withRouteHandler(
|
||
withAdminAuth(async (request) => {
|
||
try {
|
||
const stripe = requireStripeClient()
|
||
const parsed = await parseRequest(
|
||
adminV1ListReferralCampaignsContract,
|
||
request,
|
||
{},
|
||
{
|
||
validationErrorResponse: adminValidationErrorResponse,
|
||
}
|
||
)
|
||
if (!parsed.success) return parsed.response
|
||
const query = parsed.data.query
|
||
|
||
const listParams: Record<string, unknown> = { limit: query.limit }
|
||
if (query.starting_after) listParams.starting_after = query.starting_after
|
||
if (query.active !== undefined) listParams.active = query.active
|
||
|
||
const promoCodes = await stripe.promotionCodes.list(listParams)
|
||
|
||
const data = promoCodes.data.map(formatPromoCode)
|
||
|
||
logger.info(`Admin API: Listed ${data.length} Stripe promotion codes`)
|
||
|
||
return NextResponse.json({
|
||
data,
|
||
hasMore: promoCodes.has_more,
|
||
...(data.length > 0 ? { nextCursor: data[data.length - 1].id } : {}),
|
||
})
|
||
} catch (error) {
|
||
logger.error('Admin API: Failed to list promotion codes', { error })
|
||
return internalErrorResponse('Failed to list promotion codes')
|
||
}
|
||
})
|
||
)
|
||
|
||
export const POST = withRouteHandler(
|
||
withAdminAuth(async (request) => {
|
||
try {
|
||
const stripe = requireStripeClient()
|
||
const parsed = await parseRequest(
|
||
adminV1CreateReferralCampaignContract,
|
||
request,
|
||
{},
|
||
{
|
||
validationErrorResponse: adminValidationErrorResponse,
|
||
invalidJson: 'throw',
|
||
}
|
||
)
|
||
if (!parsed.success) return parsed.response
|
||
|
||
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } =
|
||
parsed.data.body
|
||
const appliesTo = parsed.data.body.appliesTo ?? undefined
|
||
const effectiveDuration: AdminV1ReferralCampaignDuration = duration
|
||
|
||
let appliesToProducts: string[] | undefined
|
||
if (appliesTo?.length) {
|
||
appliesToProducts = await resolveProductIds(stripe, 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
|
||
try {
|
||
const promoParams: Stripe.PromotionCodeCreateParams = {
|
||
coupon: coupon.id,
|
||
...(code ? { code: code.trim().toUpperCase() } : {}),
|
||
...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}),
|
||
...(expiresAt ? { expires_at: Math.floor(new Date(expiresAt).getTime() / 1000) } : {}),
|
||
}
|
||
|
||
promoCode = await stripe.promotionCodes.create(promoParams)
|
||
} catch (promoError) {
|
||
try {
|
||
await stripe.coupons.del(coupon.id)
|
||
} catch (cleanupError) {
|
||
logger.error(
|
||
'Admin API: Failed to clean up orphaned coupon after promo code creation failed',
|
||
{
|
||
couponId: coupon.id,
|
||
cleanupError,
|
||
}
|
||
)
|
||
}
|
||
throw promoError
|
||
}
|
||
|
||
logger.info('Admin API: Created Stripe promotion code', {
|
||
promoCodeId: promoCode.id,
|
||
code: promoCode.code,
|
||
couponId: coupon.id,
|
||
percentOff,
|
||
duration: effectiveDuration,
|
||
...(appliesTo ? { appliesTo } : {}),
|
||
})
|
||
|
||
return singleResponse(formatPromoCode(promoCode))
|
||
} catch (error) {
|
||
if (
|
||
error instanceof Error &&
|
||
'type' in error &&
|
||
(error as { type: string }).type === 'StripeInvalidRequestError'
|
||
) {
|
||
logger.warn('Admin API: Stripe rejected promotion code request', { error: error.message })
|
||
return badRequestResponse(error.message)
|
||
}
|
||
logger.error('Admin API: Failed to create promotion code', { error })
|
||
return internalErrorResponse('Failed to create promotion code')
|
||
}
|
||
})
|
||
)
|