Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions scripts/mutation/equivalent-mutants.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ src/ui/client/admin/order-gallery.ts:105:34 ?? → || # states is a record ob
# `x ?? F` vs `x || F` differ only when x is falsy-but-not-null; these are all
# cases where x can't be such a value, or F equals the only one it can be.
src/shared/checkout-pricing.ts:324:41 ?? → || # intent.modifiers: an array is always truthy
src/shared/booking-lines.ts:71:42 ?? → || # packageGroupId is absent or a positive database group id; an explicit 0 also has the same 0 fallback
src/features/api/payment-processing/create.ts:60:36 ?? → || # lineGroupId returns undefined or a positive package group id; an explicit 0 would also keep the same 0 fallback
src/features/api/payment-processing/create.ts:193:28 ?? → || # listingAnswerIds is an object when present, and objects are always truthy
src/features/api/payment-processing/create.ts:196:67 ?? → || # a listing's text refs are an array when present, and arrays are always truthy
src/features/api/payment-processing/create.ts:199:46 ?? → || # a grouped answer set is an object when present, and objects are always truthy
src/features/api/payment-processing/create.ts:203:35 ?? → || # existing text answer ids are an array when present, and arrays are always truthy
src/shared/logistics-filter.ts:22:35 ?? → || # raw: string|null, only falsy string "" === fallback ""
src/shared/config.ts:170:39 ?? → || # getEnv(): string|undefined, only falsy string "" === fallback ""
src/shared/config.ts:36:5 ?? → || # providerValue returns boolean or null, and false stays false with either fallback operator
Expand Down Expand Up @@ -123,15 +129,19 @@ src/shared/db/query-log.ts:292:22 ?? → || # store is a truthy QueryLogState
src/shared/update.ts:153:45 ?? → || # the stored commit is a string or undefined and the fallback is empty, so both operators return the same string
src/shared/update.ts:186:11 ?? → || # the primary row value is a string or absent and the fallback is empty, so both operators return the same string

# Parent/child fold moved to fold-tree.ts in Phase 2a (see the fold-tree.ts
# entries below); the ticket-payment.ts fold-internal equivalents went with it.
# What stays here are `?? F` vs `|| F` sites whose left operand can never be a
# falsy-but-non-null value (a Map/object/array is always truthy, and the only
# falsy number is 0 with 0??0===0||0), so the two operators always agree.
src/features/public/ticket-payment.ts:341:43 ?? → || # base.quantities.get(): number|undefined; the only falsy number is 0, 0??0===0||0
src/features/public/ticket-payment.ts:414:42 ?? → || # packageGroupId: number|undefined; the only falsy number is 0, 0??0===0||0, and undefined→0 either way
src/features/public/ticket-payment.ts:442:37 ?? → || # ledgerOrder: PricedOrder|null — an object is always truthy, null→fallback either way
src/features/public/ticket-payment.ts:596:61 ?? → || # childrenByParentId.get(): TicketListing[]|undefined — an array is always truthy, undefined→null either way
# ticket-payment values whose present form is always truthy, whose falsy value
# equals the fallback, or whose mutated constant is normalized before use.
src/features/public/ticket-payment.ts:193:11 ?? → || # date is nullish or a non-empty ISO date; only the nullish values use the null fallback
src/features/public/ticket-payment.ts:221:42 ?? → || # childrenByParentId.get(): TicketListing[]|undefined, and every present array is truthy
src/features/public/ticket-payment.ts:188:14 1 → 0 # bookingDateFields clamps either default day count to one, so the produced availability rows are identical
Comment thread
stefan-burke marked this conversation as resolved.
src/features/public/ticket-payment.ts:265:30 ?? → || # a package stand-in is a non-empty group name or undefined, so only undefined reaches the listing-name fallback
src/features/public/ticket-payment.ts:337:28 ?? → || # prebuiltTree is a BookingTree object when present, and objects are always truthy
src/features/public/ticket-payment.ts:346:43 ?? → || # base.quantities.get(): number|undefined; the only falsy number is 0, 0??0===0||0
src/features/public/ticket-payment.ts:391:17 0 → 1 # EMPTY_PRICED_ORDER.fullSubtotal is never read: bookingFactsFromOrder derives its facts from total, extras, lines, and modifierApplications
src/features/public/ticket-payment.ts:402:14 1 → 0 # bookingsForOrder normalizes either default day count to one, so the stored booking duration is identical
src/features/public/ticket-payment.ts:439:37 ?? → || # ledgerOrder: PricedOrder|null — an object is always truthy, null→fallback either way
src/features/public/ticket-payment.ts:593:61 ?? → || # childrenByParentId.get(): TicketListing[]|undefined — an array is always truthy, undefined→null either way
src/features/public/ticket-payment.ts:791:17 ?? → || # pagePackages is an array when present, and arrays are always truthy

