diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 3cf3347..7f5595d 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -5,15 +5,17 @@ import { createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.js"; import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js"; -// Adult name field bases, in order: purchaser first, then additional regular -// adults (Adult Ticket #2–#10), then donor adult tickets (Adult Donor #1–#2). -// Donor tickets are the free/voucher adult admissions; their names live in the -// separate names_Donor_* groups but are still adults who need a gate pass. -const ADULT_NAME_BASES = [ +// Regular adult attendee name groups (Adult Ticket #1–#10), in order. +const REGULAR_NAME_BASES = [ "names", "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9", - "names_Donor_1", "names_Donor_2", ]; +// Donor voucher ticket name groups. Each FILLED group is one free voucher adult +// ticket — the voucher tickets live in these two name fields (there's no +// separate quantity field for them). +const DONOR_NAME_BASES = ["names_Donor_1", "names_Donor_2"]; +// All adult names for the gate display list. +const ADULT_NAME_BASES = [...REGULAR_NAME_BASES, ...DONOR_NAME_BASES]; export async function webhookRoutes(app: FastifyInstance): Promise { const handler = async (req: any, reply: any) => { @@ -30,17 +32,23 @@ export async function webhookRoutes(app: FastifyInstance): Promise { // buying for others or add-ons only) + the attendee name groups. const customerName = nameGroup(body, "customer_name") || String(body.name ?? "").trim(); const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean); - // Person the email is addressed to (the buyer). + // Free voucher adult tickets = number of donor name fields filled. + const voucherTickets = DONOR_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean).length; + // Ticket title + email recipient = the billing/customer name (fall back to + // the first attendee only if the customer name is somehow missing). const purchaser = customerName || adultNames[0]; - // Title shown at the gate: the first attendee if any, else the customer. - const title = adultNames[0] || customerName; + const title = customerName || adultNames[0]; if (!title) { return reply.code(400).send({ error: "missing_fields", detail: "customer or attendee name is required" }); } - // Attendee counts. + // Attendee counts. Adults = regular (paid) tickets + additional paid donor + // tickets + free voucher tickets (one per donor name provided). const counts = { - adults: qty(body.item_quantity_adult_ticket_reg) + qty(body.item_quantity_adult_ticket_donor), + adults: + qty(body.item_quantity_adult_ticket_reg) + + qty(body.item_quantity_adult_ticket_donor) + + voucherTickets, youth: qty(body.item_quantity_youth_ticket_reg) + qty(body.item_quantity_youth_ticket_donor), kids12: qty(body.item_quantity_kids_12), kids9: qty(body.item_quantity_kids_9), @@ -57,7 +65,9 @@ export async function webhookRoutes(app: FastifyInstance): Promise { donorTier === "donor" || toBool(body.donor_eligible) || selected(body.input_radio); // "Are you a campground donor?" - const vouchers = qty(body.vouchers); + // Vouchers consumed in this order = the free voucher tickets actually taken + // (donor names filled), which is what the ticket-voucher lookup subtracts. + const vouchers = voucherTickets; // Extras (best-effort from payment fields — donor variants may be free/$0). const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor); diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts index ea5f10a..c2a146c 100644 --- a/backend/src/routes/webhookDoc.ts +++ b/backend/src/routes/webhookDoc.ts @@ -11,21 +11,21 @@ interface Field { } const FIELDS: Field[] = [ - { key: "customer_name", req: "required", type: "name (compound)", desc: "Billing / customer name — the buyer. Used to address the email and as the ticket title when there are no attendee names (add-on-only orders). Object {first_name, middle_name, last_name}; flat customer_name[first_name] keys also accepted." }, + { key: "customer_name", req: "required", type: "name (compound)", desc: "Billing / customer name — the buyer. Stored as the ticket title and used to address the email. Object {first_name, middle_name, last_name}; flat customer_name[first_name] keys also accepted." }, { key: "names", req: "optional", type: "name (compound)", desc: "Adult Ticket #1 attendee — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys. May be empty when buying only donor tickets or add-ons." }, - { key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional adult attendee names (Adults #2–#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." }, - { key: "names_Donor_1 / names_Donor_2", req: "optional", type: "name (compound)", desc: "Donor (voucher) adult ticket names — the free adult admissions. Counted via item_quantity_adult_ticket_donor and added to the gate name list." }, + { key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional regular adult attendee names (Adults #2–#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." }, + { key: "names_Donor_1 / names_Donor_2", req: "optional", type: "name (compound)", desc: "Donor voucher ticket names. Each FILLED group is one FREE voucher adult ticket — this is how voucher tickets are counted (there's no quantity field for them). Also added to the gate name list and recorded as the vouchers consumed." }, { key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." }, { key: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." }, - { key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." }, - { key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." }, + { key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Regular (non-donor) adult tickets." }, + { key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "ADDITIONAL paid donor adult tickets bought beyond the free vouchers. Added to the adult total; does NOT include the voucher tickets (those come from names_Donor_1/2)." }, { key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." }, { key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. FREE — stored but NOT counted toward the scannable ticket total." }, { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. FREE — stored but NOT counted toward the scannable ticket total." }, { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — stored but NOT counted toward the scannable ticket total." }, { key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." }, { key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." }, - { key: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." }, + { key: "vouchers", req: "optional", type: "hidden", desc: "Voucher entitlement from the ticket-voucher lookup (informational). The vouchers actually consumed are counted from the filled names_Donor_1/2 groups, not this field." }, { key: "input_radio", req: "optional", type: "choice", desc: "'Are you a campground donor?' — also used as a donor signal." }, { key: "payment_parking_reg / _donor", req: "optional", type: "payment", desc: "Car parking. Flagged if either variant is selected." }, { key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." }, @@ -121,7 +121,7 @@ const PAGE = `

What it does

On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject "2026 Beartaria Campgrounds Tickets"). FluentForms sends the payment receipt separately.

-

Scannable ticket total = adults + youth (13-16). Children 12 & under are free (charging starts at 13) — their counts are stored and shown to gate staff, but not counted toward the ticket total. Each adult name provided is stored and shown on a successful scan.

+

Scannable ticket total = adults + youth (13-16). Adults = item_quantity_adult_ticket_reg (regular) + item_quantity_adult_ticket_donor (extra paid donor tickets) + the number of donor voucher names (names_Donor_1/2 — each filled name is one free voucher ticket). Children 12 & under are free (charging starts at 13) — stored and shown to gate staff, but not counted toward the total. Each adult name provided is stored and shown on a successful scan; the ticket title is the customer_name.

Tickets are optional. A customer can buy ice, an ATV/UTV pass, or parking with no admission ticket, or buy tickets for other people. A record + QR is still created as long as there's something to redeem or verify at the gate (a ticket, ice, or an add-on). Only a truly empty order is rejected.

Fields