/** * Coupon codes — percentage, fixed-amount and comp (100%) discounts on registrations. * * Rules: * - A registration can carry one active coupon. Its discount is stored as a * CouponRedemption (amount locked at apply time) and mirrored as the sum of active * redemptions on Registration.discountTotal, which computeRegistrationTotalDue * subtracts. Early-bird pricing refreshes never touch it. * - Editing a registration's items recalculates percentage and comp codes over the new * items; fixed-amount codes keep their amount (capped at what's in scope). * - Cancelling a registration releases its redemption, freeing the use. * - Scope: no CouponOption rows = the whole event; otherwise only matching options * (or one variant of an option). * - stackEarlyBird=false means "best price wins": the coupon is worked out against the * standard price, and only the part that beats the early-bird price is discounted. * - Restricted codes (users, roles, email domain) need a known account, so guests * can't use them; a match on any one restriction is enough. */ const prisma = require('../config/db'); function normalizeCode(code) { return String(code || '').trim().toUpperCase(); } function couponError(message) { const err = new Error(message); err.isCouponError = true; err.statusCode = 400; return err; } const round2 = (n) => Math.round(Number(n || 0) * 100) / 100; /** * Discountable lines from a registration — one per price tranche, so tickets bought at * different prices are each discounted at what was actually charged. * `basePrice` is the standard (non-early-bird) price, used for "best price wins". */ function buildCouponLines(registration) { const lines = []; for (const ro of registration?.registrationOptions || []) { const eo = ro.eventOption || {}; const variant = ro.variantId ? (eo.variants || []).find(v => v.id === ro.variantId) : null; const standard = variant && variant.price !== null && variant.price !== undefined ? Number(variant.price) : Number(eo.price || 0); const tranches = ro.tranches && ro.tranches.length > 0 ? ro.tranches : [{ quantity: ro.quantity, priceSnapshot: ro.priceSnapshot ?? standard, appliedTierId: ro.appliedTierId }]; for (const t of tranches) { const unitPrice = Number(t.priceSnapshot ?? standard); lines.push({ eventOptionId: ro.eventOptionId, variantId: ro.variantId || null, quantity: Number(t.quantity || 0), unitPrice, // Only an early-bird tranche has a "standard" price above what was charged; a // staff price or plain price is already the base the coupon works from. basePrice: t.appliedTierId ? Math.max(standard, unitPrice) : unitPrice, }); } } return lines; } function lineInScope(line, scopes) { if (!scopes || scopes.length === 0) return true; return scopes.some(s => s.eventOptionId === line.eventOptionId && (!s.variantId || s.variantId === line.variantId)); } function scopedLines(coupon, lines) { return (lines || []).filter(l => lineInScope(l, coupon.options)); } /** * The discount a coupon gives on these lines, in rand, never more than what the in-scope * lines cost. Pure — used for previews, applying, and recalculating after edits. */ function computeCouponDiscount(coupon, lines) { const scoped = scopedLines(coupon, lines); const charged = scoped.reduce((s, l) => s + l.unitPrice * l.quantity, 0); if (charged <= 0) return 0; let discount = 0; if (coupon.type === 'comp') { discount = charged; } else if (coupon.type === 'percent') { const pct = Math.min(100, Math.max(0, Number(coupon.value || 0))) / 100; if (coupon.stackEarlyBird) { discount = charged * pct; } else { discount = scoped.reduce((s, l) => s + (l.unitPrice - Math.min(l.unitPrice, l.basePrice * (1 - pct))) * l.quantity, 0); } } else if (coupon.type === 'fixed') { const amount = Math.max(0, Number(coupon.value || 0)); if (coupon.stackEarlyBird) { discount = amount; } else { const base = scoped.reduce((s, l) => s + l.basePrice * l.quantity, 0); discount = charged - Math.max(0, base - amount); } } return round2(Math.min(charged, Math.max(0, discount))); } /** * Checks whether a coupon can be used. Pure — the caller supplies the use counts. * * @param {object} coupon - with events[], options[], allowedUsers[] * @param {object} ctx - { eventId, user: {id, role, email}|null, now, usesTotal, usesByUser, lines, * deferRestrictions } — deferRestrictions lets staff preview a * restricted code before the attendee's account exists; it's * checked against the attendee when the registration is saved. * @returns {{ ok: true } | { ok: false, message: string }} */ function validateCoupon(coupon, ctx) { const no = (message) => ({ ok: false, message }); if (!coupon) return no('That code isn\'t valid.'); if (!coupon.active) return no('This code is no longer active.'); const now = ctx.now ? new Date(ctx.now) : new Date(); if (coupon.validFrom && now < new Date(coupon.validFrom)) return no('This code isn\'t valid yet.'); if (coupon.validUntil && now > new Date(coupon.validUntil)) return no('This code has expired.'); if ((coupon.events || []).length > 0 && !coupon.events.some(e => e.eventId === ctx.eventId)) { return no('This code isn\'t valid for this event.'); } const restrictedUsers = (coupon.allowedUsers || []).map(u => u.userId); const restrictedRoles = coupon.restrictRoles || []; const domain = String(coupon.restrictDomain || '').trim().toLowerCase().replace(/^@/, ''); if ((restrictedUsers.length || restrictedRoles.length || domain) && !(ctx.deferRestrictions && !ctx.user)) { const user = ctx.user; if (!user) return no('Log in to use this code.'); const email = String(user.email || '').toLowerCase(); const allowed = restrictedUsers.includes(user.id) || restrictedRoles.includes(user.role) || (domain && email.endsWith(`@${domain}`)); if (!allowed) return no('This code isn\'t available for your account.'); } if (coupon.maxUses !== null && coupon.maxUses !== undefined && (ctx.usesTotal || 0) >= coupon.maxUses) { return no('This code has been fully used.'); } if (coupon.maxUsesPerUser !== null && coupon.maxUsesPerUser !== undefined && ctx.user && (ctx.usesByUser || 0) >= coupon.maxUsesPerUser) { return no('You\'ve already used this code.'); } const scopedQty = scopedLines(coupon, ctx.lines).reduce((s, l) => s + l.quantity, 0); if (scopedQty === 0) return no('This code doesn\'t apply to the items you\'ve chosen.'); if (coupon.minQuantity && scopedQty < coupon.minQuantity) { return no(`This code needs at least ${coupon.minQuantity} qualifying ticket${coupon.minQuantity === 1 ? '' : 's'}.`); } return { ok: true }; } /** Short human description, e.g. "20% off", "R50 off", "Free". */ function describeCoupon(coupon) { if (!coupon) return ''; if (coupon.type === 'comp') return 'Free'; if (coupon.type === 'percent') return `${Number(coupon.value)}% off`; return `R${Number(coupon.value).toFixed(2)} off`; } // ─── Database helpers ───────────────────────────────────────────────────────── const COUPON_INCLUDE = { events: true, options: true, allowedUsers: true }; const REGISTRATION_INCLUDE = { registrationOptions: { include: { tranches: true, eventOption: { include: { variants: true } } } }, couponRedemptions: { where: { releasedAt: null }, include: { coupon: { include: COUPON_INCLUDE } } }, user: { select: { id: true, role: true, email: true } }, }; async function findCouponByCode(code, db = prisma) { const normalized = normalizeCode(code); if (!normalized) return null; return db.coupon.findUnique({ where: { code: normalized }, include: COUPON_INCLUDE }); } /** Active uses: unreleased redemptions on registrations that aren't cancelled. */ async function countCouponUses(couponId, userId, db = prisma) { const base = { couponId, releasedAt: null, registration: { status: { not: 'cancelled' } } }; const [usesTotal, usesByUser] = await Promise.all([ db.couponRedemption.count({ where: base }), userId ? db.couponRedemption.count({ where: { ...base, userId } }) : Promise.resolve(0), ]); return { usesTotal, usesByUser }; } /** * Validates `code` against hypothetical lines (nothing saved) and returns the discount it * would give. Throws a coupon error with a user-facing message when it can't be used. */ async function previewCoupon({ code, eventId, user, lines, deferRestrictions = false }) { const coupon = await findCouponByCode(code); const uses = coupon ? await countCouponUses(coupon.id, user?.id) : {}; const check = validateCoupon(coupon, { eventId, user, lines, deferRestrictions, ...uses }); if (!check.ok) throw couponError(check.message); return { coupon, discount: computeCouponDiscount(coupon, lines) }; } /** * Applies `code` to a saved registration: validates it against the registration's owner * and items, locks in the discount, and updates Registration.discountTotal. Runs under a * per-coupon advisory lock so the last available use can't be claimed twice. */ async function applyCouponToRegistration({ registrationId, code, appliedById = null }) { const normalized = normalizeCode(code); if (!normalized) throw couponError('Enter a code.'); return prisma.$transaction(async (tx) => { const coupon = await findCouponByCode(normalized, tx); if (!coupon) throw couponError('That code isn\'t valid.'); await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${coupon.id}))`; const registration = await tx.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE }); if (!registration) throw couponError('Registration not found.'); if (registration.couponRedemptions.length > 0) { throw couponError(registration.couponRedemptions[0].couponId === coupon.id ? 'This code is already applied to this registration.' : 'A code has already been applied to this registration.'); } const lines = buildCouponLines(registration); const uses = await countCouponUses(coupon.id, registration.userId, tx); const check = validateCoupon(coupon, { eventId: registration.eventId, user: registration.user, lines, ...uses }); if (!check.ok) throw couponError(check.message); const discount = computeCouponDiscount(coupon, lines); const redemption = await tx.couponRedemption.create({ data: { couponId: coupon.id, registrationId, userId: registration.userId, discountAmount: discount, appliedById }, }); await tx.registration.update({ where: { id: registrationId }, data: { discountTotal: discount, updatedAt: new Date() } }); return { coupon, discount, redemption }; }); } /** * Re-works active discounts after a registration's items change: percentage and comp * codes are recalculated over the new items, fixed codes keep their amount (capped at * what's still in scope). Pass `tx` to run inside the caller's transaction. */ async function recalculateRegistrationDiscount(registrationId, db = prisma) { const registration = await db.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE }); if (!registration) return 0; if (registration.couponRedemptions.length === 0) { if (registration.discountTotal !== 0) { await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } }); } return 0; } const lines = buildCouponLines(registration); let total = 0; for (const r of registration.couponRedemptions) { let amount; if (r.coupon.type === 'fixed') { const inScope = scopedLines(r.coupon, lines).reduce((s, l) => s + l.unitPrice * l.quantity, 0); amount = round2(Math.min(r.discountAmount, inScope)); } else { amount = computeCouponDiscount(r.coupon, lines); } if (Math.abs(amount - r.discountAmount) > 0.001) { await db.couponRedemption.update({ where: { id: r.id }, data: { discountAmount: amount } }); } total += amount; } total = round2(total); if (Math.abs(total - registration.discountTotal) > 0.001) { await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total } }); } return total; } /** Releases a cancelled registration's coupon uses so they can be claimed again. */ async function releaseRegistrationCoupons(registrationId, db = prisma) { const released = await db.couponRedemption.updateMany({ where: { registrationId, releasedAt: null }, data: { releasedAt: new Date() }, }); if (released.count > 0) { await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } }); } return released.count; } module.exports = { normalizeCode, couponError, buildCouponLines, computeCouponDiscount, validateCoupon, describeCoupon, findCouponByCode, countCouponUses, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, };