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>
96 lines
4.1 KiB
TypeScript
96 lines
4.1 KiB
TypeScript
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 };
|
||
}
|