Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
80 changes: 80 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,86 @@ everything still outstanding is captured below.

---

## Marketing screenshot visual cleanup

*Origin: visual audit of the mobile Retina screenshots generated from
`../tickets-site/scripts/screenshots/` on 2026-07-18.*

The screenshots use real application pages with scenario-specific custom CSS.
Keep fixes in those scenarios unless the same problem also appears in the normal
application UI. Regenerate each affected PNG and inspect the final image at its
actual size before marking an item complete.

**High priority:**

- **Fix the logistics card overflow and buttons.** In
`scripts/screenshots/logistics.js`, the white booking card extends past the
right edge of its blue day panel in `logistics-deliveries.png`. Both orange
“Mark done” buttons are too narrow: the text touches the pill edges and looks
vertically cramped. Keep the card inside its parent with border-box sizing,
then give the buttons enough inline and block padding for the full label.
- **Give the daily-calendar states distinct, readable colours.** In
`scripts/screenshots/daily-events.js`, the selected 3 August date uses white
text on pale yellow in `daily-events-calendar.png`, which has poor contrast.
The selected date and the other highlighted date also look almost identical.
Use dark text and visibly different selected/today treatments while keeping
both states clear without relying on colour alone.
- **Remove accidental focus outlines from checkout captures.** Several images
end with whichever control was filled last still focused, producing an
unrelated black or white outline: `charity-family-fun-day-checkout.png`,
`promo-codes-and-add-ons-checkout.png`, `equipment-hire-booking.png`,
`the-tempest-group-checkout.png`, and
`garden-party-package-checkout.png`. Add one shared scenario helper that blurs
the active control before capture, then use it for all filled checkout
scenarios. Keep deliberate focus only in a screenshot that is specifically
demonstrating keyboard focus.
- **Stack the Garden Party email field on mobile.** In
`scripts/screenshots/packages.js`, “Your Email” and its input are squeezed
onto one row in `garden-party-package-checkout.png`, unlike the name field
above it. Make contact-field labels and controls consistently full-width so
the input does not crowd the label.

**Polish:**

- **Shorten the bulk-email preview.** In `scripts/screenshots/bulk-email.js`,
`bulk-email-preview.png` is about twice as tall as it needs to be because the
warning copy, line height, and section gaps are oversized. Reduce the
scenario font size/line height and vertical spacing without hiding or
rewriting the real warning. Keep the recipients, subject, warning, and full
message preview visible.
- **Tighten the balance summary.** In
`scripts/screenshots/deposits-and-balance-payments.js`, the three totals in
`deposits-and-balance-payments.png` have large vertical gaps and the payment
action sits in an oversized empty panel. Reduce those gaps and panel padding
while preserving a clear order: full price, already paid, balance due, then
payment action.
- **Tighten oversized checkout headings.** The headings in
`charity-family-fun-day-checkout.png` and `equipment-hire-booking.png` wrap
with more line spacing than the forms use. Adjust only the scenario heading
line-height and bottom margin so each title remains prominent but does not
dominate the image.
- **Use the theme colour for custom-question controls.** In
`scripts/screenshots/custom-questions.js`, the selected radio in
`custom-questions-checkout.png` uses the browser’s bright blue default, which
clashes with the brown bakery theme. Set `accent-color` to the scenario’s
accessible brown accent and confirm the selected state remains obvious.
- **Reduce the listing-form crop height if it stays readable.** In
`scripts/screenshots/listing-management.js`,
`summer-sessions-listing-form.png` is nearly 2,000 pixels tall despite
already being limited to the Basics fieldset. Tighten field hints, editor
height, and section spacing rather than removing the date or venue. Keep all
text comfortably readable at the rendered `split-image` size.

**Final visual check:**

- Regenerate every scenario-owned screenshot after the fixes and inspect for
clipped text, overflowing boxes, accidental focus rings, low contrast,
overlapping labels, inconsistent padding, and empty space at all four crop
edges. Also render the affected `split-image` pages at desktop and mobile
widths so a good source PNG is not undermined by its website placement.

---

## Split the database migration runtime

