/** * 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. * - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after * refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice. * - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption * that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier * is now expired or its stock is exhausted. */ 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 if (tier.stockLimit > 0) { const soldAgg = await prisma.registrationOption.aggregate({ where: { appliedTierId: tier.id, 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.registrationOption.aggregate({ where: { appliedTierId: tier.id, 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 } } } } } }); if (!registration) return { changed: false }; let anyChanged = false; for (const ro of registration.registrationOptions) { // Only refresh options that were priced via a tier if (!ro.appliedTierId) continue; // Find the currently applied tier const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId); if (currentTier && new Date() < new Date(currentTier.deadline)) { // The tier's deadline is still in the future — honor the locked price. continue; } // Deadline has passed (or tier record missing) — resolve the next applicable tier const resolved = ro.variantId ? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity) : await resolveOptionPrice(ro.eventOption, ro.quantity); const tierChanged = resolved.tierId !== ro.appliedTierId; const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001; if (tierChanged || priceChanged) { await prisma.registrationOption.update({ where: { id: ro.id }, data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId } }); anyChanged = true; } } return { changed: anyChanged }; } /** * Compute total due for a registration at a given time. * * Uses priceSnapshot when present (authoritative — set at registration time and refreshed * before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice * for legacy rows without a snapshot. * * @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption} * 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) => { 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 sum + qty * unit; }, 0); } module.exports = { getEffectiveUnitPrice, resolveOptionPrice, resolveVariantTierPrice, refreshPricingForRegistration, computeRegistrationTotalDue, };