Sell a set of tickets together for one price (e.g. 5 Adult + 3 Child for R500). Bundles apply automatically, as many times as a registration's tickets allow, and only when cheaper than the prices actually charged. Coupons come off afterwards. - EventBundle/EventBundleItem/RegistrationBundle models and Registration.bundleDiscount - Supervisor "Bundle deals" page, quote endpoint, bundle lines on invoices, receipts, emails, WhatsApp and Yoco checkout, Discounts report rows - Register form shows started bundles with "Complete bundle" and "+" buttons - Demo: Family Fun Day sells a Family deal bundle instead of the Family pass ticket Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
14 KiB
JavaScript
318 lines
14 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.
|
|
* - Bundle deals (utils/bundles.js) come off first; the coupon is worked out on what's
|
|
* left. discountTotal = bundle discount + coupon discounts.
|
|
*/
|
|
|
|
const prisma = require('../config/db');
|
|
const { applyBundles, loadBundlesFor, saveBundleApplications } = require('./bundles');
|
|
|
|
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 } } },
|
|
bundleApplications: true,
|
|
user: { select: { id: true, role: true, email: true } },
|
|
};
|
|
|
|
/** Bundle deals a basket gets before any coupon (nothing saved). */
|
|
async function previewBundles(eventId, lines, db = prisma) {
|
|
return applyBundles(await loadBundlesFor(eventId, {}, db), lines);
|
|
}
|
|
|
|
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 after the basket's bundle deals (returned as `bundles`). 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 bundles = await previewBundles(eventId, lines);
|
|
const check = validateCoupon(coupon, { eventId, user, lines: bundles.lines, deferRestrictions, ...uses });
|
|
if (!check.ok) throw couponError(check.message);
|
|
return { coupon, discount: computeCouponDiscount(coupon, bundles.lines), bundles };
|
|
}
|
|
|
|
/**
|
|
* 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 } = await registrationBundles(registration, tx);
|
|
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 recalculateRegistrationDiscount(registrationId, tx);
|
|
await tx.registration.update({ where: { id: registrationId }, data: { updatedAt: new Date() } });
|
|
return { coupon, discount, redemption };
|
|
});
|
|
}
|
|
|
|
/** A loaded registration's bundle deals (bundles it already has stay usable). */
|
|
async function registrationBundles(registration, db = prisma) {
|
|
const bundles = await loadBundlesFor(registration.eventId, { keepIds: (registration.bundleApplications || []).map(a => a.bundleId) }, db);
|
|
return applyBundles(bundles, buildCouponLines(registration));
|
|
}
|
|
|
|
/**
|
|
* Re-works a registration's discounts: bundle deals first, then the coupon on what's
|
|
* left. Percentage and comp codes are recalculated over the new items, fixed codes keep
|
|
* their amount (capped at what's still in scope). `keepCouponAmounts` keeps every
|
|
* coupon's amount (capped the same way): used when only prices changed, e.g. an
|
|
* early-bird tier expiring. Updates bundleDiscount and discountTotal and returns
|
|
* discountTotal. Pass `tx` to run inside the caller's transaction.
|
|
*/
|
|
async function recalculateRegistrationDiscount(registrationId, db = prisma, { keepCouponAmounts = false } = {}) {
|
|
const registration = await db.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
|
|
if (!registration) return 0;
|
|
|
|
const bundles = await registrationBundles(registration, db);
|
|
await saveBundleApplications(registrationId, registration.bundleApplications, bundles.applications, db);
|
|
const lines = bundles.lines;
|
|
let total = bundles.discount;
|
|
for (const r of registration.couponRedemptions) {
|
|
let amount;
|
|
if (r.coupon.type === 'fixed' || keepCouponAmounts) {
|
|
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 || Math.abs(bundles.discount - registration.bundleDiscount) > 0.001) {
|
|
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total, bundleDiscount: bundles.discount } });
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Releases a registration's coupon uses so they can be claimed again (cancelled, or the
|
|
* code removed). Bundle deals stay.
|
|
*/
|
|
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 recalculateRegistrationDiscount(registrationId, db);
|
|
return released.count;
|
|
}
|
|
|
|
module.exports = {
|
|
normalizeCode,
|
|
couponError,
|
|
buildCouponLines,
|
|
computeCouponDiscount,
|
|
validateCoupon,
|
|
describeCoupon,
|
|
findCouponByCode,
|
|
countCouponUses,
|
|
previewCoupon,
|
|
previewBundles,
|
|
applyCouponToRegistration,
|
|
recalculateRegistrationDiscount,
|
|
releaseRegistrationCoupons,
|
|
};
|