Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+248
View File
@@ -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,
};