Skip to content
27 changes: 13 additions & 14 deletions .claude/skills/booking/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,13 @@ B-P1-05, #898). Requests are retired by status: `DELETE
`__tests__/payments/appointment-delete-forbidden.test.ts` keeps the six sweep
scripts free of the forbidden call shapes.

Be precise about slots rather than absolute, because slot rows are _not_
uniformly soft-deleted. `cleanup-abandoned-payments` soft-cancels them
(`transitionSlotCompletion` to `CANCELLED` plus `deletedAt`), while
`expire-stale-requests.ts` and `cleanup-tentative-slots.ts` under
`scripts/appointments/` still hard-delete tentative holds — always re-checking
`isTentative: true` in the WHERE at delete time, so a slot confirmed between the
cohort read and the statement is never touched. If you think you need a delete
on an Appointment or a confirmed slot, you are almost certainly wrong: reconcile
in place, as `replaceContiguousSlotRun` does precisely so Stream
`MeetingSession` and `Recording` rows survive.
Slot rows are soft-deleted uniformly as of #1380/#1424: `cleanup-abandoned-payments`,
`expire-stale-requests.ts`, and `cleanup-tentative-slots.ts` all release a
tentative hold the same way, through `transitionSlotCompletion` to `CANCELLED`
with `deletedAt` set in the same call, so the row's history survives the
release. If you think you need a delete on an Appointment or a confirmed slot,
you are almost certainly wrong: reconcile in place, as `replaceContiguousSlotRun`
does precisely so Stream `MeetingSession` and `Recording` rows survive.

### 3. Refunds have exactly two front doors

Expand Down Expand Up @@ -156,10 +153,12 @@ rows themselves expire from `PENDING` only. The sweep that does refund is a
different cohort — `expireApprovedUnallocatedSubscriptions` in
`scripts/appointments/expire-stale-requests.ts` calls `refundPaymentsForExpired`,
which routes every `SUCCEEDED` payment through `refundBookingPayment`. The
sibling pass in that same file, `expirePaymentPendingRequests`, is the
counter-example rather than the pattern: it flips `APPROVED_PENDING_PAYMENT` to
`EXPIRED` with a bare `updateMany` that carries neither the money predicate nor
the CAS helper.
sibling pass in that same file, `expirePaymentPendingRequests`, is the pattern
rather than a counter-example as of #1423: it flips `APPROVED_PENDING_PAYMENT`
to `EXPIRED` through `transitionConsultationRequest`, with `fromIn:
["APPROVED_PENDING_PAYMENT"]` and the `UNPAID_CONSULTATION` money predicate
repeated inside the CAS `where`, the same two guards this rule requires of any
new sweep.

### 6. There are no backfill migrations

Expand Down
23 changes: 18 additions & 5 deletions __tests__/booking/no-show-refund-front-door.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,32 @@ jest.mock("../../lib/novu/service", () => ({
notifyRefundProcessed: jest.fn(),
}));

jest.mock("../../lib/prisma", () => ({
__esModule: true,
default: {
// #1493 — claimConsultantNoShow now runs the cancel through
// transitionConsultationRequest inside prisma.$transaction, so the mock needs
// $transaction (running its callback against this same client),
// consultation.findUnique (the helper's pre-read of the from-status), and
// bookingStatusHistory.create (the audit row the helper appends).
jest.mock("../../lib/prisma", () => {
const client: Record<string, unknown> = {
consultation: {
findMany: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findUnique: jest.fn().mockResolvedValue({
status: "APPROVED",
appointment: { id: "appt-1" },
}),
},
slotOfAppointment: {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
bookingStatusHistory: {
create: jest.fn().mockResolvedValue({}),
},
$disconnect: jest.fn(),
},
}));
};
client.$transaction = jest.fn((fn: (tx: unknown) => unknown) => fn(client));
return { __esModule: true, default: client };
});

// #1280 — the detector now corroborates against Stream before refunding,
// because our attendance rows come from per-participant webhook deliveries that
Expand Down
42 changes: 42 additions & 0 deletions __tests__/maintenance/cron-lock-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,48 @@ describe("cron lock registry (#1169)", () => {
expect(orphaned).toEqual([]);
});

it("gates every refund front-door caller behind FINANCIAL_JOB_NAMES (#1506)", () => {
// A refunding sweep that is not in the set runs straight through DEGRADED
// maintenance, which is the exact bug #1506 fixed for the no-show and
// expiry sweeps. Grep scripts/** for callers rather than trusting a
// hand-maintained list, so a new refunding script fails this test instead
// of shipping unguarded.
const REFUND_FRONT_DOORS = [
"refundBookingPayment(",
"refundWholeEventPayments(",
"refundRemovedAttendeeSeat(",
"refundPaymentsForExpired(",
];
const SCRIPTS_DIR = path.join(ROOT, "scripts");

function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else if (entry.name.endsWith(".ts")) out.push(full);
}
return out;
}

const callers = walk(SCRIPTS_DIR).filter((file) => {
const src = read(file);
return !!src && REFUND_FRONT_DOORS.some((fn) => src.includes(fn));
});

expect(callers.length).toBeGreaterThan(0);

const ungated = callers
.map((file) => {
const lock = findLock(read(file));
return { file: path.relative(ROOT, file), jobName: lock?.jobName };
})
.filter((r) => !r.jobName || !FINANCIAL_JOB_NAMES.has(r.jobName))
.map((r) => `${r.file} → withCronLock("${r.jobName ?? "none"}")`);

expect(ungated).toEqual([]);
});

