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
58 changes: 35 additions & 23 deletions src/features/admin/attendee-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
} from "#shared/db/attendees/atomic-update.ts";
import { getAttendeeOrderSummary } from "#shared/db/attendees/balance.ts";
import { checkLinesCapacity } from "#shared/db/attendees/capacity/checks.ts";
import { hasActiveBookingLine } from "#shared/db/attendees/queries.ts";
import {
getContactRecordOrRepair,
hashEmail,
Expand Down Expand Up @@ -76,41 +75,54 @@ export type LoadedAttendee = {
paymentReferences: RefundPaymentReferenceSet;
};

type AttendeePaymentFacts = Pick<
LoadedAttendee,
"canRefund" | "paymentReferences"
>;

/** Load one bounded, typed payment set for both display and refund admission. */
const attendeePaymentFacts = async (
const attendeePaymentReferences = async (
attendee: Attendee,
): Promise<AttendeePaymentFacts> => {
const paymentReferences = await getRefundPaymentReferencesForAttendee(
): Promise<RefundPaymentReferenceSet> =>
getRefundPaymentReferencesForAttendee(
{ currentPaymentId: attendee.payment_id, id: attendee.id },
await requireRequestPrivateKey(),
);
const hasAutomaticPayment =
paymentReferences.kind === "complete" &&
paymentReferences.references.length > 0;
return {
canRefund:
hasAutomaticPayment &&
refundWorkRemains(attendee, paymentReferences.references) &&
(await hasActiveBookingLine(attendee.id, attendee.listing_id)),
paymentReferences,
};
};

/** Whether the attendee still holds a real (quantity > 0) booking on this exact
* listing. Read from the lines the page has already loaded: asking the database
* again would answer the same question the same way. A no-quantity line does
* not count, so a line marked no-quantity stops offering the refund. */
const holdsRealLineOn = (
existing: readonly ExistingLine[],
listingId: number,
): boolean =>
existing.some(
({ booking }) => booking.listing_id === listingId && booking.quantity > 0,
);

/** Whether the page may offer a refund: automatic payment money that still has
* work left, against a booking the attendee really holds. */
const refundIsOffered = (
attendee: Attendee,
paymentReferences: RefundPaymentReferenceSet,
existing: readonly ExistingLine[],
): boolean =>
paymentReferences.kind === "complete" &&
paymentReferences.references.length > 0 &&
refundWorkRemains(attendee, paymentReferences.references) &&
holdsRealLineOn(existing, attendee.listing_id);

/** Load an attendee + all its lines, or null (→ 404) when it doesn't exist. */
export const loadAttendeeForEdit: (
attendeeId: number,
) => Promise<LoadedAttendee | null> = withDecryptedAttendee(
async (attendee) => {
const [payment, existing] = await Promise.all([
attendeePaymentFacts(attendee),
const [paymentReferences, existing] = await Promise.all([
attendeePaymentReferences(attendee),
loadExistingLines(attendee.id),
]);
return { attendee, existing, ...payment };
return {
attendee,
canRefund: refundIsOffered(attendee, paymentReferences, existing),
existing,
paymentReferences,
};
},
);

Expand Down
20 changes: 20 additions & 0 deletions test/features/admin/attendee-page-data/refund-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
finalizeProcessedPayment,
taggedPaymentReference,
} from "#test-utils/processed-payments.ts";
import { recordQueries } from "#test-utils/record-queries.ts";
import { adminGet, withTestSession } from "#test-utils/session.ts";

const getListingPageHtml = async (listingId: number): Promise<string> => {
Expand Down Expand Up @@ -120,6 +121,25 @@ describeWithEnv("server (admin refund actions)", { db: true }, () => {
await expectCannotRefund(ctx.attendee.id, false);
});

test("decides canRefund without asking about the booking again", async () => {
const ctx = await setupRefundTest("pi_no_second_line_read");
const seen: string[] = [];
const restore = recordQueries(seen);
try {
await withTestSession(() => loadAttendeeForEdit(ctx.attendee.id));
} finally {
restore();
}

// The page loads every one of the attendee's lines; asking the database
// whether one of them is a real booking would be asking twice.
expect(
seen.filter((sql) =>
sql.includes("attendee_id = ? AND listing_id = ? AND quantity > 0"),
),
).toEqual([]);
});

test("loads canRefund as false once every charge is returned", async () => {
const ctx = await setupRefundTest("pi_already_refunded_action");
await markAsRefunded(ctx.attendee.id);
Expand Down