From 049f433930de3fa74fe1eecd57ce8e2043bd0a02 Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 20 Jul 2026 07:40:05 +0000 Subject: [PATCH] Fix ice bag count + default ice check-in to 1 bag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The form sends payment_ice as a descriptive label, e.g. "One Ice ticket good for one bag per day (3 total bags)". The old parser pulled the first number ("3") and treated it as 3 tickets, then multiplied by 3 bags/ticket → 9 bags for one ice ticket (18 for two). New iceBagsFromPayment reads the "(N total bags)" the label states directly, with worded-count and numeric dollar/count fallbacks for forward compatibility. 1 ice → 3 bags, 2 → 6. 5 new tests. Scanner: Ice mode now defaults the check-in count to 1 (a bag at a time) instead of all remaining bags; staff can bump it up. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/index.tsx | 5 +++-- backend/src/fluentforms.ts | 28 ++++++++++++++++++++++++++++ backend/src/routes/webhook.ts | 13 +++++++------ backend/src/test/fluentforms.test.ts | 27 ++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/app/index.tsx b/app/app/index.tsx index d3211da..b7a0b2e 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -109,8 +109,9 @@ export default function ScannerScreen() { feedbackSuccess(); const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining; setTicket(res.ticket); - // Ice: default to grabbing all remaining bags at once. Tickets: default 1. - setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining)); + // Default to 1 (people usually grab ice a bag at a time); staff can bump + // the count up. Clamp to what's left so a 0-remaining ticket stays at 0. + setCount(Math.min(1, remaining)); setPhase("confirm"); } catch (e: any) { if (e?.name === "AuthError") return router.replace("/login"); diff --git a/backend/src/fluentforms.ts b/backend/src/fluentforms.ts index de628a7..60f9e4c 100644 --- a/backend/src/fluentforms.ts +++ b/backend/src/fluentforms.ts @@ -56,6 +56,34 @@ export function addressLine(v: any): string | undefined { return undefined; } +const NUMBER_WORDS: Record = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 }; + +/** + * Total bags of ice from the `payment_ice` field. The form sends a descriptive + * option label, e.g. "One Ice ticket good for one bag per day (3 total bags)", + * so the reliable signal is the "(N total bags)" the label states. Falls back to + * a worded ticket count ("Two Ice tickets" → 2 × bagsPerTicket), then to a + * numeric dollar-total/ticket-count for forward compatibility. + */ +export function iceBagsFromPayment( + value: unknown, + opts: { bagsPerTicket: number; ticketPrice: number }, +): number { + const { bagsPerTicket, ticketPrice } = opts; + const s = typeof value === "string" ? value : ""; + // Preferred: the label states the total bags directly. + const bagsMatch = s.match(/(\d+)\s*total\s*bags/i); + if (bagsMatch) return Math.max(0, parseInt(bagsMatch[1], 10)); + // Worded ticket count: "One Ice ticket", "Two Ice tickets". + const wordMatch = s.match(/\b(one|two|three|four|five|six)\b\s+ice/i); + if (wordMatch) return NUMBER_WORDS[wordMatch[1].toLowerCase()] * bagsPerTicket; + // Numeric fallback: a dollar total (>= price) → tickets; else a small count. + const n = qty(value); + if (n <= 0) return 0; + const tickets = n >= ticketPrice ? Math.round(n / ticketPrice) : Math.round(n); + return Math.max(0, tickets) * bagsPerTicket; +} + /** Donor status from the hidden lookup fields + the "are you a donor?" radio. */ export function readDonor(body: Record): { isDonor: boolean; donorTier: string } { const donorTier = String(body.donor_tier ?? "").trim(); diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 7f5595d..250c14f 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -3,7 +3,7 @@ import type { FastifyInstance } from "fastify"; import { toBool } from "../fields.js"; import { createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.js"; -import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js"; +import { safeEqual, nameGroup, qty, selected, addressLine, iceBagsFromPayment } from "../fluentforms.js"; // Regular adult attendee name groups (Adult Ticket #1–#10), in order. const REGULAR_NAME_BASES = [ @@ -73,11 +73,12 @@ export async function webhookRoutes(app: FastifyInstance): Promise { const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor); const rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_donor); const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_donor); - // Ice: payment_ice is either a ticket count (1-4) or a dollar total - // ($20-$80). One ice ticket = ICE_BAGS_PER_TICKET bags. - const iceRaw = qty(body.payment_ice); - const iceTickets = iceRaw >= app.ctx.config.ICE_TICKET_PRICE ? Math.round(iceRaw / app.ctx.config.ICE_TICKET_PRICE) : Math.round(iceRaw); - const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; + // Ice: payment_ice is a descriptive option label whose "(N total bags)" + // states the bags. One ice ticket = ICE_BAGS_PER_TICKET bags. + const iceBags = iceBagsFromPayment(body.payment_ice, { + bagsPerTicket: app.ctx.config.ICE_BAGS_PER_TICKET, + ticketPrice: app.ctx.config.ICE_TICKET_PRICE, + }); const iceAccess = iceBags > 0 || selected(body.input_radio_7); // Tickets are optional: a customer can buy ice/UTV/parking with no admission diff --git a/backend/src/test/fluentforms.test.ts b/backend/src/test/fluentforms.test.ts index 083f3b4..9023e24 100644 --- a/backend/src/test/fluentforms.test.ts +++ b/backend/src/test/fluentforms.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; -import { nameGroup, qty, selected, addressLine, readDonor } from "../fluentforms.js"; +import { nameGroup, qty, selected, addressLine, readDonor, iceBagsFromPayment } from "../fluentforms.js"; + +const ICE = { bagsPerTicket: 3, ticketPrice: 20 }; // Food vendors are the only vendor tickets; two pass-holder name slots. const FOOD_SLOTS = ["names", "names_1"]; @@ -44,6 +46,29 @@ describe("food vendor pass counting", () => { }); }); +describe("iceBagsFromPayment", () => { + it("reads '(N total bags)' from the real form label", () => { + expect(iceBagsFromPayment("One Ice ticket good for one bag per day (3 total bags)", ICE)).toBe(3); + expect(iceBagsFromPayment("Two Ice tickets good for one bag per day (6 total bags)", ICE)).toBe(6); + }); + it("falls back to a worded ice-ticket count", () => { + expect(iceBagsFromPayment("Two Ice tickets", ICE)).toBe(6); // 2 × 3 + expect(iceBagsFromPayment("Four Ice tickets", ICE)).toBe(12); + }); + it("falls back to a dollar total at the ticket price", () => { + expect(iceBagsFromPayment("$40.00", ICE)).toBe(6); // 2 tickets × 3 + expect(iceBagsFromPayment(20, ICE)).toBe(3); // 1 ticket × 3 + }); + it("treats a small plain count as ticket count", () => { + expect(iceBagsFromPayment(2, ICE)).toBe(6); // 2 tickets × 3 + }); + it("is 0 for blank / no ice", () => { + expect(iceBagsFromPayment("", ICE)).toBe(0); + expect(iceBagsFromPayment(undefined, ICE)).toBe(0); + expect(iceBagsFromPayment(0, ICE)).toBe(0); + }); +}); + describe("readDonor", () => { it("treats donor_tier=member as a donor", () => { expect(readDonor({ donor_tier: "member" })).toEqual({ isDonor: true, donorTier: "member" });