Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user