Files
hope-events/backend/src/utils/coupons.js
T
joshuaandClaude Opus 5.5 709ea38c10 Add coupon codes and staff pricing
Percentage, fixed-amount and comp codes, limited by event, ticket option
or variant, dates, use counts, minimum quantity, and people, roles or an
email domain. The discount is locked in on the registration (mirrored on
Registration.discountTotal so every total sees it), shows on invoices,
receipts, emails and the Yoco checkout, and is released on cancellation.
Use limits are enforced under an advisory lock.

Ticket options and variants can also carry a staff price that Staff and
above get automatically. Adds a Supervisor > Coupon codes page, code
fields on registration, My Events, manual registration, at the door and
the kiosk, a Discounts report, and audit log entries. Also fixes Yoco
line items and the free-registration check for lines spanning several
price tranches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:43:10 +02:00

299 lines
13 KiB
JavaScript

/**
* 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,
};