*Origin: CodeRabbit review on PR #1845 (`src/shared/db/migrations.ts`).*
Expand Down
2 changes: 2 additions & 0 deletions scripts/mutation/equivalent-mutants.txt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ src/shared/accounting/rows.ts:133:72 ?? → || # renderInsert guard?.args: In
src/shared/accounting/manual-entries.ts:209:29 ?? → || # guard error message `transfer.kind ?? ""`: string|undefined, "" ?? "" === "" || ""
src/shared/checkout-ledger.ts:35:72 ?? → || # find(...)?.amount: number|undefined; the only falsy-non-null amount is 0 and 0 ?? 0 === 0 || 0
src/features/settings-bundles.ts:264:40 ?? → || # bundle is a readonly string[] or undefined; every present array, including [], is truthy
src/shared/db/attendees/balance.ts:168:54 ?? → || # remainingBalance is a number when present; its only falsy value is 0, and both operators return the same 0 fallback
src/shared/db/attendees/balance.ts:231:22 ?? → || # attendee status ids are positive integers when present, so the value is always truthy or nullish
src/shared/accounting/queries.ts:162:26 || → && # transferActivityBounds: MIN(occurred_at) and MAX(occurred_at) over one table are NULL together (both iff the table is empty), so either-null and both-null coincide
src/shared/admin-features.ts:95:58 ?? → || # find(): AdminFeatureDefinition|undefined; a feature object is always truthy
src/shared/db/admin-features.ts:50:10 __required_setting_condition__ → "" # the placeholder key is never stored: a failed condition always aborts on its NULL value
Expand Down
17 changes: 13 additions & 4 deletions scripts/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,16 +164,20 @@ const submit = async (page: Page, formSelector: string): Promise<void> => {
]);
};

