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:
parent
49e45ff94c
commit
049f433930
4 changed files with 64 additions and 9 deletions
|
|
@ -56,6 +56,34 @@ export function addressLine(v: any): string | 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. */
|
||||
export function readDonor(body: Record<string, any>): { isDonor: boolean; donorTier: string } {
|
||||
const donorTier = String(body.donor_tier ?? "").trim();
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue