Transfers now send the new holder a link (to the email/phone typed) and only complete once they accept; they can decline and the sender can cancel. No response reveals whether contact details belong to an account, and attendee account links are no longer sent to payers. Terms of Use and Privacy Policy are updated for all features since 1.12, with a full cookie policy and a "Cookie settings" control. The /docs route list now covers every mounted route, and account activation auto-login is fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
386 lines
17 KiB
JavaScript
386 lines
17 KiB
JavaScript
/**
|
|
* Early-bird pricing utility
|
|
*
|
|
* Rules:
|
|
* - Base price is eventOption.price
|
|
* - resolveOptionPrice: picks the cheapest applicable tier, checking both deadline AND stock limits.
|
|
* Used at registration-creation time and again at payment-initiation time.
|
|
* - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and
|
|
* as a fallback for legacy RegistrationOption rows that have no priceSnapshot.
|
|
* - A RegistrationOption is an aggregate line (one per registration+option+variant); each
|
|
* purchase-at-a-price is its own RegistrationOptionTranche row underneath it (quantity +
|
|
* priceSnapshot + appliedTierId, never mutated after creation — mirrors the Payment model's
|
|
* append-only pattern). This is what lets someone buy more of the same ticket type after an
|
|
* early-bird tier expires without the new price bleeding onto tickets already bought.
|
|
* RegistrationOption.quantity/priceSnapshot/appliedTierId are kept as a maintained mirror
|
|
* (quantity = sum of tranche quantities; priceSnapshot/appliedTierId = most recent tranche)
|
|
* for code that only needs "how many" or a single display price.
|
|
* - computeRegistrationTotalDue: sums quantity*priceSnapshot across each RegistrationOption's
|
|
* tranches (falling back to the legacy single-priceSnapshot/getEffectiveUnitPrice path for
|
|
* rows created before tranches existed).
|
|
* - Staff price: an option (or variant) can carry a staffPrice that Staff and above get
|
|
* automatically when it's lower than the price they'd otherwise pay.
|
|
* - Coupon discounts are stored on Registration.discountTotal (see utils/coupons.js) and
|
|
* subtracted by computeRegistrationTotalDue, never below zero.
|
|
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
|
|
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
|
|
* applicable tier/price for just that tranche. Older tranches — already priced-in — are
|
|
* never touched.
|
|
*/
|
|
|
|
const prisma = require('../config/db');
|
|
|
|
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
|
|
|
/** The staff price this user gets for an option/variant, or null if none applies. */
|
|
function staffPriceFor(option, variantId, user) {
|
|
if (!user || !STAFF_ROLES.includes(user.role)) return null;
|
|
const variant = variantId ? (option?.variants || []).find(v => v.id === variantId) : null;
|
|
const price = variant && variant.staffPrice !== null && variant.staffPrice !== undefined
|
|
? variant.staffPrice
|
|
: option?.staffPrice;
|
|
return price === null || price === undefined ? null : Number(price);
|
|
}
|
|
|
|
// Staff price wins only when it's cheaper than the resolved (possibly early-bird) price.
|
|
function withStaffPrice(resolved, staffPrice) {
|
|
if (staffPrice === null || !(staffPrice < resolved.price)) return resolved;
|
|
return { price: staffPrice, tierId: null };
|
|
}
|
|
|
|
/**
|
|
* Compute effective unit price for an event option at a given time considering early-bird tiers.
|
|
* Only checks deadline (not stock). Used for display and as a legacy fallback.
|
|
*
|
|
* @param {object} eventOption - includes price:number and earlyBirdTiers?:Array<{deadline:string|Date, price:number}>
|
|
* @param {Date|string|null} referenceTime - usually the last payment time; if null, falls back to atTime
|
|
* @param {Date} atTime - payment/evaluation time (e.g., now or payment.createdAt)
|
|
* @returns {number}
|
|
*/
|
|
function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
|
|
if (!eventOption) return 0;
|
|
const base = Number(eventOption.price || 0);
|
|
const tiers = Array.isArray(eventOption.earlyBirdTiers) ? eventOption.earlyBirdTiers.slice() : [];
|
|
if (!tiers.length) return base;
|
|
|
|
const t = atTime ? new Date(atTime) : new Date();
|
|
// If no referenceTime (no payments yet), use atTime so early-bird applies based on "now"
|
|
const ref = referenceTime ? new Date(referenceTime) : t;
|
|
|
|
// Only tiers whose deadline is after BOTH reference and payment/evaluation times qualify
|
|
const applicable = tiers
|
|
.map(x => ({ ...x, deadline: new Date(x.deadline) }))
|
|
.filter(x => (ref < x.deadline) && (t < x.deadline))
|
|
.sort((a, b) => a.deadline.getTime() - b.deadline.getTime() || (a.order || 0) - (b.order || 0) || a.price - b.price);
|
|
|
|
if (applicable.length === 0) return base;
|
|
const chosen = applicable[0];
|
|
const price = Number(chosen.price);
|
|
if (!(price >= 0)) return base;
|
|
return price;
|
|
}
|
|
|
|
/**
|
|
* Resolve the best applicable early-bird tier price for an event option.
|
|
* Checks BOTH deadline AND stock limits. Cancelled registrations are excluded from stock counts.
|
|
* Tiers are sorted cheapest-first (best deal for the user); earliest deadline breaks ties.
|
|
*
|
|
* @param {object} option - EventOption with price, earlyBirdTiers[]
|
|
* @param {number} requestedQty - quantity being purchased
|
|
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
|
|
* @returns {Promise<{ price: number, tierId: string|null }>}
|
|
*/
|
|
async function resolveOptionPrice(option, requestedQty = 1, user = null) {
|
|
return withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user));
|
|
}
|
|
|
|
async function resolveOptionTierPrice(option, requestedQty) {
|
|
const now = new Date();
|
|
// Only option-level tiers (no variant association)
|
|
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
|
|
.filter(t => !t.variantId)
|
|
.slice();
|
|
|
|
// Sort cheapest-first; earliest deadline breaks ties
|
|
tiers.sort((a, b) => {
|
|
if (a.price !== b.price) return a.price - b.price;
|
|
return new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
|
|
});
|
|
|
|
for (const tier of tiers) {
|
|
// Skip expired tiers
|
|
if (now >= new Date(tier.deadline)) continue;
|
|
|
|
// Check stock limit if one is set. Sold-so-far is summed across tranches (not
|
|
// RegistrationOption rows directly) because one RegistrationOption can now span
|
|
// multiple tiers across its tranches — the row's own appliedTierId/quantity only
|
|
// reflects its most recent tranche.
|
|
if (tier.stockLimit > 0) {
|
|
const soldAgg = await prisma.registrationOptionTranche.aggregate({
|
|
where: {
|
|
appliedTierId: tier.id,
|
|
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
|
},
|
|
_sum: { quantity: true }
|
|
});
|
|
const tierSold = soldAgg._sum?.quantity || 0;
|
|
if (tierSold + requestedQty > tier.stockLimit) continue; // tier exhausted — try next
|
|
}
|
|
|
|
return { price: tier.price, tierId: tier.id };
|
|
}
|
|
|
|
// No tier applicable — fall back to base option price
|
|
return { price: option.price, tierId: null };
|
|
}
|
|
|
|
/**
|
|
* Resolve the best applicable early-bird tier price for a specific variant.
|
|
* Checks tiers that have variantId matching the given variant.
|
|
* Falls back to variant.price (or option.price if variant has no override) when no tier applies.
|
|
*
|
|
* @param {object} option - EventOption with price, earlyBirdTiers[], variants[]
|
|
* @param {string} variantId
|
|
* @param {number} requestedQty
|
|
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
|
|
* @returns {Promise<{ price: number, tierId: string|null }>}
|
|
*/
|
|
async function resolveVariantTierPrice(option, variantId, requestedQty = 1, user = null) {
|
|
return withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user));
|
|
}
|
|
|
|
async function resolveVariantOnlyTierPrice(option, variantId, requestedQty) {
|
|
const now = new Date();
|
|
const variant = (option.variants || []).find(v => v.id === variantId);
|
|
const basePrice = (variant && variant.price !== null && variant.price !== undefined)
|
|
? Number(variant.price)
|
|
: Number(option.price || 0);
|
|
|
|
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
|
|
.filter(t => t.variantId === variantId)
|
|
.slice();
|
|
|
|
if (!tiers.length) return { price: basePrice, tierId: null };
|
|
|
|
tiers.sort((a, b) => {
|
|
if (a.price !== b.price) return a.price - b.price;
|
|
return new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
|
|
});
|
|
|
|
for (const tier of tiers) {
|
|
if (now >= new Date(tier.deadline)) continue;
|
|
if (tier.stockLimit > 0) {
|
|
const soldAgg = await prisma.registrationOptionTranche.aggregate({
|
|
where: { appliedTierId: tier.id, registrationOption: { registration: { status: { not: 'cancelled' } } } },
|
|
_sum: { quantity: true }
|
|
});
|
|
const tierSold = soldAgg._sum?.quantity || 0;
|
|
if (tierSold + requestedQty > tier.stockLimit) continue;
|
|
}
|
|
return { price: tier.price, tierId: tier.id };
|
|
}
|
|
|
|
return { price: basePrice, tierId: null };
|
|
}
|
|
|
|
/**
|
|
* Re-evaluate early-bird prices for all RegistrationOptions that have an appliedTierId.
|
|
* If a tier is now expired or its stock is exhausted, the next applicable tier (or base price)
|
|
* is resolved and priceSnapshot + appliedTierId are updated in the DB.
|
|
*
|
|
* Call this before processing any payment to ensure stock-based price forfeiture is enforced.
|
|
*
|
|
* @param {string} registrationId
|
|
* @returns {Promise<{ changed: boolean }>}
|
|
*/
|
|
async function refreshPricingForRegistration(registrationId) {
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: registrationId },
|
|
include: {
|
|
registrationOptions: {
|
|
include: {
|
|
eventOption: { include: { earlyBirdTiers: true, variants: true } },
|
|
tranches: true,
|
|
}
|
|
},
|
|
payments: true,
|
|
user: { select: { role: true } },
|
|
}
|
|
});
|
|
if (!registration) return { changed: false };
|
|
|
|
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
|
|
|
// For each option, only its single newest tranche is ever a repricing candidate — older
|
|
// tranches were already locked in at purchase time and must never be touched. A newest
|
|
// tranche is only touched once payments-so-far are established to not yet cover it (i.e.
|
|
// it's the still-unpaid remainder) — legacy rows with no tranches are left to the
|
|
// getEffectiveUnitPrice fallback in computeRegistrationTotalDue instead.
|
|
let registrationOlderTotal = 0;
|
|
const candidates = [];
|
|
for (const ro of registration.registrationOptions) {
|
|
const tranches = ro.tranches || [];
|
|
if (tranches.length === 0) continue;
|
|
const sorted = [...tranches].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
|
const newest = sorted[sorted.length - 1];
|
|
const older = sorted.slice(0, -1);
|
|
registrationOlderTotal += older.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
|
|
candidates.push({ ro, newest });
|
|
}
|
|
|
|
// Payments don't even cover the already-locked-in older tranches yet — leave everything
|
|
// alone rather than guessing which portion is "paid for".
|
|
if (totalPaid < registrationOlderTotal - 0.001) return { changed: false };
|
|
|
|
// Each candidate is independent, so resolve/update them concurrently instead of one at a
|
|
// time — this loop sits directly in the payment-capture path.
|
|
const results = await Promise.all(candidates.map(async ({ ro, newest }) => {
|
|
// Only refresh tranches that were priced via a tier
|
|
if (!newest.appliedTierId) return false;
|
|
|
|
// Find the currently applied tier
|
|
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === newest.appliedTierId);
|
|
|
|
if (currentTier && new Date() < new Date(currentTier.deadline)) {
|
|
// The tier's deadline is still in the future — honor the locked price.
|
|
return false;
|
|
}
|
|
|
|
// Deadline has passed (or tier record missing) — resolve the next applicable tier
|
|
const resolved = ro.variantId
|
|
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity, registration.user)
|
|
: await resolveOptionPrice(ro.eventOption, newest.quantity, registration.user);
|
|
|
|
const tierChanged = resolved.tierId !== newest.appliedTierId;
|
|
const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
|
|
|
|
if (tierChanged || priceChanged) {
|
|
await prisma.registrationOptionTranche.update({
|
|
where: { id: newest.id },
|
|
data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
|
|
});
|
|
// Mirror onto the aggregate row — it's this option's most recent tranche.
|
|
await prisma.registrationOption.update({
|
|
where: { id: ro.id },
|
|
data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
}));
|
|
|
|
return { changed: results.some(Boolean) };
|
|
}
|
|
|
|
/**
|
|
* Compute the total for a single RegistrationOption line — sums quantity*priceSnapshot
|
|
* across its tranches (each priced at whatever was in effect when it was purchased).
|
|
* Falls back to the legacy single priceSnapshot/getEffectiveUnitPrice path for rows
|
|
* created before tranches existed.
|
|
*
|
|
* @param {object} ro - RegistrationOption, optionally with .tranches[] included
|
|
* @param {Date|null} lastPaymentAt - used only for the legacy getEffectiveUnitPrice fallback
|
|
* @param {Date} atTime - evaluation time (used only for the legacy fallback)
|
|
* @returns {number}
|
|
*/
|
|
function computeOptionLineTotal(ro, lastPaymentAt, atTime) {
|
|
if (Array.isArray(ro.tranches) && ro.tranches.length > 0) {
|
|
return ro.tranches.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
|
|
}
|
|
|
|
const qty = Number(ro.quantity || 0);
|
|
let unit;
|
|
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) {
|
|
// priceSnapshot is authoritative — set at registration creation and kept current
|
|
// by refreshPricingForRegistration at payment initiation time.
|
|
unit = ro.priceSnapshot;
|
|
} else {
|
|
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines
|
|
const eo = ro.eventOption || {};
|
|
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime);
|
|
}
|
|
return qty * unit;
|
|
}
|
|
|
|
/**
|
|
* Compute total due for a registration at a given time.
|
|
*
|
|
* Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
|
|
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise),
|
|
* less the registration's coupon discount (`discountTotal`), never below zero.
|
|
*
|
|
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
|
|
* and optionally payments[]
|
|
* @param {Date} atTime - evaluation time (used for legacy fallback only)
|
|
* @returns {number}
|
|
*/
|
|
function computeRegistrationTotalDue(registration, atTime) {
|
|
if (!registration || !Array.isArray(registration.registrationOptions)) return 0;
|
|
|
|
// Determine the last payment time (used only for the legacy getEffectiveUnitPrice fallback)
|
|
let lastPaymentAt = null;
|
|
try {
|
|
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
|
|
lastPaymentAt = new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())));
|
|
}
|
|
} catch {}
|
|
|
|
const subtotal = computeRegistrationSubtotal(registration, lastPaymentAt, atTime);
|
|
return Math.max(0, subtotal - Number(registration.discountTotal || 0));
|
|
}
|
|
|
|
/** Sum of the item lines before any coupon discount. */
|
|
function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
|
|
if (!registration || !Array.isArray(registration.registrationOptions)) return 0;
|
|
return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
|
|
}
|
|
|
|
/**
|
|
* Attach computed, tranche-aware display totals to a registration in place: `lineTotal` on
|
|
* each RegistrationOption and `totalDueComputed` on the registration itself. Lets API
|
|
* responses hand the frontend an exact total instead of every page re-deriving
|
|
* unitPrice*quantity client-side (which goes wrong once a line spans multiple tranches).
|
|
*
|
|
* @param {object} registration - requires registrationOptions[].tranches included
|
|
* @returns {object} the same registration, mutated
|
|
*/
|
|
function attachComputedTotals(registration) {
|
|
if (!registration) return registration;
|
|
// Never send which named attendees have an account: the person who typed their email or
|
|
// phone mustn't be able to tell (the link is used server-side only).
|
|
if (Array.isArray(registration.attendees)) {
|
|
registration.attendees = registration.attendees.map(({ userId, ...a }) => a);
|
|
}
|
|
let lastPaymentAt = null;
|
|
try {
|
|
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
|
|
lastPaymentAt = new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())));
|
|
}
|
|
} catch {}
|
|
const now = new Date();
|
|
if (Array.isArray(registration.registrationOptions)) {
|
|
for (const ro of registration.registrationOptions) {
|
|
ro.lineTotal = computeOptionLineTotal(ro, lastPaymentAt, now);
|
|
}
|
|
}
|
|
registration.subtotalComputed = computeRegistrationSubtotal(registration, lastPaymentAt, now);
|
|
registration.totalDueComputed = computeRegistrationTotalDue(registration, now);
|
|
return registration;
|
|
}
|
|
|
|
function attachComputedTotalsToList(registrations) {
|
|
return (registrations || []).map(attachComputedTotals);
|
|
}
|
|
|
|
module.exports = {
|
|
getEffectiveUnitPrice,
|
|
resolveOptionPrice,
|
|
resolveVariantTierPrice,
|
|
refreshPricingForRegistration,
|
|
computeOptionLineTotal,
|
|
computeRegistrationTotalDue,
|
|
computeRegistrationSubtotal,
|
|
staffPriceFor,
|
|
attachComputedTotals,
|
|
attachComputedTotalsToList,
|
|
}; |