Fix early-bird price blending and mislabeling; add contact-only events
- 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>
This commit is contained in:
+131
-43
@@ -7,11 +7,21 @@
|
||||
* 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.
|
||||
* - 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');
|
||||
@@ -74,12 +84,15 @@ async function resolveOptionPrice(option, requestedQty = 1) {
|
||||
// Skip expired tiers
|
||||
if (now >= new Date(tier.deadline)) continue;
|
||||
|
||||
// Check stock limit if one is set
|
||||
// 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.registrationOption.aggregate({
|
||||
const soldAgg = await prisma.registrationOptionTranche.aggregate({
|
||||
where: {
|
||||
appliedTierId: tier.id,
|
||||
registration: { status: { not: 'cancelled' } }
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
||||
},
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
@@ -125,8 +138,8 @@ async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
|
||||
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' } } },
|
||||
const soldAgg = await prisma.registrationOptionTranche.aggregate({
|
||||
where: { appliedTierId: tier.id, registrationOption: { registration: { status: { not: 'cancelled' } } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const tierSold = soldAgg._sum?.quantity || 0;
|
||||
@@ -154,21 +167,46 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true, variants: true } }
|
||||
eventOption: { include: { earlyBirdTiers: true, variants: true } },
|
||||
tranches: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
}
|
||||
});
|
||||
if (!registration) return { changed: false };
|
||||
|
||||
// Each registrationOption 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(registration.registrationOptions.map(async (ro) => {
|
||||
// Only refresh options that were priced via a tier
|
||||
if (!ro.appliedTierId) return 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 === ro.appliedTierId);
|
||||
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.
|
||||
@@ -177,19 +215,21 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
|
||||
// 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);
|
||||
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity)
|
||||
: await resolveOptionPrice(ro.eventOption, newest.quantity);
|
||||
|
||||
const tierChanged = resolved.tierId !== ro.appliedTierId;
|
||||
const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001;
|
||||
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
|
||||
}
|
||||
data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -199,14 +239,43 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
* Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
|
||||
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise).
|
||||
*
|
||||
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption}
|
||||
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
|
||||
* and optionally payments[]
|
||||
* @param {Date} atTime - evaluation time (used for legacy fallback only)
|
||||
* @returns {number}
|
||||
@@ -222,22 +291,38 @@ function computeRegistrationTotalDue(registration, atTime) {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return registration.registrationOptions.reduce((sum, ro) => {
|
||||
const qty = Number(ro.quantity || 0);
|
||||
let unit;
|
||||
return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
|
||||
}
|
||||
|
||||
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);
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
return sum + qty * unit;
|
||||
}, 0);
|
||||
function attachComputedTotalsToList(registrations) {
|
||||
return (registrations || []).map(attachComputedTotals);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -245,5 +330,8 @@ module.exports = {
|
||||
resolveOptionPrice,
|
||||
resolveVariantTierPrice,
|
||||
refreshPricingForRegistration,
|
||||
computeOptionLineTotal,
|
||||
computeRegistrationTotalDue,
|
||||
attachComputedTotals,
|
||||
attachComputedTotalsToList,
|
||||
};
|
||||
Reference in New Issue
Block a user