# Parent/child curried atoms (shared.tsx).
src/ui/templates/layout.tsx:48:30 ?? → || # Theme is "light"|"dark" or undefined, so every present value is truthy
Expand Down
72 changes: 40 additions & 32 deletions src/features/api/payment-processing/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* quantity-0 placeholder instead of dropping a paid customer.
*/

import { requiredMapValue } from "#fp";
import { businessTime } from "#routes/api/payment-processing/metadata.ts";
import type { ValidatedItem } from "#routes/api/payment-processing/package-pricing.ts";
import {
Expand All @@ -16,14 +17,18 @@ import type {
BookingIntent,
PaymentResult,
} from "#routes/api/webhook-types.ts";
import { attendeeFailureFormatter } from "#shared/attendee-failures.ts";
/* jscpd:ignore-start */
import { lineGroupId } from "#shared/booking/signed-metadata.ts";
import { orderBookings } from "#shared/booking-lines.ts";
import {
bookingsForOrder,
checkoutBookingLines,
} from "#shared/booking-lines.ts";
import { bookingBatchPlan } from "#shared/checkout-complete.ts";
import type {
ModifierApplication,
PricedOrder,
} from "#shared/checkout-pricing.ts";
/* jscpd:ignore-end */
import { formatCurrency } from "#shared/currency.ts";
import { logActivity } from "#shared/db/activityLog.ts";
import { requirePublicStatusId } from "#shared/db/attendee-statuses.ts";
Expand Down Expand Up @@ -108,17 +113,19 @@ export const pairEntriesByListing = <A extends { listing_id: number }>(
);
return attendees.map((attendee) => ({
attendee,
listing: listingByItemId.get(attendee.listing_id)!,
listing: requiredMapValue(
listingByItemId,
attendee.listing_id,
`Listing ${attendee.listing_id} was not loaded for a created booking`,
),
}));
};

/** Format error for post-payment attendee creation failure */
const formatPostPaymentError = attendeeFailureFormatter({
fallback: "Registration failed.",
generic: "Sorry, this listing sold out while you were completing payment.",
withName: (name) =>
`Sorry, ${name} sold out while you were completing payment.`,
});
/** Format a capacity failure without assuming the listing has a display name. */
const formatPostPaymentError = (name: string): string =>
name
? `Sorry, ${name} sold out while you were completing payment.`
: "Sorry, this listing sold out while you were completing payment.";

