248 lines
9.5 KiB
JavaScript
248 lines
9.5 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.
|
|
* - 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,
|
|
}; |