it("gives every scheduled workflow a queueing concurrency group", () => {
// #1413 — a second, redundant guard alongside withCronLock: an overlap
// should queue behind the in-flight run at the Actions layer too, not
Expand Down
27 changes: 21 additions & 6 deletions __tests__/maintenance/no-show-auto-complete-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,29 @@ jest.mock("../../lib/cron/with-cron-lock", () => ({
LONG_JOB_TTL_MS: 35 * 60 * 1000,
}));

jest.mock("../../lib/prisma", () => ({
__esModule: true,
default: {
consultation: { findMany: jest.fn(), updateMany: jest.fn() },
// #1493 — the no-show detector's claim now runs through
// transitionConsultationRequest inside prisma.$transaction, which needs
// consultation.findUnique (the helper's pre-read) and bookingStatusHistory
// (the audit row it appends) alongside $transaction itself.
jest.mock("../../lib/prisma", () => {
const client: Record<string, unknown> = {
consultation: {
findMany: jest.fn(),
updateMany: jest.fn(),
findUnique: jest.fn(),
},
webinar: { findMany: jest.fn(), updateMany: jest.fn() },
class: { findMany: jest.fn(), updateMany: jest.fn() },
subscription: { findMany: jest.fn(), updateMany: jest.fn() },
trialSession: { findMany: jest.fn() },
slotOfAppointment: { findMany: jest.fn(), updateMany: jest.fn() },
supportTicket: { findFirst: jest.fn() },
bookingStatusHistory: { create: jest.fn() },
$disconnect: jest.fn(),
},
}));
};
client.$transaction = jest.fn((fn: (tx: unknown) => unknown) => fn(client));
return { __esModule: true, default: client };
});

import prisma from "../../lib/prisma";
import { autoCompleteAppointments } from "../../scripts/appointments/auto-complete-appointments";
Expand Down Expand Up @@ -147,6 +157,11 @@ beforeEach(() => {
db[model].updateMany?.mockResolvedValue({ count: 1 });
}
db.supportTicket.findFirst.mockResolvedValue(null);
db.consultation.findUnique.mockResolvedValue({
status: "APPROVED",
appointment: { id: "appt-1" },
});
db.bookingStatusHistory.create.mockResolvedValue({});
refundBookingPayment.mockResolvedValue({
amountRefundedPaise: 150000,
rail: "GATEWAY",
Expand Down
3 changes: 2 additions & 1 deletion app/api/cleanup/process-payouts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const { GET, POST } = cleanupRoute({
failed: r.failed,
processed: r.processed,
}),
status: () => 200,
// #1390 review — the constant 200 masked a caught job error (success:false)
// as healthy; the default statusFor already reads result.success.
failureMessage: "Failed to process payouts",
});
3 changes: 2 additions & 1 deletion app/api/cleanup/sweep-abandoned-overage-charges/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const { GET, POST } = cleanupRoute({
job: "sweep-abandoned-overage-charges",
run: () => sweepAbandonedOverageCharges(),
summarize: (r) => ({ scanned: r.scanned, failed: r.failed }),
status: () => 200,
// #1390 review — the constant 200 masked a caught job error (success:false)
// as healthy; the default statusFor already reads result.success.
failureMessage: "Failed to sweep abandoned overage charges",
});
Loading
Loading