type CreatedAttendee = Extract<
Awaited<ReturnType<typeof attendeesApi.createAttendeeAtomic>>,
Expand Down Expand Up @@ -255,25 +262,20 @@ export const createAttendeeForSession = async (
plan: Parameters<typeof attendeesApi.createBookingAtomic>[1];
};
try {
// Per-LINE paid amounts: a listing booked through two paths is two lines
// with their own prices, and each becomes its own booking row. The priced
// order's lines reference the pricing intent's item objects, which pair
// 1:1 by index with validatedItems.
// Per-line paid amounts are keyed by checkout item object, so one listing
// booked through two paths keeps each path's own price and package.
const paidByIntentItem = paidByItem(pricedOrder);
const bookings = orderBookings({
allocations: intent.allocations,
date: intent.date,
dayCount: intent.dayCount,
lines: validatedItems.map(({ item, listing }, index) => {
const pricePaid = paidByIntentItem.get(pricingIntent.items[index]!);
return {
...bookingSlot(item),
listing,
...(pricePaid !== undefined ? { pricePaid } : {}),
quantity: item.q,
};
}),
});
const listingById = new Map(
validatedItems.map(({ listing }) => [listing.id, listing]),
);
const bookings = bookingsForOrder(
{
allocations: intent.allocations,
date: intent.date,
dayCount: intent.dayCount,
},
checkoutBookingLines(pricingIntent.items, listingById, paidByIntentItem),
);
const remainingBalance =
intent.reservationAmount === undefined
? 0
Expand Down Expand Up @@ -326,11 +328,17 @@ export const createAttendeeForSession = async (

// All-or-nothing: a capacity failure rolled the transaction back (no legs).
if (!result.success) {
// A package order must never name a member in the capacity error — a hidden
// package would leak the listing it conceals. Same guard as the free path.
// The named arm needs one listing: a paid checkout always has at least one
// validated item, so the first is guaranteed to exist.
const errorName = pricingIntent.items.some(
(item) => item.packageGroupId !== undefined,
)
? ""
: validatedItems[0]!.listing.name;
Comment thread
stefan-burke marked this conversation as resolved.
return {
detail: formatPostPaymentError(
result.reason,
validatedItems[0]!.listing.name,
),
detail: formatPostPaymentError(errorName),
ok: false,
reason: result.reason,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
25 changes: 11 additions & 14 deletions src/features/public/ticket-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,15 @@ import {
type PagePackage,
} from "#shared/booking/page-packages.ts";
import type { BookingTree } from "#shared/booking/tree.ts";
/* jscpd:ignore-start */
import { bookingDateFields } from "#shared/booking-date-fields.ts";
import { orderBookings } from "#shared/booking-lines.ts";
import {
bookingsForOrder,
checkoutBookingLines,
} from "#shared/booking-lines.ts";
import { bookingBatchPlan } from "#shared/checkout-complete.ts";
import type { PricedOrder } from "#shared/checkout-pricing.ts";
/* jscpd:ignore-end */
import { getBookableStartDates, isBookingRangeValid } from "#shared/dates.ts";
import { requirePublicStatusId } from "#shared/db/attendee-statuses.ts";
import type {
Expand Down Expand Up @@ -233,7 +238,7 @@ export const handlePaymentFlow = (
const buildBookings = (
selected: ListingQty[],
date: string | null,
dayCount = 1,
dayCount: number,
): LineBooking[] =>
selected.map(({ listing, qty }) => ({
listingId: listing.id,
Expand Down Expand Up @@ -404,18 +409,10 @@ export const createFreeReservation = async ({
const listingById = new Map(
listings.map((info) => [info.listing.id, info.listing]),
);
const finalBookings = orderBookings({
allocations,
date,
dayCount,
lines: items.map((item) => ({
listing: listingById.get(item.listingId)!,
listingId: item.listingId,
packageGroupId: item.packageGroupId ?? 0,
...(paidByItem ? { pricePaid: paidByItem.get(item)! } : {}),
quantity: item.quantity,
})),
});
const finalBookings = bookingsForOrder(
{ allocations, date, dayCount },
checkoutBookingLines(items, listingById, paidByItem),
);
// When there are legs to post or stock to consume, commit the booking, its
// modifier stock, and its sale legs as ONE batch (exactly as the paid webhook
// does) — never an interactive transaction held open across a read-per-leg. The
Expand Down
126 changes: 57 additions & 69 deletions src/shared/booking-lines.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,10 @@
/**
* The canonical signed paid booking rows — one representation of what a
* completed, correctly-priced order writes to `listing_attendees`, shared by
* ordinary payment completion. The later staged-checkout runtime will reach
* the same rows through the same builder, which is why the builder is pure
* over its inputs (the only impurity is the single order-token UUID the
* expansion mints, mirroring the in-memory token main's rows already share).
*
* Behaviour kept identical to main's paid webhook path:
* - one row per signed line, carrying listing, quantity, date, duration,
* package path, and paid price, in input order;
* - child allocations expand into one row per (child, parent), preserving
* total quantity and exact total paid price (the last split row absorbs
* the rounding residue) and sharing one order token;
* - a folded child is stamped with its parent's package ONLY when that parent
* books through exactly one path — mixed and standalone parent paths do
* not falsely stamp children;
* - a genuine `pricePaid: 0` (a free line that was still signed) is kept
* distinct from an omitted price, so the ledger never confuses "free"
* with "unpriced".
* Build the booking rows shared by paid and free checkout completion.
* Checkout items keep each listing path separate, while the order details add
* dates, parent allocations, and inherited package paths.
*/

import { requiredMapValue } from "#fp";
import {
soleParentPackageIds,
stampChildRowPackages,
Expand All @@ -33,63 +18,70 @@ import type {
ListingBooking,
} from "#shared/db/attendee-types.ts";
import { expandChildAllocations } from "#shared/db/attendees/order-parents.ts";
import type { CheckoutItem } from "#shared/payments.ts";

/** One signed paid line: the booking slot (listing id + package path), the
* quantity, the listing's date/duration facts, and the line's charged
* amount — everything {@link orderBookings} needs to build one canonical
* {@link ListingBooking} row before child-allocation expansion.
*
* The slot's `packageGroupId` is `0` for a standalone line and the package's
* group id for a line booked through a package — the same `packageGroupId 0`
* contract the existing writers expect. `pricePaid` carries the distinction
* the ledger depends on: a genuine `0` stays `0`; an `undefined` line
* carries no price and is written to no row's `pricePaid`. */
export type SignedPaidLine = {
type BookingLine = {
listingId: number;
packageGroupId: number;
quantity: number;
/** The listing facts that derive the row's `date`/`durationDays`. */
listing: BookingDateSource;
/** The line's charged amount in minor units, or `undefined` when the line
* carries no price. */
pricePaid?: number;
};

/** Input to {@link orderBookings}: the signed paid lines plus the order's
* shared date, day count, and per-(child, parent) allocations. */
export type OrderBookingsInput = {
lines: SignedPaidLine[];
type BookingOrder = {
date: string | null;
/** Visitor-chosen day count for "customisable days" listings. Absent or 1
* when no selected listing is customisable — modelled as optional to
* mirror the genuinely optional `BookingIntent.dayCount` (a legacy session
* without `day_count` still means one day via {@link bookingDateFields}'s
* default). Typed `number | undefined` (not bare `?: number`) so an
* intent's `dayCount` can be passed through directly under
* `exactOptionalPropertyTypes`. */
dayCount?: number | undefined;
/** Per-(child, parent) allocations from the fold, carried through the signed
* metadata. Absent or empty for legacy/no-parent orders. Typed
* `ChildAllocation[] | undefined` so an intent's `allocations` can be
* passed through directly under `exactOptionalPropertyTypes`. */
allocations?: ChildAllocation[] | undefined;
};

/**
* Build the canonical signed paid booking rows from a validated, priced order.
*
* Each line becomes one row carrying its listing, quantity, date, duration,
* package path, and paid price; child lines expand into per-parent rows when
* allocations are present; each folded child is stamped with its parent's
* package only when that parent books through exactly one path (see
* {@link soleParentPackageIds}). Preserves input order, exact total paid price
* (the last split row absorbs the rounding residue), the `pricePaid: 0`
* versus omitted distinction, and one shared order token across every
* expanded row.
*/
export const orderBookings = (input: OrderBookingsInput): ListingBooking[] => {
const { lines, date, dayCount, allocations } = input;
const rawBookings: ListingBooking[] = lines.map((line) => ({
/** Get the item's paid amount when this checkout carries paid amounts. Pricing
* has no line for an existing zero-unit signed item, so only that amount may be
* absent. */
const paidAmountForOrNull = <T extends CheckoutItem>(
item: T,
paidByItem: ReadonlyMap<T, number> | undefined,
): number | undefined => {
if (paidByItem === undefined) return;
const amount = paidByItem.get(item);
if (amount === undefined && item.quantity !== 0) {
throw new Error(
`Paid amount for listing ${item.listingId} was not loaded for checkout`,
);
}
return amount;
};

/** Turn checkout items into booking lines using their loaded listings and paid
* amounts. A missing lookup means the checkout data is incomplete, so it fails
* before any booking write starts. */
export const checkoutBookingLines = <T extends CheckoutItem>(
items: readonly T[],
listingById: ReadonlyMap<number, BookingDateSource>,
paidByItem?: ReadonlyMap<T, number>,
): BookingLine[] =>
items.map((item) => {
const pricePaid = paidAmountForOrNull(item, paidByItem);
return {
listing: requiredMapValue(
listingById,
item.listingId,
`Listing ${item.listingId} was not loaded for checkout`,
),
listingId: item.listingId,
packageGroupId: item.packageGroupId ?? 0,
...(pricePaid !== undefined ? { pricePaid } : {}),
quantity: item.quantity,
};
});

/** Apply an order's date and parent allocations to its booking lines. Input
* order is kept. Allocated children expand in allocation order, with any
* standalone remainder last, and split prices keep their exact total. */
export const bookingsForOrder = (
{ allocations, date, dayCount }: BookingOrder,
lines: readonly BookingLine[],
): ListingBooking[] => {
const bookings: ListingBooking[] = lines.map((line) => ({
listingId: line.listingId,
packageGroupId: line.packageGroupId,
quantity: line.quantity,
Expand All @@ -98,12 +90,8 @@ export const orderBookings = (input: OrderBookingsInput): ListingBooking[] => {
}));
return stampChildRowPackages(
allocations && allocations.length > 0
? expandChildAllocations(rawBookings, allocations)
: rawBookings,
// soleParentPackageIds reads each line's package path; a SignedPaidLine
// already carries packageGroupId (0 = standalone, dropped; N = a sole
// package, kept), so the lines can be passed directly — matching main's
// per-line package-group derivation.
? expandChildAllocations(bookings, allocations)
: bookings,
soleParentPackageIds(lines),
);
};
Loading