- Early-bird pricing: RegistrationOption now tracks each purchase as a separate price tranche instead of overwriting a single price/quantity on repeat purchases, so buying more tickets after a tier expires no longer re-prices tickets already bought at the old price. Stock-limit checks, total-due calculation, and the Finance report's revenue-by- option are all tranche-aware; pages that showed one blended price per line now render/total each tranche. Viewing a pending/partially-paid registration (dashboard, detail page, or an event's registration list) now refreshes stale pricing on the spot instead of only at payment time. - Fixed the "(early bird)" dashboard label incorrectly firing on any line priced below the base option price (e.g. a plain cheaper variant) — it now checks the real applied-tier flag. - Added contact-only events (e.g. baptism): no registration/payment flow, shown on the public site with a "Contact us" popup instead of a Register button. Configurable via the admin event wizard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
337 lines
14 KiB
JavaScript
337 lines
14 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).
|
|
* - 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,
|
|
}; |