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
43 changes: 36 additions & 7 deletions .claude/skills/booking/references/money-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,34 @@ neither should be reimplemented.
As of wave 5 (#1327), `quoteBookingRefund` in
`lib/payments/operations/cancellation-policy.ts` is the only refund
calculation, and both the cancel POST route and the `cancel/preview` GET route
call it. It returns `refundPct`, `noticeHours`, `proratedBasePaise`, `prorated`
and `refundPaise`. Unlike the price derivation it **does** use BigInt for its two
multiplications, on the stated grounds that the products can leave the
safe-integer range long before the amounts stop being real money. A booking that
was never scheduled has `noticeHours` of positive infinity, so it sits in the top
tier rather than the already-started floor. `PLATFORM_DEFAULT_TIERS` is 100% at
24 hours, 50% at 2 hours, 0% below.
call it. It returns `refundPct`, `tierRefundPct`, `noticeHours`,
`proratedBasePaise`, `prorated`, `refundPaise` and `creditRestoresInFull`. Unlike
the price derivation it **does** use BigInt for its two multiplications, on the
stated grounds that the products can leave the safe-integer range long before the
amounts stop being real money. A booking that was never scheduled has
`noticeHours` of positive infinity, so it sits in the top tier rather than the
already-started floor. `PLATFORM_DEFAULT_TIERS` is 100% at 24 hours, 50% at 2
hours, 0% below.

A quote that restates the rule is a rule that can drift from the charge. If you
change a tier, a proration denominator or a clamp, change it here.

**The tiers are typed rows, not a Json snapshot (#1499).** `CancellationPolicy` +
`CancellationPolicyTier` hold one published, immutable version of a ladder;
`Appointment.cancellationPolicyId` points at the version that governed the sale.
`Appointment.cancellationPolicySnapshot` is FROZEN — never written, never read,
dropped at the reset — so do not add a reader for it. Loading and publishing live
in `lib/payments/operations/cancellation-policy-store.ts`
(`POLICY_TERMS_INCLUDE`, `termsFromPolicyRow`, `ensurePlatformCancellationPolicy`,
`resolveCheckoutCancellationPolicyId`, `publishOrgCancellationPolicy`); the maths
module above stays Prisma-free. Checkout resolves the version once inside the
booking transaction, and an org's ladder governs only the bookings that **org
funds** — a personal booking merely tagged to an org keeps the platform ladder.
Webinar and class seats always use the platform ladder, because one shared
`Appointment` serves every registrant. Editing a ladder publishes a new version
and archives the old one under `withSerializableRetry`; nothing updates a version
in place.

## 5. Refunds travel on three rails, and the rail is named before the click

`FundingRail` is `"GATEWAY" | "INTERNAL" | "CREDITS"`, decided from the payment
Expand All @@ -90,6 +107,18 @@ no gateway money), everything else is `GATEWAY`. The two front doors are
`refundBookingPayment` refuses a partial `amountPaise` on the credits rail with
`RefundValidationError` / `INVALID_AMOUNT`.

**The credits rail cannot pay a fraction, so #1500 rounds the tier, not the
rail.** A booking funded entirely by credit (`free_` intent **and** amount 0)
restores its credit IN FULL inside any tier above 0%, and restores NOTHING inside
a 0% tier — a late cancel bites a credit buyer exactly as it bites a card buyer.
The whole rule is one predicate in `quoteBookingRefund`
(`isFreeCreditFunded && refundPct > 0`) surfaced as `creditRestoresInFull`; the
cancel route calls `refundBookingPayment` with no `amountPaise` when it is true
and falls through to `POLICY_ZERO` when it is not. There is no `MANUAL_REVIEW`
status any more. A `free_` intent with a NON-zero amount is a mixed payment that
still settles on the money arm and is still refused `INVALID_AMOUNT`; both halves
of the predicate are load-bearing.

As of wave 5 (#1325) the rail is also computed **ahead of the refund** by
`fundingRailForIntent` (`lib/payments/operations/booking-refund.ts`), so the
cancel dialog can say which way the money comes back. `GET
Expand Down
4 changes: 4 additions & 0 deletions __tests__/booking-algorithm/allocation-top-up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ function makeNoDeleteTx() {
},
appointment: {
findMany: jest.fn().mockResolvedValue([]),
// #1499 — createAppointments reads the originating appointment to
// inherit the policy version the booking was sold under. Null here:
// these fixtures predate the FK, so the created rows carry no policy.
findFirst: jest.fn().mockResolvedValue(null),
create: jest
.fn()
.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Expand Down
108 changes: 74 additions & 34 deletions __tests__/booking-algorithm/cancellation-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,17 @@ const mockRecordSystemError = jest.fn();
jest.mock("../../lib/prisma", () => ({
__esModule: true,
default: {
appointment: { findMany: (...a: unknown[]) => mockAppointmentFindMany(...a) },
appointment: {
findMany: (...a: unknown[]) => mockAppointmentFindMany(...a),
},
},
}));

jest.mock("../../lib/enterprise/system-events", () => ({
recordSystemError: (...a: unknown[]) => mockRecordSystemError(...a),
}));

import { PLATFORM_DEFAULT_TERMS } from "@/lib/payments/operations/cancellation-policy";
import {
bookingAppointmentFilter,
resolveBookingRefundContext,
Expand All @@ -53,6 +56,20 @@ function hoursFromNow(h: number) {
return new Date(Date.now() + h * HOUR);
}

/** A stored policy version, in the shape `POLICY_TERMS_INCLUDE` selects. */
function policyRow(
id: string,
tiers: { hoursBefore: number; refundBps: number }[],
) {
return {
id,
organizationId: null,
version: 1,
consultantInitiatedBps: 10_000,
tiers,
};
}

/**
* A subscription mid-plan: session 1 delivered, sessions 2 and 3 still owed,
* and the money sitting on the slot-less placeholder.
Expand All @@ -61,21 +78,21 @@ function subscriptionRows() {
return [
{
id: PLACEHOLDER,
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }],
slotsOfAppointment: [],
},
{
id: SESSION_1,
cancellationPolicySnapshot: { version: 1, tiers: [] },
cancellationPolicy: null,
payment: [],
slotsOfAppointment: [
{ startsAt: hoursFromNow(-48), completionStatus: "COMPLETED" },
],
},
{
id: SESSION_2,
cancellationPolicySnapshot: { version: 1, tiers: [] },
cancellationPolicy: null,
payment: [],
slotsOfAppointment: [
{ startsAt: hoursFromNow(72), completionStatus: "SCHEDULED" },
Expand All @@ -93,7 +110,10 @@ beforeEach(() => {
describe("bookingAppointmentFilter", () => {
it("selects the whole subscription, not the one appointment handed in", () => {
expect(
bookingAppointmentFilter({ appointmentId: SESSION_2, subscriptionId: "sub-1" }),
bookingAppointmentFilter({
appointmentId: SESSION_2,
subscriptionId: "sub-1",
}),
).toEqual({ subscriptionId: "sub-1" });
});

Expand All @@ -111,9 +131,7 @@ describe("bookingAppointmentFilter", () => {

it("refuses to select everything when nothing identifies the booking", () => {
// An empty filter would have matched every appointment in the table.
expect(() => bookingAppointmentFilter({})).toThrow(
/no booking identifier/,
);
expect(() => bookingAppointmentFilter({})).toThrow(/no booking identifier/);
});
});

Expand Down Expand Up @@ -161,7 +179,7 @@ describe("resolveBookingRefundContext", () => {
mockAppointmentFindMany.mockResolvedValue([
{
id: SESSION_1,
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }],
slotsOfAppointment: [
{ startsAt: hoursFromNow(30), completionStatus: "RESCHEDULED" },
Expand All @@ -180,7 +198,7 @@ describe("resolveBookingRefundContext", () => {
mockAppointmentFindMany.mockResolvedValue([
{
id: PLACEHOLDER,
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }],
slotsOfAppointment: [],
},
Expand All @@ -194,53 +212,71 @@ describe("resolveBookingRefundContext", () => {
expect(ctx.paidPayment).not.toBeNull();
});

it("takes the terms frozen on the row the buyer actually paid for", async () => {
const paidTerms = { version: 1, tiers: [{ hoursBefore: 48, refundPct: 90 }] };
it("takes the terms stamped on the row the buyer actually paid for", async () => {
mockAppointmentFindMany.mockResolvedValue([
{
id: SESSION_1,
cancellationPolicySnapshot: { version: 1, tiers: [] },
cancellationPolicy: policyRow("policy-session", [
{ hoursBefore: 0, refundBps: 0 },
]),
payment: [],
slotsOfAppointment: [],
},
{
id: PLACEHOLDER,
cancellationPolicySnapshot: paidTerms,
cancellationPolicy: policyRow("policy-paid", [
{ hoursBefore: 48, refundBps: 9_000 },
]),
payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }],
slotsOfAppointment: [],
},
]);

const ctx = await resolveBookingRefundContext({ subscriptionId: "sub-1" });

expect(ctx.policySnapshot).toEqual(paidTerms);
expect(ctx.policy.policyId).toBe("policy-paid");
expect(ctx.policy.tiers).toEqual([{ hoursBefore: 48, refundPct: 90 }]);
});

it("falls back to a session's snapshot when the paid row carries none", async () => {
// The subscription placeholder predates the snapshot write, so without this
// fallback every subscription silently dropped to the platform defaults.
const sessionTerms = {
version: 1,
tiers: [{ hoursBefore: 12, refundPct: 25 }],
};
it("falls back to a session's policy when the paid row carries none", async () => {
// Bookings sold before #1499 stamped the FK on the subscription placeholder;
// without this fallback every one of them silently dropped to the defaults.
mockAppointmentFindMany.mockResolvedValue([
{
id: PLACEHOLDER,
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }],
slotsOfAppointment: [],
},
{
id: SESSION_1,
cancellationPolicySnapshot: sessionTerms,
cancellationPolicy: policyRow("policy-session", [
{ hoursBefore: 12, refundBps: 2_500 },
]),
payment: [],
slotsOfAppointment: [],
},
]);

const ctx = await resolveBookingRefundContext({ subscriptionId: "sub-1" });

expect(ctx.policySnapshot).toEqual(sessionTerms);
expect(ctx.policy.policyId).toBe("policy-session");
expect(ctx.policy.tiers).toEqual([{ hoursBefore: 12, refundPct: 25 }]);
});

it("reads a booking with no policy row at all as the platform ladder", async () => {
mockAppointmentFindMany.mockResolvedValue([
{
id: PLACEHOLDER,
cancellationPolicy: null,
payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }],
slotsOfAppointment: [],
},
]);

const ctx = await resolveBookingRefundContext({ subscriptionId: "sub-1" });

expect(ctx.policy).toEqual(PLATFORM_DEFAULT_TERMS);
});

it("scopes the payment lookup to one buyer for group events", async () => {
Expand Down Expand Up @@ -301,7 +337,7 @@ describe("resolveBookingRefundContext", () => {
mockAppointmentFindMany.mockResolvedValue([
{
id: PLACEHOLDER,
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [
{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] },
{ id: "pay-2", amount: 40_000, refunds: [], disputes: [] },
Expand All @@ -327,7 +363,7 @@ describe("resolveBookingRefundContext", () => {
mockAppointmentFindMany.mockResolvedValue([
{
id: PLACEHOLDER,
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }],
slotsOfAppointment: [],
},
Expand All @@ -342,7 +378,7 @@ describe("resolveBookingRefundContext", () => {
mockAppointmentFindMany.mockResolvedValue([
{
id: "appt-c1",
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [{ id: "pay-c", amount: 250_000, refunds: [], disputes: [] }],
slotsOfAppointment: [
{ startsAt: hoursFromNow(5), completionStatus: "SCHEDULED" },
Expand All @@ -368,7 +404,7 @@ describe("resolveBookingRefundContext", () => {
mockAppointmentFindMany.mockResolvedValue([
{
id: PLACEHOLDER,
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [
{
id: "pay-1",
Expand Down Expand Up @@ -405,7 +441,7 @@ describe("resolveBookingRefundContext", () => {
mockAppointmentFindMany.mockResolvedValue([
{
id: PLACEHOLDER,
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [
{
id: "pay-1",
Expand All @@ -431,24 +467,28 @@ describe("resolveBookingRefundContext", () => {
mockAppointmentFindMany.mockResolvedValue([
{
id: PLACEHOLDER,
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }],
slotsOfAppointment: [],
},
]);
expect((await resolveBookingRefundContext({ subscriptionId: "s" })).slotsTotal).toBe(0);
expect(
(await resolveBookingRefundContext({ subscriptionId: "s" })).slotsTotal,
).toBe(0);

mockAppointmentFindMany.mockResolvedValue([
{
id: PLACEHOLDER,
cancellationPolicySnapshot: null,
cancellationPolicy: null,
payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }],
slotsOfAppointment: [
{ startsAt: hoursFromNow(48), completionStatus: "CANCELLED" },
],
},
]);
const cancelled = await resolveBookingRefundContext({ subscriptionId: "s" });
const cancelled = await resolveBookingRefundContext({
subscriptionId: "s",
});
expect(cancelled.slotsTotal).toBe(1);
expect(cancelled.sessionsRemaining).toBe(0);
expect(cancelled.sessionsCompleted).toBe(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ function makeMockTx() {
bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) },
appointment: {
findMany: jest.fn().mockResolvedValue([]),
// #1499 — createAppointments reads the originating appointment to
// inherit the policy version the booking was sold under. Null here:
// these fixtures predate the FK, so the created rows carry no policy.
findFirst: jest.fn().mockResolvedValue(null),
Comment thread
teetangh marked this conversation as resolved.
create: jest
.fn()
.mockResolvedValue({ id: "apt-1", slotsOfAppointment: [] }),
Expand Down
4 changes: 4 additions & 0 deletions __tests__/booking-algorithm/row-walk-truncation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ const mockTx = {
bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) },
appointment: {
findMany: jest.fn().mockResolvedValue([]),
// #1499 — createAppointments reads the originating appointment to
// inherit the policy version the booking was sold under. Null here:
// these fixtures predate the FK, so the created rows carry no policy.
findFirst: jest.fn().mockResolvedValue(null),
create: jest
.fn()
.mockResolvedValue({ id: "apt-1", slotsOfAppointment: [] }),
Expand Down
Loading
Loading