Fix ice bag count + default ice check-in to 1 bag

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) <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-20 07:40:05 +00:00
parent 49e45ff94c
commit 049f433930
4 changed files with 64 additions and 9 deletions

View file

@ -109,8 +109,9 @@ export default function ScannerScreen() {
feedbackSuccess(); feedbackSuccess();
const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining; const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining;
setTicket(res.ticket); setTicket(res.ticket);
// Ice: default to grabbing all remaining bags at once. Tickets: default 1. // Default to 1 (people usually grab ice a bag at a time); staff can bump
setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining)); // the count up. Clamp to what's left so a 0-remaining ticket stays at 0.
setCount(Math.min(1, remaining));
setPhase("confirm"); setPhase("confirm");
} catch (e: any) { } catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login"); if (e?.name === "AuthError") return router.replace("/login");

View file

@ -56,6 +56,34 @@ export function addressLine(v: any): string | undefined {
return undefined; return undefined;
} }
const NUMBER_WORDS: Record<string, number> = { 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. */ /** Donor status from the hidden lookup fields + the "are you a donor?" radio. */
export function readDonor(body: Record<string, any>): { isDonor: boolean; donorTier: string } { export function readDonor(body: Record<string, any>): { isDonor: boolean; donorTier: string } {
const donorTier = String(body.donor_tier ?? "").trim(); const donorTier = String(body.donor_tier ?? "").trim();

View file

@ -3,7 +3,7 @@ import type { FastifyInstance } from "fastify";
import { toBool } from "../fields.js"; import { toBool } from "../fields.js";
import { createTicket } from "../ticketService.js"; 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, iceBagsFromPayment } from "../fluentforms.js";
// Regular adult attendee name groups (Adult Ticket #1#10), in order. // Regular adult attendee name groups (Adult Ticket #1#10), in order.
const REGULAR_NAME_BASES = [ const REGULAR_NAME_BASES = [
@ -73,11 +73,12 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor); 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 rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_donor);
const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_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 // Ice: payment_ice is a descriptive option label whose "(N total bags)"
// ($20-$80). One ice ticket = ICE_BAGS_PER_TICKET bags. // states the bags. One ice ticket = ICE_BAGS_PER_TICKET bags.
const iceRaw = qty(body.payment_ice); const iceBags = iceBagsFromPayment(body.payment_ice, {
const iceTickets = iceRaw >= app.ctx.config.ICE_TICKET_PRICE ? Math.round(iceRaw / app.ctx.config.ICE_TICKET_PRICE) : Math.round(iceRaw); bagsPerTicket: app.ctx.config.ICE_BAGS_PER_TICKET,
const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; ticketPrice: app.ctx.config.ICE_TICKET_PRICE,
});
const iceAccess = iceBags > 0 || selected(body.input_radio_7); const iceAccess = iceBags > 0 || selected(body.input_radio_7);
// Tickets are optional: a customer can buy ice/UTV/parking with no admission // Tickets are optional: a customer can buy ice/UTV/parking with no admission

View file

@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest"; 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. // Food vendors are the only vendor tickets; two pass-holder name slots.
const FOOD_SLOTS = ["names", "names_1"]; 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", () => { describe("readDonor", () => {
it("treats donor_tier=member as a donor", () => { it("treats donor_tier=member as a donor", () => {
expect(readDonor({ donor_tier: "member" })).toEqual({ isDonor: true, donorTier: "member" }); expect(readDonor({ donor_tier: "member" })).toEqual({ isDonor: true, donorTier: "member" });