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:
2026-08-20 17:26:35 +02:00
co-authored by Claude Sonnet 5
parent f2c3172e16
commit f0f8d4c242
22 changed files with 804 additions and 225 deletions
@@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `feeAmount` on the `EventCashupLine` table. All the data in the column will be lost.
- You are about to drop the column `feeAmount` on the `Payment` table. All the data in the column will be lost.
- You are about to drop the column `feeChannel` on the `Payment` table. All the data in the column will be lost.
- You are about to drop the column `feeRate` on the `Payment` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "EventCashupLine" DROP COLUMN "feeAmount";
-- AlterTable
ALTER TABLE "Payment" DROP COLUMN "feeAmount",
DROP COLUMN "feeChannel",
DROP COLUMN "feeRate";
@@ -0,0 +1,29 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactName" TEXT,
ADD COLUMN "contactPhone" TEXT,
ADD COLUMN "requiresRegistration" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "RegistrationOptionTranche" (
"id" TEXT NOT NULL,
"registrationOptionId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"priceSnapshot" DOUBLE PRECISION NOT NULL,
"appliedTierId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationOptionTranche_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RegistrationOptionTranche_registrationOptionId_idx" ON "RegistrationOptionTranche"("registrationOptionId");
-- CreateIndex
CREATE INDEX "RegistrationOptionTranche_appliedTierId_idx" ON "RegistrationOptionTranche"("appliedTierId");
-- AddForeignKey
ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_appliedTierId_fkey" FOREIGN KEY ("appliedTierId") REFERENCES "EarlyBirdTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+27
View File
@@ -101,6 +101,10 @@ model Event {
isActive Boolean @default(true)
isHidden Boolean @default(false)
requiresAuth Boolean @default(true)
requiresRegistration Boolean @default(true)
contactName String?
contactPhone String?
contactEmail String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
@@ -163,6 +167,7 @@ model EarlyBirdTier {
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
registrationOptions RegistrationOption[]
tranches RegistrationOptionTranche[]
@@index([eventOptionId, deadline])
@@index([variantId])
@@ -217,6 +222,7 @@ model RegistrationOption {
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
tickets Ticket[]
tranches RegistrationOptionTranche[]
@@index([registrationId])
@@index([eventOptionId])
@@ -224,6 +230,27 @@ model RegistrationOption {
@@index([appliedTierId])
}
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
// (mirrors the Payment model's append-only pattern) — this is what lets a single ticket
// type be bought in multiple batches at different early-bird prices without either batch's
// price bleeding into the other. RegistrationOption.quantity/priceSnapshot/appliedTierId
// stay in sync as an aggregate (quantity = sum of tranche quantities; priceSnapshot/appliedTierId
// mirror the most recently added tranche) for the many call sites that only need "how many"
// or a single display price.
model RegistrationOptionTranche {
id String @id @default(uuid())
registrationOptionId String
quantity Int
priceSnapshot Float
appliedTierId String?
createdAt DateTime @default(now())
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
@@index([registrationOptionId])
@@index([appliedTierId])
}
model Payment {
id String @id @default(uuid())
amount Float