const setupAdmin = async (page: Page, baseUrl: string): Promise<void> => {
const setupAdmin = async (
page: Page,
baseUrl: string,
username = USERNAME,
): Promise<void> => {
await page.goto(`${baseUrl}/setup/`);
await page.locator('[name="admin_username"]').fill(USERNAME);
await page.locator('[name="admin_username"]').fill(username);
await page.locator('[name="admin_password"]').fill(PASSWORD);
await page.locator('[name="admin_password_confirm"]').fill(PASSWORD);
await page.locator('[name="accept_agreement"]').check();
await submit(page, 'form[action="/setup/"]');

await page.goto(`${baseUrl}/`);
await page.locator('[name="username"]').fill(USERNAME);
await page.locator('[name="username"]').fill(username);
await page.locator('[name="password"]').fill(PASSWORD);
await submit(page, 'form[action="/admin/login"]');

Expand Down Expand Up @@ -345,7 +349,7 @@ const captureScenario = async (
baseUrl: string,
outputDir: string,
): Promise<void> => {
await setupAdmin(page, baseUrl);
await setupAdmin(page, baseUrl, scenario.setupUsername);
await applyTheme(
page,
baseUrl,
Expand All @@ -357,6 +361,11 @@ const captureScenario = async (
}`,
);
await scenario.run({
balancePathFor: async (attendeeId) => {
Deno.env.set("DB_ENCRYPTION_KEY", DB_KEY);
const { signBalanceToken } = await import("#shared/balance-link.ts");
return `/pay/${await signBalanceToken(attendeeId)}`;
},
baseUrl,
page,
submit: (formSelector) => submit(page, formSelector),
Expand Down
4 changes: 4 additions & 0 deletions scripts/screenshots/scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { toFileUrl } from "@std/path";
import type { Page } from "playwright";

export interface ScreenshotScenarioContext {
balancePathFor: (attendeeId: number) => Promise<string>;
baseUrl: string;
page: Page;
submit: (formSelector: string) => Promise<void>;
Expand All @@ -13,6 +14,7 @@ export interface ScreenshotScenario {
fullPage?: boolean;
name: string;
run: (context: ScreenshotScenarioContext) => Promise<void>;
setupUsername?: string;
}

const isScenario = (value: unknown): value is ScreenshotScenario => {
Expand All @@ -23,6 +25,8 @@ const isScenario = (value: unknown): value is ScreenshotScenario => {
typeof candidate.name === "string" &&
/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(candidate.name) &&
typeof candidate.run === "function" &&
(candidate.setupUsername === undefined ||
typeof candidate.setupUsername === "string") &&
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
(candidate.elementSelector === undefined ||
typeof candidate.elementSelector === "string") &&
(candidate.fullPage === undefined ||
Expand Down
17 changes: 17 additions & 0 deletions src/shared/accounting/projection-sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import {
ATTENDEE,
REVENUE,
WORLD,
WRITEOFF_TYPE,
} from "#shared/accounting/accounts.ts";
import { KIND } from "#shared/accounting/kinds.ts";
Expand Down Expand Up @@ -114,6 +115,22 @@ export const accountBalanceSubquery = (
return `(SELECT ${signedSumCase(asDest, asSource)} FROM transfers WHERE ${asDest} OR ${asSource})`;
};

/** Net cash received from the outside world by one account. Payments into the
* account add; refunds and reversals back to the world subtract. Other ledger
* legs do not represent cash and are ignored. */
export const externalCashBalanceSubquery = (
type: string,
idExpr: string,
): string => {
const accountIn = accountPredicate("dest", type, idExpr);
const accountOut = accountPredicate("source", type, idExpr);
const worldOut = `source_type = '${WORLD.type}' AND source_id = '${WORLD.id}'`;
const worldIn = `dest_type = '${WORLD.type}' AND dest_id = '${WORLD.id}'`;
const received = `${accountIn} AND ${worldOut}`;
const returned = `${accountOut} AND ${worldIn}`;
return `(SELECT ${signedSumCase(received, returned)} FROM transfers WHERE ${received} OR ${returned})`;
};

/**
* The bare subquery for what an attendee still owes: the negation of their net
* account balance (outstanding = −balance). The single place the "owed equals
Expand Down
19 changes: 16 additions & 3 deletions src/shared/db/attendees/balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import type { InValue } from "@libsql/client";
import { compact, mapParallel, sumOf } from "#fp";
import { attendeeAccount, WORLD } from "#shared/accounting/accounts.ts";
import { KIND } from "#shared/accounting/kinds.ts";
import { attendeeOwedSubquery } from "#shared/accounting/projection-sql.ts";
import {
attendeeOwedSubquery,
externalCashBalanceSubquery,
} from "#shared/accounting/projection-sql.ts";
import { eventGroup, legReference } from "#shared/accounting/refs.ts";
import { guardedInsertStatement } from "#shared/accounting/rows.ts";
import { decrypt } from "#shared/crypto/encryption.ts";
Expand Down Expand Up @@ -123,6 +126,16 @@ const getAttendeeOrderRows = (attendeeId: number): Promise<OrderRow[]> =>
[attendeeId],
);

const getAttendeeAmountPaid = async (attendeeId: number): Promise<number> => {
const row = (await queryOne<{ amount_paid: number }>(
`SELECT ${externalCashBalanceSubquery(
"attendee",
String(attendeeId),
)} AS amount_paid`,
))!;
return Number(row.amount_paid);
};
Comment thread
stefan-burke marked this conversation as resolved.
Outdated

const orderLineFromRow = async (row: OrderRow): Promise<OrderLine | null> =>
row.listing_name === null || row.listing_unit_price === null
? null
Expand All @@ -137,16 +150,16 @@ const orderLineFromRow = async (row: OrderRow): Promise<OrderLine | null> =>
export const getAttendeeOrderSummary = async (
attendeeId: number,
): Promise<OrderSummary> => {
const [rows, state] = await Promise.all([
const [rows, state, depositPaid] = await Promise.all([
getAttendeeOrderRows(attendeeId),
getAttendeeBalanceState(attendeeId),
getAttendeeAmountPaid(attendeeId),
]);
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
Comment thread
stefan-burke marked this conversation as resolved.
Outdated

// The LEFT JOIN keeps dangling booking rows visible so we can preserve the
// previous behavior of dropping lines whose listing has since been deleted.
const lines = compact(await mapParallel(orderLineFromRow)(rows));

const depositPaid = sumOf((l: OrderLine) => l.pricePaid)(lines);
const listedFullPrice = sumOf((l: OrderLine) => l.unitPrice * l.quantity)(
lines,
);
Expand Down
17 changes: 6 additions & 11 deletions test/lib/server-reservation/edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describeWithEnv(
() => {
afterEach(() => resetStripeClient());

test("reservation balance page projects the gross sale (deposit accuracy deferred to concern 5)", async () => {
test("reservation balance page shows cash paid and the discounted full price", async () => {
const listing = await setupReservationListing({
bookingFee: "0",
reservationAmount: "10%",
Expand Down Expand Up @@ -63,22 +63,17 @@ describeWithEnv(
expect(await modifierUsageCount(promo.id)).toBe(1);
expect(await modifierUsageAmount(promo.id)).toBe(100);

// Concern 4 projects price_paid from the per-row ledger SALE leg, which
// is the gross list price (1000), not the 90 reservation deposit. So the
// order summary's "already paid" (depositPaid) and "full order price"
// overstate to the gross sale; only the balance due (remaining_balance,
// £8.10) stays accurate. No live site takes reservations, so this is
// accepted — concern 5 restores the deposit/owed model for the page.
const summary = await getAttendeeOrderSummary(attendee.id);
expect(summary.depositPaid).toBe(1000); // gross sale leg, not the 90 deposit
expect(summary.fullPrice).toBe(1810); // gross sale + remaining balance
expect(summary.depositPaid).toBe(90);
expect(summary.fullPrice).toBe(900);

const token = await signBalanceToken(attendee.id);
const html = await (
await handleRequest(mockRequest(`/pay/${token}`))
).text();
expect(html).toContain("Full order price");
expect(html).toContain("£8.10"); // balance due — still correct
expect(html).toContain("Full order price:</strong> £9");
expect(html).toContain("Already paid:</strong> £0.90");
expect(html).toContain("Balance due:</strong> £8.10");
} finally {
session.restore();
}
Expand Down
17 changes: 17 additions & 0 deletions test/shared/accounting/projection-sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it as test } from "@std/testing/bdd";
import {
ATTENDEE,
REVENUE,
WORLD,
WRITEOFF_TYPE,
} from "#shared/accounting/accounts.ts";
import { KIND } from "#shared/accounting/kinds.ts";
Expand All @@ -11,6 +12,7 @@ import {
accountPredicate,
attendeeOwedSubquery,
creditsLessWriteoffDebits,
externalCashBalanceSubquery,
LEG_COLUMNS,
saleLegPredicate,
signedSumCase,
Expand Down Expand Up @@ -108,6 +110,21 @@ describe("accountBalanceSubquery", () => {
});
});

describe("externalCashBalanceSubquery", () => {
test("adds cash received and subtracts cash returned", () => {
const received =
`dest_type = '${ATTENDEE}' AND dest_id = CAST(a.id AS TEXT)` +
` AND source_type = '${WORLD.type}' AND source_id = '${WORLD.id}'`;
const returned =
`source_type = '${ATTENDEE}' AND source_id = CAST(a.id AS TEXT)` +
` AND dest_type = '${WORLD.type}' AND dest_id = '${WORLD.id}'`;
expect(externalCashBalanceSubquery(ATTENDEE, "a.id")).toBe(
`(SELECT ${signedSumCase(received, returned)} FROM transfers` +
` WHERE ${received} OR ${returned})`,
);
});
});

describe("attendeeOwedSubquery", () => {
test("is the negation of the attendee's balance subquery", () => {
expect(attendeeOwedSubquery("a.id")).toBe(
Expand Down
28 changes: 27 additions & 1 deletion test/shared/db/attendees/balance.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect } from "@std/expect";
import { it as test } from "@std/testing/bdd";
import { KIND } from "#shared/accounting/kinds.ts";
import { eventGroup, legReference } from "#shared/accounting/refs.ts";
import {
attendeeStatuses,
getPaidDefaultStatus,
Expand Down Expand Up @@ -35,8 +37,9 @@ describeWithEnv("db > settle attendee balance", { db: true }, () => {
test("clears the balance, moves to the paid status and logs it", async () => {
const { attendeeId, listingId } = await createReservedAttendee(1500);
const paid = await getPaidDefaultStatus();
const settlement = settle();

const result = await settleAttendeeBalance(attendeeId, 1500, settle());
const result = await settleAttendeeBalance(attendeeId, 1500, settlement);
expect(result).toEqual({ amount: 1500, listingId, settled: true });

const state = await getAttendeeBalanceState(attendeeId);
Expand All @@ -46,6 +49,18 @@ describeWithEnv("db > settle attendee balance", { db: true }, () => {
const log = await getAttendeeActivityLog(attendeeId);
expect(log).toHaveLength(1);
expect(log[0]!.message).toContain("Reservation balance paid");

const { rows } = await getDb().execute({
args: [KIND.payment, String(attendeeId), 1500],
sql: "SELECT event_group, reference FROM transfers WHERE kind = ? AND dest_id = ? AND amount = ?",
});
expect(rows).toHaveLength(1);
expect(rows[0]!.event_group).toBe(
await eventGroup(["balance", settlement.id]),
);
expect(rows[0]!.reference).toBe(
await legReference(["balance", settlement.id, KIND.payment]),
);
});

test("clears a non-reservation balance without replacing its status", async () => {
Expand Down Expand Up @@ -113,6 +128,17 @@ describeWithEnv("db > settle attendee balance", { db: true }, () => {
});
});

test("settles a one-penny balance", async () => {
const { attendeeId } = await createReservedAttendee(1);
expect(await settleAttendeeBalance(attendeeId, 1, settle())).toMatchObject({
amount: 1,
settled: true,
});
expect((await getAttendeeBalanceState(attendeeId))?.remainingBalance).toBe(
0,
);
});

test("reports not_found for a missing attendee", async () => {
expect(await settleAttendeeBalance(9999, 1500, settle())).toEqual({
reason: "not_found",
Expand Down
Loading