Webhook: count voucher tickets + use customer_name as title

Two corrections to the adult-ticket model:

1. Adult total was undercounting. Voucher (donor) tickets live only in
   the names_Donor_1 / names_Donor_2 name fields — each filled name is
   one free voucher adult ticket — and weren't counted at all. Adults
   now = item_quantity_adult_ticket_reg (regular) +
   item_quantity_adult_ticket_donor (extra PAID donor tickets beyond
   vouchers) + the donor voucher-name count. Vouchers consumed is now
   that same donor-name count (what the ticket-voucher lookup subtracts),
   instead of the hidden `vouchers` entitlement.

2. Ticket title now comes from customer_name (billing name), not the
   first adult ticket name.

Doc updated to describe the adult total and the title source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-17 19:07:56 +00:00
parent a987e046da
commit 49e45ff94c
2 changed files with 29 additions and 19 deletions

View file

@ -5,15 +5,17 @@ import { createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js"; import { renderQrPng } from "../services/qrcode.js";
import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js"; import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js";
// Adult name field bases, in order: purchaser first, then additional regular // Regular adult attendee name groups (Adult Ticket #1#10), in order.
// adults (Adult Ticket #2#10), then donor adult tickets (Adult Donor #1#2). const REGULAR_NAME_BASES = [
// 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 = [
"names", "names",
"names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9", "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<void> { export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const handler = async (req: any, reply: any) => { const handler = async (req: any, reply: any) => {
@ -30,17 +32,23 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
// buying for others or add-ons only) + the attendee name groups. // buying for others or add-ons only) + the attendee name groups.
const customerName = nameGroup(body, "customer_name") || String(body.name ?? "").trim(); const customerName = nameGroup(body, "customer_name") || String(body.name ?? "").trim();
const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean); 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]; const purchaser = customerName || adultNames[0];
// Title shown at the gate: the first attendee if any, else the customer. const title = customerName || adultNames[0];
const title = adultNames[0] || customerName;
if (!title) { if (!title) {
return reply.code(400).send({ error: "missing_fields", detail: "customer or attendee name is required" }); 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 = { 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), youth: qty(body.item_quantity_youth_ticket_reg) + qty(body.item_quantity_youth_ticket_donor),
kids12: qty(body.item_quantity_kids_12), kids12: qty(body.item_quantity_kids_12),
kids9: qty(body.item_quantity_kids_9), kids9: qty(body.item_quantity_kids_9),
@ -57,7 +65,9 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
donorTier === "donor" || donorTier === "donor" ||
toBool(body.donor_eligible) || toBool(body.donor_eligible) ||
selected(body.input_radio); // "Are you a campground donor?" 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). // Extras (best-effort from payment fields — donor variants may be free/$0).
const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor); const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor);

View file

@ -11,21 +11,21 @@ interface Field {
} }
const FIELDS: 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", 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_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) adult ticket names — the free adult admissions. Counted via item_quantity_adult_ticket_donor and added to the gate name list." }, { 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: "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: "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_reg", req: "required", type: "quantity", desc: "Regular (non-donor) adult tickets." },
{ key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." }, { 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_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_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_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: "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_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: "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: "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_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." }, { key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." },
@ -121,7 +121,7 @@ const PAGE = `<!doctype html>
<h2>What it does</h2> <h2>What it does</h2>
<p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p> <p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p>
<p><b>Scannable ticket total</b> = adults + youth (13-16). <b>Children 12 &amp; under are free</b> (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.</p> <p><b>Scannable ticket total</b> = adults + youth (13-16). <b>Adults</b> = <code>item_quantity_adult_ticket_reg</code> (regular) + <code>item_quantity_adult_ticket_donor</code> (extra paid donor tickets) + the number of donor voucher names (<code>names_Donor_1/2</code> each filled name is one free voucher ticket). <b>Children 12 &amp; under are free</b> (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 <code>customer_name</code>.</p>
<p><b>Tickets are optional.</b> 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.</p> <p><b>Tickets are optional.</b> 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.</p>
<h2>Fields</h2> <h2>Fields</h2>