/** * 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). * - 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'); /** * 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 * @returns {Promise<{ price: number, tierId: string|null }>} */ async function resolveOptionPrice(option, requestedQty = 1) { 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 * @returns {Promise<{ price: number, tierId: string|null }>} */ async function resolveVariantTierPrice(option, variantId, requestedQty = 1) { 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, } }); 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) : await resolveOptionPrice(ro.eventOption, newest.quantity); 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). * * @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 {} 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; 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.totalDueComputed = computeRegistrationTotalDue(registration, now); return registration; } function attachComputedTotalsToList(registrations) { return (registrations || []).map(attachComputedTotals); } module.exports = { getEffectiveUnitPrice, resolveOptionPrice, resolveVariantTierPrice, refreshPricingForRegistration, computeOptionLineTotal, computeRegistrationTotalDue, attachComputedTotals, attachComputedTotalsToList, };