CampgroundTickets/backend/src/fluentforms.ts
Hank 049f433930 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>
2026-07-20 07:40:05 +00:00

96 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { timingSafeEqual } from "node:crypto";
import { toBool, toNumber } from "./fields.js";
/** Constant-time string compare for shared webhook secrets. */
export function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a || "");
const bb = Buffer.from(b || "");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/** Read a FluentForms compound name field, given as a nested object
* (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */
export function nameGroup(body: Record<string, any>, base: string): string {
const obj = body[base];
let first: any, middle: any, last: any;
if (obj && typeof obj === "object") {
({ first_name: first, middle_name: middle, last_name: last } = obj);
} else {
first = body[`${base}[first_name]`];
middle = body[`${base}[middle_name]`];
last = body[`${base}[last_name]`];
}
return [first, middle, last]
.map((x) => (x == null ? "" : String(x).trim()))
.filter(Boolean)
.join(" ");
}
/** Read an item_quantity / payment field's numeric value (handles nested
* objects like {quantity} / {value} and money strings like "$40.00"). */
export function qty(v: any): number {
if (v == null || v === "") return 0;
if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0);
if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, ""));
return toNumber(v);
}
/** A payment/extra field counts as "selected" if it has a meaningful value.
* Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */
export function selected(v: any): boolean {
if (v == null || v === "") return false;
if (typeof v === "object") {
if ("selected" in v) return toBool((v as any).selected);
return qty(v) > 0 || Object.keys(v).length > 0;
}
const s = String(v).trim().toLowerCase();
if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false;
return true;
}
/** Flatten a FluentForms compound address (`address_1`) to a single line. */
export function addressLine(v: any): string | undefined {
if (v && typeof v === "object") return Object.values(v).filter(Boolean).join(", ");
if (v !== undefined) return String(v);
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();
const isDonor =
donorTier === "member" ||
donorTier === "donor" ||
toBool(body.donor_eligible) ||
selected(body.input_radio); // "Are you a campground donor?"
return { isDonor, donorTier };
}