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:
parent
a987e046da
commit
49e45ff94c
2 changed files with 29 additions and 19 deletions
|
|
@ -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<void> {
|
||||
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.
|
||||
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<void> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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 = `<!doctype html>
|
|||
|
||||
<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><b>Scannable ticket total</b> = adults + youth (13-16). <b>Children 12 & 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 & 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>
|
||||
|
||||
<h2>Fields</h2>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue