diff --git a/.claude/skills/booking/references/money-boundary.md b/.claude/skills/booking/references/money-boundary.md index 1365c55e2..d14f47340 100644 --- a/.claude/skills/booking/references/money-boundary.md +++ b/.claude/skills/booking/references/money-boundary.md @@ -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 @@ -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 diff --git a/__tests__/booking-algorithm/allocation-top-up.test.ts b/__tests__/booking-algorithm/allocation-top-up.test.ts index 6645f7e3c..bf79c2ad2 100644 --- a/__tests__/booking-algorithm/allocation-top-up.test.ts +++ b/__tests__/booking-algorithm/allocation-top-up.test.ts @@ -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 }) => diff --git a/__tests__/booking-algorithm/cancellation-scope.test.ts b/__tests__/booking-algorithm/cancellation-scope.test.ts index 031adaf15..db9b442b4 100644 --- a/__tests__/booking-algorithm/cancellation-scope.test.ts +++ b/__tests__/booking-algorithm/cancellation-scope.test.ts @@ -31,7 +31,9 @@ const mockRecordSystemError = jest.fn(); jest.mock("../../lib/prisma", () => ({ __esModule: true, default: { - appointment: { findMany: (...a: unknown[]) => mockAppointmentFindMany(...a) }, + appointment: { + findMany: (...a: unknown[]) => mockAppointmentFindMany(...a), + }, }, })); @@ -39,6 +41,7 @@ jest.mock("../../lib/enterprise/system-events", () => ({ recordSystemError: (...a: unknown[]) => mockRecordSystemError(...a), })); +import { PLATFORM_DEFAULT_TERMS } from "@/lib/payments/operations/cancellation-policy"; import { bookingAppointmentFilter, resolveBookingRefundContext, @@ -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. @@ -61,13 +78,13 @@ 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" }, @@ -75,7 +92,7 @@ function subscriptionRows() { }, { id: SESSION_2, - cancellationPolicySnapshot: { version: 1, tiers: [] }, + cancellationPolicy: null, payment: [], slotsOfAppointment: [ { startsAt: hoursFromNow(72), completionStatus: "SCHEDULED" }, @@ -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" }); }); @@ -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/); }); }); @@ -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" }, @@ -180,7 +198,7 @@ describe("resolveBookingRefundContext", () => { mockAppointmentFindMany.mockResolvedValue([ { id: PLACEHOLDER, - cancellationPolicySnapshot: null, + cancellationPolicy: null, payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }], slotsOfAppointment: [], }, @@ -194,18 +212,21 @@ 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: [], }, @@ -213,26 +234,25 @@ describe("resolveBookingRefundContext", () => { 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: [], }, @@ -240,7 +260,23 @@ describe("resolveBookingRefundContext", () => { 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 () => { @@ -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: [] }, @@ -327,7 +363,7 @@ describe("resolveBookingRefundContext", () => { mockAppointmentFindMany.mockResolvedValue([ { id: PLACEHOLDER, - cancellationPolicySnapshot: null, + cancellationPolicy: null, payment: [{ id: "pay-1", amount: 100_000, refunds: [], disputes: [] }], slotsOfAppointment: [], }, @@ -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" }, @@ -368,7 +404,7 @@ describe("resolveBookingRefundContext", () => { mockAppointmentFindMany.mockResolvedValue([ { id: PLACEHOLDER, - cancellationPolicySnapshot: null, + cancellationPolicy: null, payment: [ { id: "pay-1", @@ -405,7 +441,7 @@ describe("resolveBookingRefundContext", () => { mockAppointmentFindMany.mockResolvedValue([ { id: PLACEHOLDER, - cancellationPolicySnapshot: null, + cancellationPolicy: null, payment: [ { id: "pay-1", @@ -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); diff --git a/__tests__/booking-algorithm/collaborator-availability-modes.test.ts b/__tests__/booking-algorithm/collaborator-availability-modes.test.ts index b938aa547..5ea75d073 100644 --- a/__tests__/booking-algorithm/collaborator-availability-modes.test.ts +++ b/__tests__/booking-algorithm/collaborator-availability-modes.test.ts @@ -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), create: jest .fn() .mockResolvedValue({ id: "apt-1", slotsOfAppointment: [] }), diff --git a/__tests__/booking-algorithm/row-walk-truncation.test.ts b/__tests__/booking-algorithm/row-walk-truncation.test.ts index a252e2c5f..54039a00c 100644 --- a/__tests__/booking-algorithm/row-walk-truncation.test.ts +++ b/__tests__/booking-algorithm/row-walk-truncation.test.ts @@ -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: [] }), diff --git a/__tests__/booking-algorithm/slotAllocationService.test.ts b/__tests__/booking-algorithm/slotAllocationService.test.ts index b6aed12fe..f1c73ce38 100644 --- a/__tests__/booking-algorithm/slotAllocationService.test.ts +++ b/__tests__/booking-algorithm/slotAllocationService.test.ts @@ -115,6 +115,10 @@ function makeMockTx() { }, 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: [], @@ -1870,6 +1874,30 @@ describe("createAppointments - grouping and validation", () => { } }); + // #1499 — a session allocated after checkout must cite the policy VERSION the + // booking was sold under, read off the originating appointment. Resolving one + // afresh here would hand the buyer whatever ladder the org has published + // since, which is exactly what immutable versions exist to prevent. + it("should inherit the originating appointment's cancellation policy", async () => { + mockTx.consultation.findUnique.mockResolvedValue(makeConsultationEvent()); + mockTx.appointment.findFirst.mockResolvedValue({ + cancellationPolicyId: "policy-abc", + }); + + await SlotAllocationService.allocate({ + eventType: "consultation", + eventId: "consult-1", + mode: "manual", + slots: ["2025-01-06T10:00:00Z", "2025-01-06T10:30:00Z"], + }); + + expect(mockTx.appointment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ cancellationPolicyId: "policy-abc" }), + }), + ); + }); + it("should only connect consultant when no consultee (webinar)", async () => { mockTx.webinar.findUnique.mockResolvedValue(makeWebinarEvent()); diff --git a/__tests__/booking/cancellation-policy.test.ts b/__tests__/booking/cancellation-policy.test.ts index faadda0bb..50d8e0f05 100644 --- a/__tests__/booking/cancellation-policy.test.ts +++ b/__tests__/booking/cancellation-policy.test.ts @@ -3,19 +3,32 @@ */ /** - * B1 — the snapshot-at-booking refund policy. The tiers frozen onto the - * Appointment at checkout decide the refund; consultant-initiated always - * refunds in full; pre-feature bookings (null snapshot) get the platform - * defaults. + * B1/#1499 — the refund policy a booking was sold under. The terms live in typed + * versioned rows now, but the guarantee is the same one the Json snapshot gave: the + * ladder that governs a booking is the one that was live when it was bought, and a + * booking with no policy at all is governed by the platform defaults. + * + * #1500 — and a booking funded entirely by referral credit restores that credit in + * full inside any partial tier, because the credits rail cannot pay a fraction. A 0% + * tier still restores nothing. */ import { computeRefundPct, - parsePolicySnapshot, - resolveCancellationPolicySnapshot, + quoteBookingRefund, + validateTierLadder, + MAX_POLICY_TIERS, PLATFORM_DEFAULT_TIERS, + PLATFORM_DEFAULT_TERMS, + type CancellationPolicyTerms, } from "@/lib/payments/operations/cancellation-policy"; +function terms( + overrides: Partial = {}, +): CancellationPolicyTerms { + return { ...PLATFORM_DEFAULT_TERMS, ...overrides }; +} + describe("computeRefundPct — platform default tiers", () => { it.each([ [48, 100], // two days out → full refund @@ -36,33 +49,119 @@ describe("computeRefundPct — platform default tiers", () => { expect(computeRefundPct(null, 1, true)).toBe(100); expect(computeRefundPct(null, -3, true)).toBe(100); }); -}); -describe("snapshot freezing", () => { - it("a frozen snapshot wins over whatever the defaults become later", () => { - const generous = { - ...resolveCancellationPolicySnapshot(), + it("a frozen ladder wins over whatever the defaults become later", () => { + const generous = terms({ + policyId: "policy-1", + source: "ORG", tiers: [{ hoursBefore: 0, refundPct: 100 }], - }; - // 1 hour before start: platform default says 0, the buyer's frozen - // terms say 100 — the snapshot governs. + }); + // 1 hour before start: platform default says 0, the buyer's frozen terms say + // 100 — the version the booking cites governs. expect(computeRefundPct(generous, 1, false)).toBe(100); + expect(PLATFORM_DEFAULT_TERMS.tiers).toEqual(PLATFORM_DEFAULT_TIERS); + }); +}); + +describe("validateTierLadder — the one ladder rule", () => { + it.each([ + ["the platform ladder", PLATFORM_DEFAULT_TIERS, null], + ["a single total rung", [{ hoursBefore: 0, refundPct: 50 }], null], + ["an empty ladder", [], "A policy needs at least one tier"], + [ + "too many rungs", + Array.from({ length: MAX_POLICY_TIERS + 1 }, (_, i) => ({ + hoursBefore: MAX_POLICY_TIERS - i, + refundPct: 0, + })), + `A policy may not have more than ${MAX_POLICY_TIERS} tiers`, + ], + [ + "a ladder that never reaches zero notice", + [{ hoursBefore: 2, refundPct: 50 }], + "The last tier must start at 0 hours so every cancellation is covered", + ], + [ + "two rungs at the same notice", + [ + { hoursBefore: 0, refundPct: 50 }, + { hoursBefore: 0, refundPct: 10 }, + ], + "Two tiers may not share the same notice period", + ], + [ + "a refund above 100%", + [{ hoursBefore: 0, refundPct: 120 }], + "Each tier's refund must be between 0 and 100 percent", + ], + [ + "three decimal places", + [{ hoursBefore: 0, refundPct: 12.345 }], + "A refund percentage may carry at most two decimal places", + ], + // #1513 review — `0.07 * 100` is 7.000000000000001 in IEEE 754, so the + // exact-equality form of this check refused a legal two-decimal rung. + [ + "two decimal places that float badly", + [{ hoursBefore: 0, refundPct: 0.07 }], + null, + ], + [ + "three decimal places below one percent", + [{ hoursBefore: 0, refundPct: 0.075 }], + "A refund percentage may carry at most two decimal places", + ], + [ + "fractional notice hours", + [{ hoursBefore: 1.5, refundPct: 0 }], + "Each tier's notice must be a whole number of hours, zero or more", + ], + ])("%s", (_label, tiers, expected) => { + expect(validateTierLadder(tiers)).toBe(expected); + }); +}); + +describe("quoteBookingRefund — #1500 credit-funded bookings", () => { + const base = { + policy: null, + hoursUntilNextSession: 3, + slotsTotal: 1, + sessionsRemaining: 1, + isSubscription: false, + isConsultantInitiated: false, + grossPaise: 0, + refundablePaise: 0, + }; + + it("restores the credit in full inside a partial tier", () => { + // Three hours' notice is the 50% rung; a credit cannot be halved, so the whole + // credit comes back and the quote says 100%. + const quote = quoteBookingRefund({ ...base, isFreeCreditFunded: true }); + expect(quote.tierRefundPct).toBe(50); + expect(quote.creditRestoresInFull).toBe(true); + expect(quote.refundPct).toBe(100); }); - it("round-trips through the Json column", () => { - const snap = resolveCancellationPolicySnapshot({ - orgPolicyText: "Org prose policy", + it("restores nothing inside the 0% tier", () => { + const quote = quoteBookingRefund({ + ...base, + hoursUntilNextSession: 1, + isFreeCreditFunded: true, }); - const parsed = parsePolicySnapshot(JSON.parse(JSON.stringify(snap))); - expect(parsed).not.toBeNull(); - expect(parsed!.tiers).toEqual(PLATFORM_DEFAULT_TIERS); - expect(parsed!.orgPolicyText).toBe("Org prose policy"); + expect(quote.tierRefundPct).toBe(0); + expect(quote.creditRestoresInFull).toBe(false); + expect(quote.refundPct).toBe(0); }); - it("rejects malformed snapshots (falls back to defaults at the call site)", () => { - expect(parsePolicySnapshot(null)).toBeNull(); - expect(parsePolicySnapshot("v1")).toBeNull(); - expect(parsePolicySnapshot({ version: 2, tiers: [] })).toBeNull(); - expect(parsePolicySnapshot({ version: 1 })).toBeNull(); + it("leaves a money-funded booking on the tier percentage", () => { + const quote = quoteBookingRefund({ + ...base, + isFreeCreditFunded: false, + grossPaise: 200_000, + refundablePaise: 200_000, + }); + expect(quote.creditRestoresInFull).toBe(false); + expect(quote.refundPct).toBe(50); + expect(quote.refundPaise).toBe(100_000); }); }); diff --git a/__tests__/enterprise/multi-engagement-cap.test.ts b/__tests__/enterprise/multi-engagement-cap.test.ts index fc7bb84bc..5f65ee3b8 100644 --- a/__tests__/enterprise/multi-engagement-cap.test.ts +++ b/__tests__/enterprise/multi-engagement-cap.test.ts @@ -14,6 +14,9 @@ * - Cap with BLOCK throws ProgramAssignmentLimitError when exceeded * - Cap with CHARGE_MEMBER / CHARGE_ORG marks wasOverage and continues * - reverseBookingUtilization decrements engagementsUsed by the row's full count + * - #1372: the MONEY meter, not just the count meter — a CREDIT_POOL reversal + * decrements consumedPaise, a LICENSED_SEAT reversal leaves it alone, and a + * refundRatio reverses price in proportion to the money actually refunded * * Architecture note: the helper does an upsert on `paymentId` (which is * @unique on BookingUtilization). For SUBSCRIPTION, the first allocation @@ -93,7 +96,9 @@ function makeTx(opts: { create: jest.fn().mockResolvedValue({}), // Defaults to "no prior reversals" for fresh tests; partial-reversal // tests override this to simulate cumulative-reversed state. - aggregate: jest.fn().mockResolvedValue({ _sum: { engagementsConsumed: 0 } }), + aggregate: jest + .fn() + .mockResolvedValue({ _sum: { engagementsConsumed: 0 } }), }, overageEvent: { updateMany: jest.fn().mockResolvedValue({ count: 0 }), @@ -242,7 +247,9 @@ describe("recordBookingUtilization — engagement counting (issue #710)", () => expect(result.engagementsConsumedDelta).toBe(2); expect(tx.programAssignment.update).toHaveBeenCalledTimes(1); // Upsert was called with appointmentIds in create branch - expect(tx.bookingUtilization.upsert.mock.calls[0][0].create).toMatchObject({ + expect( + tx.bookingUtilization.upsert.mock.calls[0][0].create, + ).toMatchObject({ engagementsConsumed: 2, appointmentIds: ["a", "b"], }); @@ -284,7 +291,9 @@ describe("recordBookingUtilization — engagement counting (issue #710)", () => }); expect(result.engagementsConsumedDelta).toBe(1); // Upsert append-pushes only the new id - expect(tx.bookingUtilization.upsert.mock.calls[0][0].update.appointmentIds).toEqual({ + expect( + tx.bookingUtilization.upsert.mock.calls[0][0].update.appointmentIds, + ).toEqual({ push: ["c"], }); }); @@ -505,6 +514,114 @@ describe("reverseBookingUtilization — refund cap reversal (full + partial)", ( expect(tx.usageLedgerEntry.create).not.toHaveBeenCalled(); }); + // #1372 — every case above meters ENGAGEMENTS. A CREDIT_POOL program meters + // PAISE, and that arm had no assertions at all, so `consumedPaise` could have + // reversed the wrong amount (or not at all) without a single test noticing. + // `makeTx`'s single `aggregate` mock answers both aggregates; `sumPaise` of an + // undefined sum is 0, so the price clamp below is inert and the proportional + // amount is what lands. + it("#1372 CREDIT_POOL: a full reversal decrements consumedPaise by the whole price", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + programAssignmentId: "asg-1", + engagementsConsumed: 2, + priceAtBookingPaise: 200_000, + wasOverage: false, + reversedAt: null, + programAssignment: { + membershipId: "mem-1", + program: { type: "CREDIT_POOL" }, + }, + }); + + await reverseBookingUtilization(tx as never, { + paymentId: "pay-credit-pool", + reason: "Refund", + }); + + expect(tx.programAssignment.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + engagementsUsed: { decrement: 2 }, + consumedPaise: { decrement: 200_000 }, + }), + }), + ); + }); + + it("#1372 LICENSED_SEAT: consumedPaise is left untouched", async () => { + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + programAssignmentId: "asg-1", + engagementsConsumed: 2, + priceAtBookingPaise: 200_000, + wasOverage: false, + reversedAt: null, + programAssignment: { + membershipId: "mem-1", + program: { type: "LICENSED_SEAT" }, + }, + }); + + await reverseBookingUtilization(tx as never, { + paymentId: "pay-licensed-seat", + reason: "Refund", + }); + + // A seat program meters seats, so writing paise back would be inventing a + // number. `undefined` is the deliberate absence, not a forgotten branch. + expect(tx.programAssignment.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.not.objectContaining({ + consumedPaise: expect.anything(), + }), + }), + ); + }); + + it("#1372 refundRatio: reverses price in proportion to the money refunded", async () => { + // The docblock's own example: a ₹750 refund of a 2 × ₹1,000 booking releases + // one seat but reverses ₹750 of price, not the ₹1,000 the seat count implies. + const tx = makeTx({ cap: 10, behavior: "BLOCK" }); + tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ + programAssignmentId: "asg-1", + engagementsConsumed: 2, + priceAtBookingPaise: 200_000, + wasOverage: false, + reversedAt: null, + programAssignment: { + membershipId: "mem-1", + program: { type: "CREDIT_POOL" }, + }, + }); + + const result = await reverseBookingUtilization(tx as never, { + paymentId: "pay-refund-ratio", + engagementsToReverse: 1, + refundRatio: { refundAmountPaise: 75_000, paymentAmountPaise: 200_000 }, + reason: "Partial refund", + }); + + expect(result.engagementsReversed).toBe(1); + expect(tx.programAssignment.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + engagementsUsed: { decrement: 1 }, + consumedPaise: { decrement: 75_000 }, + }), + }), + ); + // The ledger has to agree with the meter or the two drift apart silently. + expect(tx.usageLedgerEntry.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + engagementsConsumed: -1, + priceAtBookingPaise: -75_000, + }), + }), + ); + }); + it("overageCount only decrements on the LAST (fully-reversing) reversal", async () => { const tx = makeTx({ cap: 10, behavior: "BLOCK" }); tx.bookingUtilization.findUnique = jest.fn().mockResolvedValue({ diff --git a/__tests__/payments/allocation-utilization-integrity.test.ts b/__tests__/payments/allocation-utilization-integrity.test.ts index b6345c450..013c8b2e9 100644 --- a/__tests__/payments/allocation-utilization-integrity.test.ts +++ b/__tests__/payments/allocation-utilization-integrity.test.ts @@ -148,6 +148,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(), update: jest.fn(), deleteMany: jest.fn().mockResolvedValue({ count: 1 }), diff --git a/__tests__/payments/attendee-removal-refund.test.ts b/__tests__/payments/attendee-removal-refund.test.ts index c0e40b522..81223b22b 100644 --- a/__tests__/payments/attendee-removal-refund.test.ts +++ b/__tests__/payments/attendee-removal-refund.test.ts @@ -93,7 +93,7 @@ function seat( paymentIntent, refunds: [], disputes: [], - appointment: { cancellationPolicySnapshot: null }, + appointment: { cancellationPolicy: null }, ...extra, }; } diff --git a/__tests__/payments/cancel-route-refund.test.ts b/__tests__/payments/cancel-route-refund.test.ts index dd7ed3f76..193c57973 100644 --- a/__tests__/payments/cancel-route-refund.test.ts +++ b/__tests__/payments/cancel-route-refund.test.ts @@ -168,7 +168,7 @@ function consultationAppointment() { organizationId: null, consultationId: "cons-1", subscriptionId: null, - cancellationPolicySnapshot: null, + cancellationPolicy: null, slotsOfAppointment: [{ startsAt: new Date(Date.now() + 120 * HOUR) }], consultation: { id: "cons-1", @@ -242,7 +242,7 @@ function bookingRows(opts: { return [ { id: APPT, - cancellationPolicySnapshot: null, + cancellationPolicy: null, payment: opts.noPayment ? [] : [ @@ -558,23 +558,48 @@ describe("#1161 — a credit-funded booking refunds as a credit restoration", () expect(body.refund.amountRefundedPaise).toBe(25_000); }); - it("escalates a partial window instead of guessing a partial restoration", async () => { + it("restores a credit-funded booking in full inside a partial window (#1500)", async () => { mockGetSession.mockResolvedValue(sessionAs("consultee")); mockAppointmentFindUnique.mockResolvedValue(consultationAppointment()); - // Inside the day: a partial tier, which credit restoration has no rule for. + // Inside the day: the 50% rung. A credit cannot be halved — the rail refuses a + // partial amount — so the product rule is to restore it whole. This case used + // to escalate to MANUAL_REVIEW and move nothing at all. mockAppointmentFindMany.mockImplementation(async () => bookingRows({ liveSlotHours: [12], ...freeFunded }), ); + mockRefundBookingPayment.mockResolvedValue({ + refundId: "r-credits", + amountRefundedPaise: 0, + rail: "CREDITS", + }); const res = await cancelHandler(makeRequest(), makeParams(APPT)); const body = await res.json(); - expect(mockRefundBookingPayment).not.toHaveBeenCalled(); - expect(body.refund.status).toBe("MANUAL_REVIEW"); - expect(body.refund.requiresManualReview).toBe(true); - expect(mockRecordSystemError).toHaveBeenCalledWith( - expect.objectContaining({ category: "PAYMENT" }), + // No amountPaise: the credits rail restores the whole credit or nothing. + const call = mockRefundBookingPayment.mock.calls[0][0]; + expect(call.paymentId).toBe("pay-1"); + expect(call.amountPaise).toBeUndefined(); + expect(body.refund.status).toBe("REFUNDED"); + expect(body.refund.refundPct).toBe(100); + expect(mockRecordSystemError).not.toHaveBeenCalled(); + }); + + it("restores nothing inside the zero tier, exactly as a card would (#1500)", async () => { + mockGetSession.mockResolvedValue(sessionAs("consultee")); + mockAppointmentFindUnique.mockResolvedValue(consultationAppointment()); + // One hour out: the 0% rung. All-or-nothing follows the ladder, so a late + // cancel bites a credit buyer as it bites a card buyer. + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ liveSlotHours: [1], ...freeFunded }), ); + + const res = await cancelHandler(makeRequest(), makeParams(APPT)); + const body = await res.json(); + + expect(mockRefundBookingPayment).not.toHaveBeenCalled(); + expect(body.refund.status).toBe("POLICY_ZERO"); + expect(body.refund.refundPct).toBe(0); }); it("surfaces a failed restoration rather than reporting it as refunded", async () => { diff --git a/__tests__/payments/checkout-open-order-reuse.test.ts b/__tests__/payments/checkout-open-order-reuse.test.ts index a1948cf2a..998ca611e 100644 --- a/__tests__/payments/checkout-open-order-reuse.test.ts +++ b/__tests__/payments/checkout-open-order-reuse.test.ts @@ -157,9 +157,13 @@ jest.mock("../../lib/novu/org-workflows", () => ({ notifyOrgProgramExhausted: jest.fn(), notifyOrgProgramCapNear: jest.fn(), })); -jest.mock("../../lib/payments/operations/cancellation-policy", () => ({ +// #1499 — checkout now resolves a policy VERSION id rather than freezing Json. +jest.mock("../../lib/payments/operations/cancellation-policy-store", () => ({ __esModule: true, - resolveCancellationPolicySnapshot: jest.fn(() => ({})), + // #1513 review — the platform row is provisioned on the global client just + // before the booking transaction opens, so the mock has to answer that too. + ensurePlatformCancellationPolicy: jest.fn(async () => "policy-platform"), + resolveCheckoutCancellationPolicyId: jest.fn(async () => "policy-1"), })); import prisma from "../../lib/prisma"; diff --git a/__tests__/payments/consultation-atom-parity.test.ts b/__tests__/payments/consultation-atom-parity.test.ts index e6e7939ba..34093d54b 100644 --- a/__tests__/payments/consultation-atom-parity.test.ts +++ b/__tests__/payments/consultation-atom-parity.test.ts @@ -289,6 +289,8 @@ async function runCheckoutCreator(): Promise { CONSULTEE_USER, false, null, + // #1499 — the policy version the caller resolved; this suite only asserts atoms. + "policy-1", ); expect(checkoutAppointmentCreate).toHaveBeenCalledTimes(1); diff --git a/__tests__/payments/refund-preview-parity.test.ts b/__tests__/payments/refund-preview-parity.test.ts index b71e54b5d..7b98ef20c 100644 --- a/__tests__/payments/refund-preview-parity.test.ts +++ b/__tests__/payments/refund-preview-parity.test.ts @@ -8,17 +8,23 @@ * The preview route exists for exactly one reason: to tell somebody, before * they click, what cancelling will return to them. That promise is only worth * something if the two agree, and until now nothing checked that they did. - * Both sides computed the same four steps — notice tier, snapshot policy, + * Both sides computed the same four steps — notice tier, the booking's policy, * per-session proration, clamp to the refundable balance — inline, in two * files, with two sets of comments explaining the same reasoning. Two copies of * a money rule drift; that is what copies do. * + * #1499 — the tiers are typed rows behind `Appointment.cancellationPolicyId` now, + * so the fixtures below are stored basis-point ladders rather than Json snapshots. + * #1500 adds the credit-funded row: its quote is a full restoration on the credits + * rail, which pays zero gateway money, so the parity assertions branch on what the + * preview says happens rather than on the amount alone. + * * `quoteBookingRefund` is now the single implementation and both routes call * it, but a shared function is only half the guarantee: each route still feeds * it, and each route could feed it differently. So this suite drives the real * GET and the real POST over one prisma stub and compares the quote the buyer * read against the amount `refundBookingPayment` was actually asked for, across - * a matrix of snapshot tiers, notice windows, paid amounts and prior partial + * a matrix of policy ladders, notice windows, paid amounts and prior partial * refunds. * * The stub models the ordering the database imposes, borrowed from @@ -144,7 +150,7 @@ jest.mock("../../lib/activity/log-activity", () => ({ import { POST as cancelHandler } from "@/app/api/appointments/[appointmentId]/cancel/route"; import { GET as previewHandler } from "@/app/api/appointments/[appointmentId]/cancel/preview/route"; -import type { CancellationPolicySnapshot } from "@/lib/payments/operations/cancellation-policy"; +import type { RefundTier } from "@/lib/payments/operations/cancellation-policy"; const HOUR = 3_600_000; const APPT = "appt-1"; @@ -216,10 +222,36 @@ function appointmentRow(kind: "consultation" | "subscription") { }; } +/** A stored policy version, in the shape `POLICY_TERMS_INCLUDE` selects. */ +type PolicyFixture = { + tiers: RefundTier[]; + consultantInitiatedPct?: number; + organizationId?: string | null; +}; + +function policyRow(policy: PolicyFixture) { + return { + id: "policy-1", + organizationId: policy.organizationId ?? null, + version: 1, + consultantInitiatedBps: Math.round( + (policy.consultantInitiatedPct ?? 100) * 100, + ), + tiers: [...policy.tiers] + .sort((a, b) => b.hoursBefore - a.hoursBefore) + .map((tier) => ({ + hoursBefore: tier.hoursBefore, + refundBps: Math.round(tier.refundPct * 100), + })), + }; +} + type Case = { name: string; kind: "consultation" | "subscription"; - snapshot: CancellationPolicySnapshot | null; + policy: PolicyFixture | null; + /** Defaults to a gateway intent; `free_` drives the #1500 credit rail. */ + paymentIntent?: string; /** Hours until each undelivered session. */ liveSlotHours: number[]; /** Hours (negative = past) of each already-delivered session. */ @@ -242,12 +274,12 @@ function bookingRows(c: Case) { return [ { id: APPT, - cancellationPolicySnapshot: c.snapshot, + cancellationPolicy: c.policy ? policyRow(c.policy) : null, payment: [ { id: "pay-1", amount: c.grossPaise, - paymentIntent: "pi_gateway_1", + paymentIntent: c.paymentIntent ?? "pi_gateway_1", refunds: c.priorRefundPaise ? [{ amountPaise: c.priorRefundPaise, status: "SUCCEEDED" }] : [], @@ -283,46 +315,42 @@ function makeParams(id: string) { } /** The tiers a platform-default booking is quoted against. */ -const DEFAULT_SNAPSHOT: CancellationPolicySnapshot = { - version: 1, - source: "PLATFORM_DEFAULT", +const DEFAULT_POLICY: PolicyFixture = { tiers: [ { hoursBefore: 24, refundPct: 100 }, { hoursBefore: 2, refundPct: 50 }, { hoursBefore: 0, refundPct: 0 }, ], - consultantInitiatedPct: 100, }; -/** A stricter org-shaped snapshot: nothing is ever fully refundable. */ -const STRICT_SNAPSHOT: CancellationPolicySnapshot = { - version: 1, - source: "ORG_DEFAULT", +/** A stricter org-published ladder: nothing is ever fully refundable. */ +const STRICT_POLICY: PolicyFixture = { + organizationId: "org-1", tiers: [ { hoursBefore: 72, refundPct: 80 }, { hoursBefore: 24, refundPct: 40 }, { hoursBefore: 0, refundPct: 10 }, ], - consultantInitiatedPct: 100, - orgPolicyText: "Acme cancellation terms", }; -/** A two-step snapshot whose thresholds are deliberately out of order. */ -const UNSORTED_SNAPSHOT: CancellationPolicySnapshot = { - version: 1, - source: "ORG_DEFAULT", +/** + * A two-step ladder whose thresholds arrive out of order. The store orders tiers + * on read, so this fixture skips that ordering deliberately: `computeRefundPct` + * sorts for itself, and a caller that hands it raw rows must still be quoted right. + */ +const UNSORTED_POLICY: PolicyFixture = { + organizationId: "org-1", tiers: [ { hoursBefore: 1, refundPct: 25 }, { hoursBefore: 48, refundPct: 90 }, ], - consultantInitiatedPct: 100, }; const CASES: Case[] = [ { name: "platform default, five days out, whole price", kind: "consultation", - snapshot: DEFAULT_SNAPSHOT, + policy: DEFAULT_POLICY, liveSlotHours: [120], grossPaise: 500_000, actor: "consultee", @@ -330,7 +358,7 @@ const CASES: Case[] = [ { name: "platform default, six hours out, mid tier", kind: "consultation", - snapshot: DEFAULT_SNAPSHOT, + policy: DEFAULT_POLICY, liveSlotHours: [6], grossPaise: 500_000, actor: "consultee", @@ -338,15 +366,15 @@ const CASES: Case[] = [ { name: "platform default, inside the final two hours", kind: "consultation", - snapshot: DEFAULT_SNAPSHOT, + policy: DEFAULT_POLICY, liveSlotHours: [1], grossPaise: 500_000, actor: "consultee", }, { - name: "no snapshot at all falls back to the platform tiers", + name: "no policy row at all falls back to the platform tiers", kind: "consultation", - snapshot: null, + policy: null, liveSlotHours: [6], grossPaise: 500_000, actor: "consultee", @@ -354,7 +382,7 @@ const CASES: Case[] = [ { name: "consultant-initiated inside the zero tier still refunds in full", kind: "consultation", - snapshot: DEFAULT_SNAPSHOT, + policy: DEFAULT_POLICY, liveSlotHours: [1], grossPaise: 500_000, actor: "consultant", @@ -362,7 +390,7 @@ const CASES: Case[] = [ { name: "strict org tiers, four days out", kind: "consultation", - snapshot: STRICT_SNAPSHOT, + policy: STRICT_POLICY, liveSlotHours: [96], grossPaise: 333_333, actor: "consultee", @@ -370,7 +398,7 @@ const CASES: Case[] = [ { name: "strict org tiers, thirty hours out", kind: "consultation", - snapshot: STRICT_SNAPSHOT, + policy: STRICT_POLICY, liveSlotHours: [30], grossPaise: 333_333, actor: "consultee", @@ -378,7 +406,7 @@ const CASES: Case[] = [ { name: "strict org tiers, inside the day, never fully unrefundable", kind: "consultation", - snapshot: STRICT_SNAPSHOT, + policy: STRICT_POLICY, liveSlotHours: [3], grossPaise: 333_333, actor: "consultee", @@ -386,7 +414,7 @@ const CASES: Case[] = [ { name: "unsorted tiers resolve to the highest threshold cleared", kind: "consultation", - snapshot: UNSORTED_SNAPSHOT, + policy: UNSORTED_POLICY, liveSlotHours: [50], grossPaise: 250_000, actor: "consultee", @@ -394,7 +422,7 @@ const CASES: Case[] = [ { name: "a prior partial refund clamps the remainder", kind: "consultation", - snapshot: DEFAULT_SNAPSHOT, + policy: DEFAULT_POLICY, liveSlotHours: [120], grossPaise: 500_000, priorRefundPaise: 200_000, @@ -403,7 +431,7 @@ const CASES: Case[] = [ { name: "a prior partial refund below the tiered amount is untouched", kind: "consultation", - snapshot: DEFAULT_SNAPSHOT, + policy: DEFAULT_POLICY, liveSlotHours: [6], grossPaise: 500_000, priorRefundPaise: 100_000, @@ -412,7 +440,7 @@ const CASES: Case[] = [ { name: "a subscription half consumed prorates the base", kind: "subscription", - snapshot: DEFAULT_SNAPSHOT, + policy: DEFAULT_POLICY, liveSlotHours: [120, 300, 480], completedSlotHours: [-200, -100, -50], grossPaise: 900_000, @@ -421,7 +449,7 @@ const CASES: Case[] = [ { name: "a subscription prorating to an indivisible base rounds down", kind: "subscription", - snapshot: STRICT_SNAPSHOT, + policy: STRICT_POLICY, liveSlotHours: [96, 200], completedSlotHours: [-10], grossPaise: 100_001, @@ -430,7 +458,7 @@ const CASES: Case[] = [ { name: "a prorated subscription also clamped by a prior refund", kind: "subscription", - snapshot: DEFAULT_SNAPSHOT, + policy: DEFAULT_POLICY, liveSlotHours: [120, 300], completedSlotHours: [-40, -20], grossPaise: 800_000, @@ -440,11 +468,32 @@ const CASES: Case[] = [ { name: "a subscription with every session still owed refunds the whole price", kind: "subscription", - snapshot: DEFAULT_SNAPSHOT, + policy: DEFAULT_POLICY, liveSlotHours: [120, 300, 480, 600], grossPaise: 640_000, actor: "consultee", }, + { + // #1500 — six hours out is the 50% rung, and a credit cannot be halved, so the + // quote and the charge both settle at a full restoration of zero gateway paise. + name: "a credit-funded booking inside a partial tier restores the credit in full", + kind: "consultation", + policy: DEFAULT_POLICY, + paymentIntent: "free_credit_1", + liveSlotHours: [6], + grossPaise: 0, + actor: "consultee", + }, + { + // #1500 — and the 0% rung still returns nothing, exactly as it does for a card. + name: "a credit-funded booking inside the zero tier restores nothing", + kind: "consultation", + policy: DEFAULT_POLICY, + paymentIntent: "free_credit_1", + liveSlotHours: [1], + grossPaise: 0, + actor: "consultee", + }, ]; beforeEach(() => { @@ -463,12 +512,13 @@ beforeEach(() => { currency: "INR", paymentIntent: "pi_gateway_1", }); + // The credits rail refuses an amount and restores the whole credit, so it reports + // zero paise moved — which is why the parity assertions below read the status. mockRefundBookingPayment.mockImplementation( - async ({ amountPaise }: { amountPaise: number }) => ({ - refundId: "r1", - amountRefundedPaise: amountPaise, - rail: "GATEWAY", - }), + async ({ amountPaise }: { amountPaise?: number }) => + amountPaise === undefined + ? { refundId: "r1", amountRefundedPaise: 0, rail: "CREDITS" } + : { refundId: "r1", amountRefundedPaise: amountPaise, rail: "GATEWAY" }, ); }); @@ -477,6 +527,12 @@ async function quoteThenCancel(c: Case) { mockGetSession.mockResolvedValue(sessionAs(c.actor)); mockAppointmentFindUnique.mockResolvedValue(appointmentRow(c.kind)); mockAppointmentFindMany.mockImplementation(async () => bookingRows(c)); + // The preview names the rail off its own payment lookup, so it has to see the + // same intent the booking rows carry. + mockPaymentFindFirst.mockResolvedValue({ + currency: "INR", + paymentIntent: c.paymentIntent ?? "pi_gateway_1", + }); const previewRes = await previewHandler( new Request(`http://localhost/api/appointments/${APPT}/cancel/preview`), @@ -514,20 +570,24 @@ describe("the cancel preview quotes what the cancel actually pays", () => { // A zero quote is a promise too: nothing is paid and nothing is attempted. // Reading the calls off the mock rather than branching keeps this one // assertion for both outcomes. - expect( - mockRefundBookingPayment.mock.calls.map(([arg]) => ({ - paymentId: (arg as { paymentId: string }).paymentId, - amountPaise: (arg as { amountPaise: number }).amountPaise, - })), - ).toEqual( - preview.estimatedRefundPaise > 0 - ? [{ paymentId: "pay-1", amountPaise: preview.estimatedRefundPaise }] - : [], + const calls = mockRefundBookingPayment.mock.calls.map(([arg]) => ({ + paymentId: (arg as { paymentId: string }).paymentId, + amountPaise: (arg as { amountPaise?: number }).amountPaise, + })); + // #1500 — a full credit restoration is issued with NO amount, because the + // credits rail refuses a partial one. Everything else pays exactly the quote. + expect(calls).toEqual( + preview.creditRestoresInFull + ? [{ paymentId: "pay-1", amountPaise: undefined }] + : preview.estimatedRefundPaise > 0 + ? [{ paymentId: "pay-1", amountPaise: preview.estimatedRefundPaise }] + : [], ); - // The route's own three-way: paid, or one of the two different zeros. + // The route's own outcomes: paid, restored in full, or one of the two + // different zeros. expect(cancelled.refund.status).toBe( - preview.estimatedRefundPaise > 0 + preview.creditRestoresInFull || preview.estimatedRefundPaise > 0 ? "REFUNDED" : preview.refundPct > 0 ? "NOTHING_REFUNDABLE" @@ -570,7 +630,7 @@ describe("the quote's own numbers hold up", () => { const c: Case = { name: "never scheduled", kind: "subscription", - snapshot: DEFAULT_SNAPSHOT, + policy: DEFAULT_POLICY, liveSlotHours: [], grossPaise: 500_000, actor: "consultee", diff --git a/__tests__/payments/rejection-refund.test.ts b/__tests__/payments/rejection-refund.test.ts index a8e911871..e745ea3fb 100644 --- a/__tests__/payments/rejection-refund.test.ts +++ b/__tests__/payments/rejection-refund.test.ts @@ -56,6 +56,7 @@ jest.mock("../../lib/enterprise/system-events", () => ({ recordSystemError: (...a: unknown[]) => mockRecordSystemError(...a), })); +import { PLATFORM_DEFAULT_TERMS } from "@/lib/payments/operations/cancellation-policy"; import { refundRejectedRequest } from "../../lib/booking/rejection-refund"; const PAID = { @@ -65,7 +66,9 @@ const PAID = { refundablePaise: 100_000, paymentIntent: "pay_ABC", }, - policySnapshot: null, + // #1499 — the context resolves terms, never a raw column; null tiers would be an + // impossible shape, so PLATFORM_DEFAULT_TERMS is what an unpublished booking reads. + policy: PLATFORM_DEFAULT_TERMS, hoursUntilNextSession: null, sessionsCompleted: 0, sessionsRemaining: 0, @@ -150,7 +153,9 @@ describe("refundRejectedRequest", () => { actor: "CONSULTANT", }); - expect(mockResolveContext).toHaveBeenCalledWith({ subscriptionId: "sub-1" }); + expect(mockResolveContext).toHaveBeenCalledWith({ + subscriptionId: "sub-1", + }); }); it("mints nothing for a request that was never paid", async () => { @@ -283,7 +288,9 @@ describe("refundRejectedRequest", () => { expect(mockRefundBookingPayment).toHaveBeenCalledWith( expect.objectContaining({ paymentId: "pay-free-1" }), ); - expect(mockRefundBookingPayment.mock.calls[0][0].amountPaise).toBeUndefined(); + expect( + mockRefundBookingPayment.mock.calls[0][0].amountPaise, + ).toBeUndefined(); expect(result).toEqual({ refundPct: 100, amountRefundedPaise: 0 }); }); diff --git a/__tests__/payments/trial-cancellation-refund.test.ts b/__tests__/payments/trial-cancellation-refund.test.ts index 452e78c34..bcbbe73b5 100644 --- a/__tests__/payments/trial-cancellation-refund.test.ts +++ b/__tests__/payments/trial-cancellation-refund.test.ts @@ -90,7 +90,7 @@ const paidTrial = { function appointmentStartingInHours(hours: number) { return { - cancellationPolicySnapshot: null, + cancellationPolicy: null, slotsOfAppointment: [ { startsAt: new Date(Date.now() + hours * 3_600_000) }, ], diff --git a/app/api/appointments/[appointmentId]/cancel/preview/route.ts b/app/api/appointments/[appointmentId]/cancel/preview/route.ts index 3617a1bd4..431abf038 100644 --- a/app/api/appointments/[appointmentId]/cancel/preview/route.ts +++ b/app/api/appointments/[appointmentId]/cancel/preview/route.ts @@ -246,12 +246,18 @@ async function quoteIndividualBooking( // own code now, not a restatement of it. An unpaid booking quotes off zeros, // which the clamp turns into zero. const quote = quoteBookingRefund({ - policySnapshot: ctx.policySnapshot, + policy: ctx.policy, hoursUntilNextSession: ctx.hoursUntilNextSession, slotsTotal: ctx.slotsTotal, sessionsRemaining: ctx.sessionsRemaining, isSubscription: !!appointment.subscriptionId, isConsultantInitiated, + // #1500 — a booking funded entirely by referral credit. The rail alone is not + // enough: `free_` with a non-zero amount is a mixed payment and settles on the + // money arm, so both halves of the predicate are load-bearing. + isFreeCreditFunded: + fundingRailForIntent(bookingPayment?.paymentIntent) === "CREDITS" && + (ctx.paidPayment?.amountPaise ?? 0) === 0, grossPaise: ctx.paidPayment?.amountPaise ?? 0, refundablePaise: ctx.paidPayment?.refundablePaise ?? 0, }); @@ -259,6 +265,10 @@ async function quoteIndividualBooking( return { refundPct: quote.refundPct, estimatedRefundPaise: quote.refundPaise, + // #1500 — the dialog must say "your credit comes back in full" rather than show + // a ₹0 refund next to a 100% tier, which is what a credit-funded quote looks + // like when only the money fields are read. + creditRestoresInFull: quote.creditRestoresInFull, currency: bookingPayment?.currency ?? "INR", hoursUntilNextSession: ctx.hoursUntilNextSession, // Only true when proration actually moves the number — an untouched diff --git a/app/api/appointments/[appointmentId]/cancel/route.ts b/app/api/appointments/[appointmentId]/cancel/route.ts index 060167f4f..b6a2abb8a 100644 --- a/app/api/appointments/[appointmentId]/cancel/route.ts +++ b/app/api/appointments/[appointmentId]/cancel/route.ts @@ -501,12 +501,7 @@ export async function POST( * already exhausted" and "the gateway refused" — and the client was left * inferring failure from a positive `refundPct`, which is a guess. */ - status: - | "REFUNDED" - | "FAILED" - | "NOTHING_REFUNDABLE" - | "POLICY_ZERO" - | "MANUAL_REVIEW"; + status: "REFUNDED" | "FAILED" | "NOTHING_REFUNDABLE" | "POLICY_ZERO"; /** #1006 — set when the refund needs a human, not a formula. */ requiresManualReview?: boolean; /** @@ -532,10 +527,10 @@ export async function POST( consultantUserId === session.user.id) || (isPrivilegedUser && session.user.id !== consulteeUserId); // #1161 — a fully-credit-funded booking: its refund IS the credit - // restoration, all-or-nothing. Full restoration when the cancellation - // is not the buyer's choice or falls in a full-refund window; a - // payer-initiated late cancel escalates (partial credit restoration is - // an unmade product call — same residual as attendee-leave). + // restoration, all-or-nothing, because the credits rail refuses a partial + // amount. #1500 settled what a partial TIER means for such a booking: the + // quote rounds it up to a full restoration, and only a 0% tier returns + // nothing. const isFreeCreditFunded = paidPayment.amountPaise === 0 && paidPayment.paymentIntent.startsWith("free_"); @@ -546,12 +541,13 @@ export async function POST( // exists to tell the buyer what this click pays, so they must be one // function or the quote eventually stops matching the charge. const quote = quoteBookingRefund({ - policySnapshot: bookingCtx.policySnapshot, + policy: bookingCtx.policy, hoursUntilNextSession: bookingCtx.hoursUntilNextSession, slotsTotal: bookingCtx.slotsTotal, sessionsRemaining: bookingCtx.sessionsRemaining, isSubscription: !!appointment.subscription, isConsultantInitiated, + isFreeCreditFunded, grossPaise: paidPayment.amountPaise, refundablePaise: paidPayment.refundablePaise, }); @@ -560,53 +556,54 @@ export async function POST( // Credit-funded first: its refund is a credit restoration, which is // all-or-nothing, so the tiered amount above does not apply to it. - // (#1006's partly-consumed escalation used to branch here; the linear - // proration in `proratedBasePaise` replaced it — see the PR for why.) - if (isFreeCreditFunded) { - if (refundPct === 100) { - try { - const restored = await refundBookingPayment({ - paymentId: paidPayment.id, - reason: - "cancellation (credit-funded booking, full restoration)", - initiatedByUserId: session.user.id, - }); - refund = { - // Report what the restoration actually returned. Hardcoding 0 - // reintroduced the ambiguity this field exists to remove — the - // status says REFUNDED while the amount reads like the policy - // owed nothing. - amountRefundedPaise: restored.amountRefundedPaise, - refundPct: 100, - status: "REFUNDED", - requiresManualReview: false, - rail: restored.rail, - }; - } catch (freeErr) { - Sentry.captureException( - freeErr instanceof Error ? freeErr : new Error(String(freeErr)), - { tags: { subsystem: "bookings" } }, - ); - refund = { - amountRefundedPaise: 0, - refundPct: 100, - status: "FAILED", - requiresManualReview: true, - }; - } - } else { + // #1500 — every tier above 0% restores the credit IN FULL; the escalation + // to a human that used to sit on the partial branch is gone, because the + // product rule it was waiting for now exists. A 0% tier falls through to + // POLICY_ZERO below, so a late cancel bites a credit buyer exactly as it + // bites a card buyer. + if (quote.creditRestoresInFull) { + try { + const restored = await refundBookingPayment({ + paymentId: paidPayment.id, + reason: `cancellation (credit-funded booking, credit restored in full from the ${quote.tierRefundPct}% tier)`, + initiatedByUserId: session.user.id, + }); + refund = { + // Report what the restoration actually returned. Hardcoding 0 + // reintroduced the ambiguity this field exists to remove — the + // status says REFUNDED while the amount reads like the policy + // owed nothing. + amountRefundedPaise: restored.amountRefundedPaise, + refundPct: 100, + status: "REFUNDED", + requiresManualReview: false, + rail: restored.rail, + }; + } catch (freeErr) { + Sentry.captureException( + freeErr instanceof Error ? freeErr : new Error(String(freeErr)), + { tags: { subsystem: "bookings" } }, + ); + // #1513 review — the monetary branch below lands a failed refund on + // the durable ops surface, and this branch owes the same: a credit + // the buyer is owed but did not get back is money, and Sentry is an + // alert channel rather than a queue anyone works. await recordSystemError({ organizationId: appointment.organizationId ?? null, category: "PAYMENT", summary: - "Credit-funded booking cancelled inside a partial-refund window; partial credit restoration has no product rule yet (#1161)", - err: new Error("FREE_CREDIT_PARTIAL_RESTORATION_UNDEFINED"), - context: { appointmentId, paymentId: paidPayment.id, refundPct }, + "Credit-funded booking cancelled but the credit restoration failed", + err: freeErr, + context: { + appointmentId, + paymentId: paidPayment.id, + tierRefundPct: quote.tierRefundPct, + }, }).catch(() => {}); refund = { amountRefundedPaise: 0, - refundPct, - status: "MANUAL_REVIEW", + refundPct: 100, + status: "FAILED", requiresManualReview: true, }; } diff --git a/app/api/organizations/[orgId]/cancellation-policy/route.ts b/app/api/organizations/[orgId]/cancellation-policy/route.ts new file mode 100644 index 000000000..9698b7a1b --- /dev/null +++ b/app/api/organizations/[orgId]/cancellation-policy/route.ts @@ -0,0 +1,186 @@ +/** + * GET /api/organizations/[orgId]/cancellation-policy + * PUT /api/organizations/[orgId]/cancellation-policy + * + * An organization's refund ladder, as immutable versions (#1499). The ladder that + * an org publishes governs the bookings the org FUNDS: its money is what a refund + * moves, so its terms are the ones that bind. Personal bookings, and event seats on + * a shared webinar or class Appointment, use the platform ladder. + * + * There is no PATCH and no DELETE on purpose. A version is immutable because + * `Appointment.cancellationPolicyId` points at the exact row a booking was sold + * under, so an edit publishes a new version and archives the previous one, and + * "turn our policy off" means publishing the platform ladder as your own. + * + * Authorization mirrors where these terms live today. The free-text + * `defaultCancellationPolicy` is OWNER-only in the org PATCH, and MemberRole has no + * ADMIN — the admin-equivalent MAINTAINER cannot write policies today — so OWNER is + * the no-widening floor for the write. Reads follow the org settings surface, which + * is `settings.manage`. + */ + +import * as Sentry from "@sentry/nextjs"; +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; + +import prisma from "@/lib/prisma"; +import { withSerializableRetry } from "@/lib/db/serializable-retry"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; +import { + MAX_POLICY_TIERS, + PLATFORM_DEFAULT_TERMS, + validateTierLadder, +} from "@/lib/payments/operations/cancellation-policy"; +import { + POLICY_TERMS_INCLUDE, + publishOrgCancellationPolicy, + termsFromPolicyRow, +} from "@/lib/payments/operations/cancellation-policy-store"; + +const TierSchema = z.object({ + // A year of notice is already absurd for a consultation; the bound exists so a + // typo cannot publish a ladder whose top rung nothing ever clears. + hoursBefore: z.coerce.number().int().min(0).max(8760), + refundPct: z.coerce.number().min(0).max(100).multipleOf(0.01), +}); + +const PutBodySchema = z + .object({ + tiers: z.array(TierSchema).min(1).max(MAX_POLICY_TIERS), + consultantInitiatedPct: z.coerce + .number() + .min(0) + .max(100) + .multipleOf(0.01) + .default(100), + policyText: z.string().max(5000).nullable().optional(), + }) + // One ladder rule, shared with the publish helper and the seed, so the editor can + // never accept a ladder the quote cannot read. + .superRefine((body, ctx) => { + const invalid = validateTierLadder(body.tiers); + if (invalid) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: invalid, + path: ["tiers"], + }); + }); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { + permission: "settings.manage", + }); + if (access.error) return access.error; + + const row = await prisma.cancellationPolicy.findFirst({ + where: { organizationId: orgId, status: "ACTIVE" }, + orderBy: { version: "desc" }, + select: { ...POLICY_TERMS_INCLUDE.select, createdAt: true }, + }); + + return NextResponse.json({ + // Null means this org has never published, in which case the platform ladder + // applies to its bookings — the client says so rather than showing an empty form. + policy: row + ? { ...termsFromPolicyRow(row), createdAt: row.createdAt } + : null, + platformDefault: PLATFORM_DEFAULT_TERMS, + }); +} + +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { minimumRole: "OWNER" }); + if (access.error) return access.error; + + const raw = await req.json().catch(() => null); + const parsed = PutBodySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const body = parsed.data; + + try { + // Publishing is read-then-write (read the current version → archive the ACTIVE + // row → insert at version + 1). Under the default isolation two concurrent + // publishes both read version N and both insert N + 1; Serializable makes that + // interleaving abort and the retry re-runs the loser, exactly as the rate-card + // bump does. + const published = await withSerializableRetry(() => + prisma.$transaction( + async (tx) => { + const created = await publishOrgCancellationPolicy(tx, { + organizationId: orgId, + tiers: body.tiers, + consultantInitiatedPct: body.consultantInitiatedPct, + policyText: body.policyText ?? null, + publishedByUserId: access.member.userId, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "SETTINGS", + action: AUDIT_ACTIONS.SETTINGS.CANCELLATION_POLICY_PUBLISHED, + description: `Cancellation policy published at version ${created.version}`, + details: { + policyId: created.id, + version: created.version, + tiers: body.tiers, + consultantInitiatedPct: body.consultantInitiatedPct, + }, + }, + }); + + return created; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + ); + + return NextResponse.json( + { policy: termsFromPolicyRow(published) }, + { status: 201 }, + ); + } catch (err) { + if (err instanceof Error && "httpStatus" in err) { + const status = typeof err.httpStatus === "number" ? err.httpStatus : 500; + return NextResponse.json({ error: err.message }, { status }); + } + // Losing the (organizationId, version) unique means someone else published + // while this request was in flight. That is not a server fault: re-read the + // current version and publish again on top of it. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return NextResponse.json( + { + error: + "Another version of this policy was published while you were saving; re-read the current policy and try again", + code: "CANCELLATION_POLICY_VERSION_CONFLICT", + }, + { status: 409 }, + ); + } + Sentry.captureException( + err instanceof Error ? err : new Error(String(err)), + { tags: { subsystem: "enterprise" } }, + ); + throw err; + } +} diff --git a/app/dashboard/organization/[orgId]/settings/CancellationPolicyCard.tsx b/app/dashboard/organization/[orgId]/settings/CancellationPolicyCard.tsx new file mode 100644 index 000000000..4014444d5 --- /dev/null +++ b/app/dashboard/organization/[orgId]/settings/CancellationPolicyCard.tsx @@ -0,0 +1,309 @@ +"use client"; + +/** + * The org's refund ladder (#1499). + * + * A published version is immutable, so this card is not an editor of one row: it + * shows the version that is live, lets an OWNER compose the next one, and publishing + * archives the previous version. Bookings already sold keep the terms they were sold + * under, which is the whole reason the versions exist. + */ + +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { MAX_POLICY_TIERS } from "@/lib/payments/operations/cancellation-policy"; + +type TierRow = { hoursBefore: string; refundPct: string }; + +type PolicyTerms = { + policyId: string | null; + source: "PLATFORM" | "ORG"; + version: number; + tiers: { hoursBefore: number; refundPct: number }[]; + consultantInitiatedPct: number; + createdAt?: string; +}; + +type PolicyResponse = { + policy: PolicyTerms | null; + platformDefault: PolicyTerms; +}; + +const policyQueryKey = (orgId: string) => ["org-cancellation-policy", orgId]; + +async function fetchPolicy(orgId: string): Promise { + const res = await fetch(`/api/organizations/${orgId}/cancellation-policy`); + if (!res.ok) throw new Error("Failed to load the cancellation policy"); + return res.json(); +} + +function toRows(terms: PolicyTerms): TierRow[] { + return terms.tiers.map((tier) => ({ + hoursBefore: String(tier.hoursBefore), + refundPct: String(tier.refundPct), + })); +} + +export function CancellationPolicyCard({ orgId }: { orgId: string }) { + const queryClient = useQueryClient(); + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: policyQueryKey(orgId), + queryFn: () => fetchPolicy(orgId), + }); + + const [rows, setRows] = useState([]); + const [consultantInitiatedPct, setConsultantInitiatedPct] = useState("100"); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + // The form starts from whatever is live — the org's own version if it has one, + // otherwise the platform ladder its bookings are already governed by, so an OWNER + // edits the real terms rather than an empty table. + useEffect(() => { + if (!data) return; + const terms = data.policy ?? data.platformDefault; + setRows(toRows(terms)); + setConsultantInitiatedPct(String(terms.consultantInitiatedPct)); + }, [data]); + + const mutation = useMutation({ + mutationFn: async () => { + // #1513 review — every field is a string from a text input, and + // `Number("")` is 0. An OWNER who cleared a box and hit publish would have + // published an immutable version carrying a 0-hour, 0% rung they never + // typed, and immutable means there is no editing it back. Refuse instead. + const tiers = rows.map((row) => ({ + hoursBefore: row.hoursBefore.trim(), + refundPct: row.refundPct.trim(), + })); + const consultantPct = consultantInitiatedPct.trim(); + const isNumeric = (value: string) => + value !== "" && Number.isFinite(Number(value)); + const everyFieldFilled = + tiers.every( + (tier) => isNumeric(tier.hoursBefore) && isNumeric(tier.refundPct), + ) && isNumeric(consultantPct); + if (!everyFieldFilled) { + throw new Error( + "Every tier needs a notice period and a refund percentage", + ); + } + + const res = await fetch( + `/api/organizations/${orgId}/cancellation-policy`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tiers: tiers.map((tier) => ({ + hoursBefore: Number(tier.hoursBefore), + refundPct: Number(tier.refundPct), + })), + consultantInitiatedPct: Number(consultantPct), + }), + }, + ); + const body = await res.json(); + if (!res.ok) + throw new Error( + body.error || "Failed to publish the cancellation policy", + ); + return body; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: policyQueryKey(orgId) }); + queryClient.invalidateQueries({ queryKey: ["org-settings", orgId] }); + setError(null); + setSuccess(true); + setTimeout(() => setSuccess(false), 2500); + }, + onError: (err: Error) => { + setSuccess(false); + setError(err.message); + }, + }); + + // #1513 review — a failed load used to render nothing at all, so an OWNER on + // a flaky connection saw a settings page with no cancellation policy on it and + // no way to tell that from an organisation that has none. Say so, and offer + // the retry. + if (isError) { + return ( + + + Cancellation policy + + +

+ We could not load your cancellation policy. +

+ +
+
+ ); + } + + if (isLoading || !data) return null; + + const live = data.policy; + + return ( + + + Cancellation policy + + {live + ? `Version ${live.version} is live${ + live.createdAt + ? `, published on ${new Date(live.createdAt).toLocaleDateString()}` + : "" + }. It applies to the sessions this organisation funds.` + : "This organisation is using the platform default. Publishing your own ladder replaces it for the sessions you fund."}{" "} + Publishing replaces your policy for future bookings only. Bookings + already paid for keep the terms they were sold under. + + + +
+ {rows.map((row, index) => ( +
+
+ + + setRows((current) => + current.map((existing, i) => + i === index + ? { ...existing, hoursBefore: event.target.value } + : existing, + ), + ) + } + /> +
+
+ + + setRows((current) => + current.map((existing, i) => + i === index + ? { ...existing, refundPct: event.target.value } + : existing, + ), + ) + } + /> +
+ +
+ ))} +

+ The last tier must start at 0 hours, so every cancellation is + covered by one of the rungs above. +

+
+ + + +
+ + setConsultantInitiatedPct(event.target.value)} + /> +
+ + {error &&

{error}

} + {success && ( +

+ Published. New bookings will be sold under this version. +

+ )} +
+ + + +
+ ); +} diff --git a/app/dashboard/organization/[orgId]/settings/GeneralPanel.tsx b/app/dashboard/organization/[orgId]/settings/GeneralPanel.tsx index ae6f83be2..ab4a97227 100644 --- a/app/dashboard/organization/[orgId]/settings/GeneralPanel.tsx +++ b/app/dashboard/organization/[orgId]/settings/GeneralPanel.tsx @@ -21,6 +21,7 @@ import type { } from "@prisma/client"; import { useOrgRole, useRequireOrgAccess } from "../useOrgRole"; +import { CancellationPolicyCard } from "./CancellationPolicyCard"; import { orgDetailsQueryKey } from "@/lib/api/organizations/org-details"; import { PanelHeader } from "@/components/dashboard/PageScaffold"; import { Button } from "@/components/ui/button"; @@ -825,6 +826,11 @@ export function GeneralPanel({ orgId }: { orgId: string }) { )} + {/* #1499 — the org's refund ladder. OWNER-only, matching the free-text + defaultCancellationPolicy field in the org PATCH: MemberRole has no + ADMIN, so OWNER is the narrowest role that can already write policy. */} + {isAtLeast("OWNER") && } + !open && setPendingDisable(null)} diff --git a/components/appointments/consultee/useEventActions.ts b/components/appointments/consultee/useEventActions.ts index 463e25841..c83550f70 100644 --- a/components/appointments/consultee/useEventActions.ts +++ b/components/appointments/consultee/useEventActions.ts @@ -62,13 +62,14 @@ function rescheduleOutcomeToast(outcome: { type CancelRefund = { amountRefundedPaise: number; refundPct: number; - status?: - | "REFUNDED" - | "FAILED" - | "NOTHING_REFUNDABLE" - | "POLICY_ZERO" - | "MANUAL_REVIEW"; + status?: "REFUNDED" | "FAILED" | "NOTHING_REFUNDABLE" | "POLICY_ZERO"; requiresManualReview?: boolean; + /** + * Which rail returned the money. The cancel route has answered this since + * #1325 and the toast ignored it, so an org-funded learner — whose card was + * never charged — was told a refund was on its way back to them. + */ + rail?: "GATEWAY" | "INTERNAL" | "CREDITS"; } | null; function describeRefund(refund: CancelRefund): string { @@ -79,8 +80,6 @@ function describeRefund(refund: CancelRefund): string { // equally "the policy owes nothing", "the balance was already exhausted" and // "the gateway refused", and only one of those deserves an apology. switch (refund.status) { - case "MANUAL_REVIEW": - return "Because sessions had already been delivered, our team is reviewing your refund and will be in touch."; case "FAILED": return "We could not complete your refund automatically — our team has been alerted and will sort it out."; case "NOTHING_REFUNDABLE": @@ -92,6 +91,21 @@ function describeRefund(refund: CancelRefund): string { break; } + // An org-funded booking reverses in the ledger against the org's wallet, + // invoice or licence — the learner's card was never charged, so "on its way + // back to you" is a promise nobody kept. Checked before the credit sentence so + // an internal reversal can never be described as a referral credit. + if (refund.rail === "INTERNAL") { + return "The refund goes back to your organisation's account."; + } + + // #1500 — a credit-funded booking settles as a REFUNDED restoration that moves no + // gateway money, so the amount is legitimately zero and the sentence has to come + // from the status rather than the number. + if (refund.status === "REFUNDED" && refund.amountRefundedPaise === 0) { + return "Your referral credit has been restored in full."; + } + if (refund.amountRefundedPaise > 0) { const rupees = (refund.amountRefundedPaise / 100).toLocaleString("en-IN", { maximumFractionDigits: 2, @@ -197,9 +211,7 @@ export function useEventActions({ const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: Object.keys(payload).length - ? JSON.stringify(payload) - : undefined, + body: Object.keys(payload).length ? JSON.stringify(payload) : undefined, }); const data = await response.json(); diff --git a/docs/booking/08-cancellation-flow.md b/docs/booking/08-cancellation-flow.md index a66a9f69c..9783bbabb 100644 --- a/docs/booking/08-cancellation-flow.md +++ b/docs/booking/08-cancellation-flow.md @@ -99,15 +99,15 @@ flowchart TD Cancellation is authorized at the API layer, not merely hidden in the UI. Here is what the route checks: -| Check | Performed? | Details | -| ------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------- | -| Is the user authenticated? | Yes | `getSession()` must return a valid session, or the route answers 401 | -| Is the user a participant? | Yes | The consultant on the plan or the consultee who requested it, or the route answers 403 | -| Is the user privileged? | Yes | `isPrivileged(session.user.role)` bypasses the participant check for admin and staff | -| Is the user an admin of the funding org? | Yes | `isOrgAdminOfAppointment()` admits an admin of the organization funding the booking (#1166) | -| Who may cancel a group event? | Organiser | Only the consultant who owns the webinar or class plan; attendees cannot cancel the event | -| Is the booking in a cancellable state? | Yes | The allowed-from set rides the `UPDATE`'s `WHERE`, so a lost race answers 409 | -| Is a payment dispute open? | Yes | An open dispute answers 409, because a refund now could pay the customer twice | +| Check | Performed? | Details | +| ---------------------------------------- | ---------- | ------------------------------------------------------------------------------------------- | +| Is the user authenticated? | Yes | `getSession()` must return a valid session, or the route answers 401 | +| Is the user a participant? | Yes | The consultant on the plan or the consultee who requested it, or the route answers 403 | +| Is the user privileged? | Yes | `isPrivileged(session.user.role)` bypasses the participant check for admin and staff | +| Is the user an admin of the funding org? | Yes | `isOrgAdminOfAppointment()` admits an admin of the organization funding the booking (#1166) | +| Who may cancel a group event? | Organiser | Only the consultant who owns the webinar or class plan; attendees cannot cancel the event | +| Is the booking in a cancellable state? | Yes | The allowed-from set rides the `UPDATE`'s `WHERE`, so a lost race answers 409 | +| Is a payment dispute open? | Yes | An open dispute answers 409, because a refund now could pay the customer twice | **What this means in practice**: the two parties to a booking, a platform admin, or an admin of the organization that funds the booking can cancel it. A group event can only be cancelled by its organiser, since cancelling it ends the session for everyone enrolled. @@ -196,39 +196,40 @@ The key insight: **only a valid JSON body that fails Zod validation returns an e } ``` -| Field | Type | Description | -| -------------------- | --------------------- | -------------------------------------------------- | -| `success` | `boolean` | Always `true` on 200 | -| `cancellationReason` | `string \| undefined` | The reason if one was provided | -| `cancelledAt` | `string` (ISO 8601) | Timestamp of when the cancellation was processed | -| `webinarId` | `string \| null` | The webinar ID if this was a webinar cancellation | -| `classId` | `string \| null` | The class ID if this was a class cancellation | -| `refund` | `object \| null` | The 1:1 policy refund outcome; `null` when the booking carried no payment | +| Field | Type | Description | +| -------------------- | --------------------- | ------------------------------------------------------------------------------- | +| `success` | `boolean` | Always `true` on 200 | +| `cancellationReason` | `string \| undefined` | The reason if one was provided | +| `cancelledAt` | `string` (ISO 8601) | Timestamp of when the cancellation was processed | +| `webinarId` | `string \| null` | The webinar ID if this was a webinar cancellation | +| `classId` | `string \| null` | The class ID if this was a class cancellation | +| `refund` | `object \| null` | The 1:1 policy refund outcome; `null` when the booking carried no payment | | `eventRefund` | `object \| null` | The whole-event fan-out summary; `null` outside class and webinar cancellations | The `webinarId` and `classId` fields are populated only for webinar and class cancellations. They are included in the client response so the frontend can trigger any UI updates related to the specific event. The `refund` object never reports the money as a bare number, because `amountRefundedPaise: 0` on its own is ambiguous — it reads identically whether the policy owed nothing, the balance was already exhausted, or the gateway refused. The `status` field disambiguates all of those. -| `refund.status` | Meaning | -| --------------------- | ------------------------------------------------------------------------------------ | -| `REFUNDED` | Money (or credit) was returned; `amountRefundedPaise` is what actually moved | -| `POLICY_ZERO` | The tier for this much notice is genuinely zero, so nothing was owed | -| `NOTHING_REFUNDABLE` | The tier was positive but the payment's refundable balance was already exhausted | -| `FAILED` | A refund was owed and the attempt threw; the cancellation still stands | -| `MANUAL_REVIEW` | The one case a formula cannot settle — a credit-funded booking cancelled inside a partial-refund window (#1161) | +| `refund.status` | Meaning | +| -------------------- | -------------------------------------------------------------------------------- | +| `REFUNDED` | Money (or credit) was returned; `amountRefundedPaise` is what actually moved | +| `POLICY_ZERO` | The tier for this much notice is genuinely zero, so nothing was owed | +| `NOTHING_REFUNDABLE` | The tier was positive but the payment's refundable balance was already exhausted | +| `FAILED` | A refund was owed and the attempt threw; the cancellation still stands | + +A credit-funded booking reports `REFUNDED` with an `amountRefundedPaise` of zero, because the restoration returns referral credit rather than gateway money. The client reads that pair as "your referral credit has been restored in full" rather than as a refund of nothing. There is no longer a `MANUAL_REVIEW` status: #1500 settled the product question that used to produce it, and the section on the credit rule below explains what replaced it. ### Error Responses -| Status | Cause | Response Body | When It Happens | -| ------ | ----------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------- | -| 401 | No session / expired session | `{ "error": "Unauthorized" }` | `getSession()` returns null or no user | -| 400 | Zod validation fails on a valid JSON body | `{ "error": "Validation failed", "details": [] }` | Body is valid JSON but fails schema validation | -| 403 | Caller is not entitled to cancel | `{ "error": "You are not authorized to cancel this appointment" }` | Not a participant, not privileged, not an org admin of the funder | -| 404 | Appointment not found in database | `{ "error": "Appointment not found" }` | ID does not match any appointment row | -| 409 | Open payment dispute | `{ "error": "...", "code": "DISPUTE_ACTIVE" }` | A dispute is live on this appointment (#1008) | -| 409 | Booking is no longer cancellable | `{ "error": "...", "code": "NOT_CANCELLABLE" }` | The CAS update matched zero rows — already cancelled, completed or expired | -| 500 | Transaction failure or unexpected error | `{ "error": "Failed to cancel appointment" }` | Database error, connection timeout, or other | +| Status | Cause | Response Body | When It Happens | +| ------ | ----------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| 401 | No session / expired session | `{ "error": "Unauthorized" }` | `getSession()` returns null or no user | +| 400 | Zod validation fails on a valid JSON body | `{ "error": "Validation failed", "details": [] }` | Body is valid JSON but fails schema validation | +| 403 | Caller is not entitled to cancel | `{ "error": "You are not authorized to cancel this appointment" }` | Not a participant, not privileged, not an org admin of the funder | +| 404 | Appointment not found in database | `{ "error": "Appointment not found" }` | ID does not match any appointment row | +| 409 | Open payment dispute | `{ "error": "...", "code": "DISPUTE_ACTIVE" }` | A dispute is live on this appointment (#1008) | +| 409 | Booking is no longer cancellable | `{ "error": "...", "code": "NOT_CANCELLABLE" }` | The CAS update matched zero rows — already cancelled, completed or expired | +| 500 | Transaction failure or unexpected error | `{ "error": "Failed to cancel appointment" }` | Database error, connection timeout, or other | The two 409s are told apart by their `code`, not their message, and both are terminal for that request rather than retryable. `DISPUTE_ACTIVE` is checked before the transaction opens, because refunding while a chargeback is contested would pay the customer twice. Nothing is written on either path, so a refused cancellation leaves the booking exactly as it was. @@ -732,17 +733,17 @@ flowchart TD ### Comparison Table: All Four Event Types -| Aspect | Consultation | Subscription | Webinar | Class | -| -------------------------------- | --------------- | --------------- | --------- | -------- | -| **Model updated** | `Consultation` | `Subscription` | `Webinar` | `Class` | -| **Status field** | `status` | `status` | `status` | `status` | -| **Audit fields stored** | Yes (5 fields) | Yes (5 fields) | No | No | -| **Cancellation reason on model** | Yes | Yes | No | No | -| **Cancelled-by tracking** | Yes | Yes | No | No | -| **Notification sent** | Yes | Yes | Yes | Yes | -| **Slots deleted** | No — marked `CANCELLED` | No — marked `CANCELLED` | No — marked `CANCELLED` | No — marked `CANCELLED` | -| **Appointment deleted** | No — preserved | No — preserved | No — preserved | No — preserved | -| **Slot scope of the update** | This appointment | Whole `subscriptionId` | This appointment | Whole `classId` | +| Aspect | Consultation | Subscription | Webinar | Class | +| -------------------------------- | ------------------------ | -------------------------------- | ---------------------------- | ---------------------------- | +| **Model updated** | `Consultation` | `Subscription` | `Webinar` | `Class` | +| **Status field** | `status` | `status` | `status` | `status` | +| **Audit fields stored** | Yes (5 fields) | Yes (5 fields) | No | No | +| **Cancellation reason on model** | Yes | Yes | No | No | +| **Cancelled-by tracking** | Yes | Yes | No | No | +| **Notification sent** | Yes | Yes | Yes | Yes | +| **Slots deleted** | No — marked `CANCELLED` | No — marked `CANCELLED` | No — marked `CANCELLED` | No — marked `CANCELLED` | +| **Appointment deleted** | No — preserved | No — preserved | No — preserved | No — preserved | +| **Slot scope of the update** | This appointment | Whole `subscriptionId` | This appointment | Whole `classId` | | **Refund rail** | Policy tier on the gross | Policy tier on the prorated base | Whole-event fan-out, in full | Whole-event fan-out, in full | ### Consultation Cancellation (Detailed) @@ -803,14 +804,12 @@ The group-event allowed-from sets are written as explicit `in` lists rather than **Why no audit fields**: Webinars are typically cancelled by the consultant (the host). Since webinars are group events, the system does not track individual cancellation reasons on the event model. The `status` change is sufficient for the event lifecycle. If audit data is needed, it can be reconstructed from the API logs and the `cancelledBy` information in the notification payload. - ### Class Cancellation (Detailed) Classes are multi-session group events (e.g., "6-week Python bootcamp"). They behave identically to webinars for cancellation purposes. **What gets written to the database**: Only `status: "CANCELLED"`, same as webinar. - ### State Transition Diagram ```mermaid @@ -890,18 +889,18 @@ Green marks a row that comes through untouched, amber a row whose status column ### Record-by-Record Breakdown -| Record | Before | After | Why | -| ------------------------------ | --------------------------------- | -------------------------------------------- | -------------------------------------------------------------------- | -| `Appointment` | Exists with type and foreign keys | **Preserved**, untouched | `Payment.appointment` cascades on delete, so removing it would destroy the money trail (#1074) | -| `SlotOfAppointment` (live) | `completionStatus` is `SCHEDULED` or `RESCHEDULED` | `completionStatus = "CANCELLED"` | The time is released by status; the record of it having been held survives | -| `SlotOfAppointment` (terminal) | Already `COMPLETED`/`CANCELLED`/`UNVERIFIED` | Untouched | A delivered session is history, and the proration denominator counts it | -| `RescheduleRequest` (open) | `PENDING_REVIEW` or similar | `status = "DECLINED"`, `openForAppointmentId` cleared | An open proposal would reserve the appointment forever and feed the expiry cron | -| `Consultation` (if applicable) | `status = "APPROVED"` | `status = "CANCELLED"` + audit fields | Preserved for refund decisions and analytics | -| `Subscription` (if applicable) | `status = "APPROVED"` | `status = "CANCELLED"` + audit fields | Same reasoning as consultation | -| `Webinar` (if applicable) | `status = "PUBLISHED"` | `status = "CANCELLED"` | Preserved but with minimal state change | -| `Class` (if applicable) | `status = "PUBLISHED"` | `status = "CANCELLED"` | Same reasoning as webinar | -| `Payment` / `PaymentOrder` | Various statuses | Preserved; a `Refund` row is added when due | The policy frozen at checkout decides the amount, not an admin | -| `Earning` / `PayoutItem` | May exist if payment was captured | Refunded share incremented by the cascade | Earnings reversal rides the same transaction as the refund | +| Record | Before | After | Why | +| ------------------------------ | -------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `Appointment` | Exists with type and foreign keys | **Preserved**, untouched | `Payment.appointment` cascades on delete, so removing it would destroy the money trail (#1074) | +| `SlotOfAppointment` (live) | `completionStatus` is `SCHEDULED` or `RESCHEDULED` | `completionStatus = "CANCELLED"` | The time is released by status; the record of it having been held survives | +| `SlotOfAppointment` (terminal) | Already `COMPLETED`/`CANCELLED`/`UNVERIFIED` | Untouched | A delivered session is history, and the proration denominator counts it | +| `RescheduleRequest` (open) | `PENDING_REVIEW` or similar | `status = "DECLINED"`, `openForAppointmentId` cleared | An open proposal would reserve the appointment forever and feed the expiry cron | +| `Consultation` (if applicable) | `status = "APPROVED"` | `status = "CANCELLED"` + audit fields | Preserved for refund decisions and analytics | +| `Subscription` (if applicable) | `status = "APPROVED"` | `status = "CANCELLED"` + audit fields | Same reasoning as consultation | +| `Webinar` (if applicable) | `status = "PUBLISHED"` | `status = "CANCELLED"` | Preserved but with minimal state change | +| `Class` (if applicable) | `status = "PUBLISHED"` | `status = "CANCELLED"` | Same reasoning as webinar | +| `Payment` / `PaymentOrder` | Various statuses | Preserved; a `Refund` row is added when due | The policy frozen at checkout decides the amount, not an admin | +| `Earning` / `PayoutItem` | May exist if payment was captured | Refunded share incremented by the cascade | Earnings reversal rides the same transaction as the refund | ### Why Nothing is Deleted @@ -1020,29 +1019,46 @@ For webinars and classes the organiser is read off the plan and every paid atten **Notification payload**: -| Field | Value | Source | -| ----------------- | ------------------------------------------------------------- | ------------------------------------------------------------ | -| `appointmentType` | `"CONSULTATION"`, `"SUBSCRIPTION"`, `"WEBINAR"`, or `"CLASS"` | `appointment.appointmentType` | -| `consultantName` | e.g., `"Bob Smith"` or `"Consultant"` (fallback) | Extracted in Phase 1, with fallback | -| `consulteeName` | e.g., `"Alice Johnson"` or `"Consultee"` (fallback) | Extracted in Phase 1, with fallback | -| `planTitle` | e.g., `"Career Strategy Session"` or `"N/A"` (fallback) | From consultation/subscription plan | -| `dateTime` | ISO 8601 string or `undefined` | `slotsOfAppointment[0].startsAt` | -| `dashboardUrl` | An org or personal appointments href | `notificationHref(appointment.organizationId, "appointments")` — both parties share one payload, so the href must suit either | -| `reason` | e.g., `"SCHEDULE_CONFLICT"` or `undefined` | From validated body | +| Field | Value | Source | +| ----------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `appointmentType` | `"CONSULTATION"`, `"SUBSCRIPTION"`, `"WEBINAR"`, or `"CLASS"` | `appointment.appointmentType` | +| `consultantName` | e.g., `"Bob Smith"` or `"Consultant"` (fallback) | Extracted in Phase 1, with fallback | +| `consulteeName` | e.g., `"Alice Johnson"` or `"Consultee"` (fallback) | Extracted in Phase 1, with fallback | +| `planTitle` | e.g., `"Career Strategy Session"` or `"N/A"` (fallback) | From consultation/subscription plan | +| `dateTime` | ISO 8601 string or `undefined` | `slotsOfAppointment[0].startsAt` | +| `dashboardUrl` | An org or personal appointments href | `notificationHref(appointment.organizationId, "appointments")` — both parties share one payload, so the href must suit either | +| `reason` | e.g., `"SCHEDULE_CONFLICT"` or `undefined` | From validated body | | `cancelledBy` | `"consultant"`, `"consultee"`, or `"system"` | Three-way: the consultant's user ID, then the consultee's, then `system` for a platform or org actor and for group events, which have no consultee | **Why fire-and-forget**: The notification is a courtesy, not a critical operation. If Novu is down for 5 minutes, the cancellation should still succeed. The user can always check their dashboard. Making the notification blocking would mean a Novu outage causes cancellation failures, which would be unacceptable. ### Refund and Earnings -**Refunds are automatic.** The judgement that used to be left to an admin is now encoded in the cancellation policy that was frozen onto the booking when the buyer paid, so an org or the platform editing its terms later never changes a buyer's deal retroactively. +**Refunds are automatic.** The judgement that used to be left to an admin is now encoded in the cancellation policy the booking was sold under, so an organisation or the platform editing its terms later never changes a buyer's deal retroactively. Since #1499 those terms are a typed, versioned row rather than a Json snapshot: the booking points at the exact `CancellationPolicy` version that governed the sale, and publishing an edit creates a new version instead of rewriting the one that bookings already cite. -When a paid consultation or subscription is cancelled, `resolveBookingRefundContext()` (`lib/booking/cancellation-scope.ts`) resolves the refund facts for the **whole booking** rather than for the single appointment the route was handed, because a subscription is one slot-less placeholder that carries the money plus one appointment per allocated session. It answers six questions: which payment funds the booking, which policy snapshot was frozen on the row the buyer paid for, how many hours remain until the earliest undelivered session, how many sessions have already been delivered, how many are still owed, and how many slots the booking holds in total regardless of status. +When a paid consultation or subscription is cancelled, `resolveBookingRefundContext()` (`lib/booking/cancellation-scope.ts`) resolves the refund facts for the **whole booking** rather than for the single appointment the route was handed, because a subscription is one slot-less placeholder that carries the money plus one appointment per allocated session. It answers six questions: which payment funds the booking, which policy version governs the row the buyer paid for, how many hours remain until the earliest undelivered session, how many sessions have already been delivered, how many are still owed, and how many slots the booking holds in total regardless of status. The policy question is answered as terms rather than as a nullable row: a booking that points at no version is governed by the platform ladder, so `resolveBookingRefundContext` resolves that fallback once and no caller downstream has to remember it. The last three exist because of the proration described below; of them the cancel route reads `sessionsRemaining` and `slotsTotal`, while `sessionsCompleted` is exposed for other callers and for diagnostics. `slotsTotal` is deliberately a count of slots of **any** status rather than of the completed and live ones summed. Summing those two would drop every terminal-but-not-completed session out of the plan and measure the undelivered share against a plan that had shrunk: a subscription with three `UNVERIFIED` past sessions and seven live ones would score seven-sevenths and refund the whole price for a plan that was already thirty per cent consumed. A cancellation the consultant initiates always settles at the policy's consultant-initiated percentage — one hundred per cent under the platform defaults — because the buyer did nothing wrong. "Not the buyer's choice" is the real question that tier asks, so a platform admin cancelling on the buyer's behalf counts as consultant-initiated too; requiring the actor to literally _be_ the consultant once meant an admin cancelling in the final hours settled an unwilling buyer at zero per cent. +#### Where the tiers come from + +The ladder is two tables. `CancellationPolicy` is one published version of a policy, and `CancellationPolicyTier` holds its rungs. A version carries the scope it belongs to, its version number, its status, and the percentage that a consultant-initiated cancellation always settles at; a rung carries the notice threshold and the refund percentage for it. Both percentages are stored as basis points, following the repo convention that money and splits are integers, and the API and the UI speak whole percent. + +| Scope | Which row | Who may publish it | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| Platform | The single row whose `organizationId` is null, at a fixed id. The seed creates it, and `ensurePlatformCancellationPolicy` creates it idempotently on first use so a database nobody seeded still quotes refunds. | Nobody through the product; it is changed in the seed and in code. | +| Organisation | The organisation's newest `ACTIVE` version. An organisation that has never published has no row, and its bookings use the platform ladder. | An OWNER, through `PUT /api/organizations/{orgId}/cancellation-policy`. | + +The platform default remains one hundred per cent a day out, fifty per cent inside the day, and nothing inside two hours, with consultant-initiated cancellations always settling in full. + +A published version is immutable. Editing an organisation's ladder does not update the row; it archives the current `ACTIVE` version and inserts a new one at the next version number, inside a Serializable transaction so two concurrent publishes cannot both claim the same number. That is what makes the guarantee in the paragraph above structural rather than conventional: `Appointment.cancellationPolicyId` points at the exact row that governed the sale, and nothing in the product can rewrite that row afterwards. There is deliberately no endpoint to edit or delete a version, and "stop using our own policy" means publishing the platform ladder as your own. + +Checkout resolves the version once, inside the booking transaction, and stamps it on the appointment. An organisation's ladder governs the bookings that **organisation funds**, because on a refund it is the organisation's money that moves; a personal booking that merely carries an organisation tag keeps the platform ladder. Sessions that a consultant allocates later against a subscription inherit the version from the row checkout created, rather than resolving a fresh one, since resolving again would hand the buyer whatever ladder was published since they paid. + +Webinar and class seats are the documented exception. One shared `Appointment` row serves every registrant of an event, so it cannot carry one buyer's terms; its policy pointer stays null and the platform ladder applies, which is what whole-event refunds already assumed. Organisation tiers therefore do not reach event seats. If they ever need to, the seat rather than the event has to become the row that carries the terms. + #### How the amount is computed The refund is two multiplications, in this order: the **base** is narrowed to what the buyer has not yet received, and the **policy tier** then applies to that base. @@ -1070,7 +1086,11 @@ The refund runs after the cancellation transaction commits, and a failure to ref **A partially-consumed subscription no longer escalates.** Earlier revisions of this route had no agreed proration rule, so a subscription that had already delivered sessions was not refunded automatically at all: the response carried `requiresManualReview`, the buyer was told their refund was under review, and a durable `SystemEvent` was recorded for an operator to settle by hand. That escalation is gone. #1006 is **closed**, and the linear per-session proration above replaced it — a partly-consumed plan now settles by formula like any other. -One case does still escalate, and it is a narrow one. A fully credit-funded booking — `Payment.amount` of zero against a `free_` intent — has no amount to tier, because its refund _is_ the restoration of the credits, which is all-or-nothing. When the policy returns one hundred per cent the credits are restored in full. When it returns anything else, the booking has landed in a partial-refund window where partial credit restoration is an unmade product call: the route records a `SystemEvent` in the `PAYMENT` category, answers `status: "MANUAL_REVIEW"` with `requiresManualReview: true`, and leaves the money for an operator. This is the only surviving path to `MANUAL_REVIEW`, and it is tracked in #1161. +**A credit-funded booking no longer escalates either.** A booking paid entirely with referral or free credit — `Payment.amount` of zero against a `free_` intent — has no amount to tier, because its refund _is_ the restoration of the credits, and the credits rail restores the whole credit or none of it. `refundBookingPayment` refuses an `amountPaise` on that rail for exactly this reason, so a partial tier had nothing it could pay. Earlier revisions escalated that case to an operator; #1500 replaced the escalation with a rule. + +The rule has two halves, and the second half is the one that keeps it fair. **Any tier above zero per cent restores the credit in full**, because rounding a partial tier up to a whole credit is the only settlement the rail can express and the buyer gave notice the ladder rewards. **A zero-per-cent tier restores nothing**, which is the same answer a card buyer gets for the same notice — a late cancel bites a credit buyer exactly as it bites everyone else, and treating "the rail cannot pay a fraction" as "therefore pay everything" would have made free credit strictly better than money. + +Both halves live in `quoteBookingRefund`, in one predicate: `isFreeCreditFunded && refundPct > 0`. The quote reports the tier the ladder actually answered on `tierRefundPct` and the settlement on `refundPct`, so the refund reason can name the real tier while the buyer is told they were made whole. The zero case falls through to the ordinary `POLICY_ZERO` arm, so nothing about it is special-cased. The predicate requires both a `free_` intent and a zero amount, because a `free_` intent carrying a non-zero amount is a mixed payment that settles on the money arm; that combination is out of scope here and is refused with `INVALID_AMOUNT` as it always was. A booking with no session scheduled at all is a different matter and does refund in full. There is no start time, so there are no hours of notice, and treating that as negative notice made cancelling before allocation score worse than cancelling after it — which no tier table can mean. The condition is keyed on the booking having no slot rows whatsoever, deliberately: a booking whose slots are merely all cancelled is not the same claim. @@ -1189,18 +1209,18 @@ Developers frequently ask: "What is the difference between cancelling and resche ### Side-by-Side Comparison -| Aspect | Cancellation | Reschedule | -| -------------------------------- | -------------------------------- | ---------------------------------------- | -| **Intent** | End the booking entirely | Move the booking to a different time | -| **Appointment record** | Preserved (money rows hang off it) | Preserved (slots are swapped) | -| **Event record** | Status set to `CANCELLED` | Status unchanged (remains `APPROVED`) | -| **Slot records** | Marked `CANCELLED` | Replaced slots flipped to `RESCHEDULED`, new times re-confirmed in place | -| **Payment** | Preserved; refunded per policy | Untouched (no additional charge) | -| **Refund triggered** | Yes, automatically per policy | No | -| **Notifications** | "Your appointment was cancelled" | "Your appointment was rescheduled" | -| **Audit fields written** | Yes (consultation/subscription) | Different fields (rescheduled timestamp) | -| **Can happen after event start** | Allowed; the tier simply pays zero | Refused — the deadline is `min(now + 72h, earliest session − 24h)` | -| **Reversible** | No (must rebook from scratch) | Yes (can reschedule again) | +| Aspect | Cancellation | Reschedule | +| -------------------------------- | ---------------------------------- | ------------------------------------------------------------------------ | +| **Intent** | End the booking entirely | Move the booking to a different time | +| **Appointment record** | Preserved (money rows hang off it) | Preserved (slots are swapped) | +| **Event record** | Status set to `CANCELLED` | Status unchanged (remains `APPROVED`) | +| **Slot records** | Marked `CANCELLED` | Replaced slots flipped to `RESCHEDULED`, new times re-confirmed in place | +| **Payment** | Preserved; refunded per policy | Untouched (no additional charge) | +| **Refund triggered** | Yes, automatically per policy | No | +| **Notifications** | "Your appointment was cancelled" | "Your appointment was rescheduled" | +| **Audit fields written** | Yes (consultation/subscription) | Different fields (rescheduled timestamp) | +| **Can happen after event start** | Allowed; the tier simply pays zero | Refused — the deadline is `min(now + 72h, earliest session − 24h)` | +| **Reversible** | No (must rebook from scratch) | Yes (can reschedule again) | ### Decision Guide: When to Cancel vs Reschedule diff --git a/docs/booking/17-org-funded-checkout.md b/docs/booking/17-org-funded-checkout.md index 8f8c7375a..f1bc08ce4 100644 --- a/docs/booking/17-org-funded-checkout.md +++ b/docs/booking/17-org-funded-checkout.md @@ -75,19 +75,32 @@ One nuance matters on this rail: at that point the **booking transaction has alr An org-funded booking carries a synthetic `org_*` intent that no gateway can resolve, so its refund is **internal**: `refundBookingPayment()` (`lib/payments/operations/booking-refund.ts`) splits the rails on `isInternalFundedIntent()` — gateway/mock money goes through the two-phase gateway refund, org-funded money reverses purely in-ledger through the reversal engine (wallet credited back, invoice accrual netted by a `*_REVERSAL` leg, license engagements restored). Event-wide refunds use `refundWholeEventPayments()` (`lib/payments/operations/event-refunds.ts`), which made the same split first. The full cascade, including the org audit rows it writes, is in the funding-seam doc and [08-cancellation-flow.md](./08-cancellation-flow.md). +## Whose cancellation policy applies + +An organisation may publish its own refund ladder, and that ladder governs the bookings the organisation **funds** — the WALLET, INVOICE and LICENSE rails described above, plus any other path where `isOrgSponsoredPayment` is true. The reasoning is the same one that decides every other question on this page: on a cancellation it is the organisation's money that comes back, so the organisation's terms are the ones that bind. A booking a member pays for personally keeps the platform ladder even when it is tagged to an organisation, because the refund settles to the member. + +`handleCheckout()` resolves the governing version exactly once, inside the booking transaction, by calling `resolveCheckoutCancellationPolicyId()` with the organisation id on the sponsored path and null everywhere else. The resolver answers the organisation's newest `ACTIVE` version, and falls back to the platform row when the organisation has never published one. The resulting id is stamped on the appointment as `cancellationPolicyId`, so the terms a booking was sold under survive any later edit: publishing a new ladder archives the old version rather than rewriting it. Sessions allocated later against a subscription inherit the id from the row checkout created rather than resolving again. + +One case falls back to the platform ladder by construction. A webinar or class has a single shared `Appointment` row for the whole event, across every registrant and therefore across every funding organisation, so no one organisation's terms can be stamped on it. Its policy pointer stays null, the platform ladder applies, and organisation tiers do not reach event seats. Whole-event refunds already assumed the platform terms, so this is consistent rather than merely tolerated, but it is a real limitation: reaching event seats would require the participant row rather than the event row to carry the terms. + +The editor is `PUT /api/organizations/{orgId}/cancellation-policy`, restricted to an OWNER because the free-text `defaultCancellationPolicy` on the organisation is already OWNER-only and `MemberRole` has no ADMIN tier to widen to. Reading the policy needs `settings.manage`, matching the rest of the organisation settings surface. + +--- + --- ## Where to look The pointers below are the fastest paths into the code and the neighboring docs. They cite the file and the function or flag name, not a line number — `checkout.ts` has drifted 500-700 lines since this document was first line-cited, and will keep drifting. -| Concern | Location | -| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Resolution chain + gates | `lib/payments/operations/checkout.ts`, inside `handleCheckout()` | -| ADR 18 allowlist/exclusivity (in-lock) | `lib/payments/operations/checkout.ts`, the `revalidateInsideLock()` function; `docs/enterprise/70-design-decisions/18-open-b2b-b2c-boundary.md` | -| Gateway skip + synthetic ids | `lib/payments/operations/checkout.ts`, the `isOrgWalletPayment` / `isOrgInvoicedPayment` / `isOrgLicensedPayment` / `isOrgSponsoredPayment` booleans and the `skipPayment` flag | -| Wallet debit (atomic conditional updateMany) | `lib/api/organizations/wallet.ts`, `walletDebit()` | -| Engagement debits + caps | `recordBookingUtilization()` in `lib/api/organizations/program-helpers.ts`, called from `handleCheckout()`; lazy SUBSCRIPTION debit in `SlotAllocationService.createAppointments` | -| Inline settlement + ledger posting | `lib/payments/operations/checkout.ts`, the sponsored-family branch of the settlement block; `lib/payments/payouts/earnings-service.ts`, `createEarningsFromPayment()` | -| Payments-side seam (legs, refunds, wallet lifecycle) | `docs/payments/05-b2c-b2b-funding-seam.md` | -| Enterprise money model | `docs/enterprise/10-money-and-ledger/` | +| Concern | Location | +| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Resolution chain + gates | `lib/payments/operations/checkout.ts`, inside `handleCheckout()` | +| ADR 18 allowlist/exclusivity (in-lock) | `lib/payments/operations/checkout.ts`, the `revalidateInsideLock()` function; `docs/enterprise/70-design-decisions/18-open-b2b-b2c-boundary.md` | +| Gateway skip + synthetic ids | `lib/payments/operations/checkout.ts`, the `isOrgWalletPayment` / `isOrgInvoicedPayment` / `isOrgLicensedPayment` / `isOrgSponsoredPayment` booleans and the `skipPayment` flag | +| Wallet debit (atomic conditional updateMany) | `lib/api/organizations/wallet.ts`, `walletDebit()` | +| Engagement debits + caps | `recordBookingUtilization()` in `lib/api/organizations/program-helpers.ts`, called from `handleCheckout()`; lazy SUBSCRIPTION debit in `SlotAllocationService.createAppointments` | +| Inline settlement + ledger posting | `lib/payments/operations/checkout.ts`, the sponsored-family branch of the settlement block; `lib/payments/payouts/earnings-service.ts`, `createEarningsFromPayment()` | +| Cancellation policy resolution + publishing | `lib/payments/operations/cancellation-policy-store.ts`, `resolveCheckoutCancellationPolicyId()` and `publishOrgCancellationPolicy()`; the tier maths is `lib/payments/operations/cancellation-policy.ts` | +| Payments-side seam (legs, refunds, wallet lifecycle) | `docs/payments/05-b2c-b2b-funding-seam.md` | +| Enterprise money model | `docs/enterprise/10-money-and-ledger/` | diff --git a/docs/enterprise/70-design-decisions/00-README.md b/docs/enterprise/70-design-decisions/00-README.md index 2491862ee..fe861ac6a 100644 --- a/docs/enterprise/70-design-decisions/00-README.md +++ b/docs/enterprise/70-design-decisions/00-README.md @@ -21,35 +21,35 @@ Every ADR follows the same four-part shape, written in full sentences: ## Index -All twenty-six ADRs below are written and live (#793 wrote the first twelve; #872 added 15–17; #971 added 18; the dashboard consolidation added 19–20; the money-productionization pass added 21–22; #1051 added 23; the offering content model added 24; #705 added 25; and the 2026-09-03 financial audit added 27 — 26 is numbered for a companion decision from the same audit that has not yet merged); this index is the authoritative list. Each row links to its record. -All twenty-six ADRs below are written and live (#793 wrote the first twelve; #872 added 15–17; #971 added 18; the dashboard consolidation added 19–20; the money-productionization pass added 21–22; #1051 added 23; the offering content model added 24; #705 added 25; and the 2026-09-03 financial audit added 26); this index is the authoritative list. Each row links to its record. - -| # | ADR | Decision in one line | -| --- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 01 | [Double-entry journal over three logs](01-double-entry-over-three-logs.md) | One balanced `LedgerTransaction`/`LedgerEntry` journal replaced `FundingLedgerEntry`, `WalletEntry`, and `SettlementLedgerEntry` (#772). | -| 02 | [Integer paise and basis points](02-integer-paise-and-basis-points.md) | All money is integer paise and all splits are integer basis points, so no float ever touches a balance. | -| 03 | [Deterministic ledger-account IDs](03-deterministic-ledger-account-ids.md) | Ledger accounts use deterministic composite IDs (kind|org|consultant|currency) instead of UUIDs (#783). | -| 04 | [Batch payouts over streaming](04-batch-payouts-over-streaming.md) | Earnings settle in periodic idempotent batches rather than per-earning transfers. | -| 05 | [GitHub Actions crons](05-github-actions-crons.md) | Scheduled jobs run as GitHub Actions invoking `npx tsx jobs/**` directly (with `CRON_SECRET`-gated routes as a manual fallback) rather than Netlify scheduled functions. | -| 06 | [Typed Membership over BetterAuth Member](06-typed-membership-over-betterauth-member.md) | Every permission gate reads the typed `Membership` row, never BetterAuth's own member table. | -| 07 | [Upstash rate limiting](07-upstash-rate-limiting.md) | BetterAuth's built-in limiter stays off; Upstash sliding windows gate the sensitive routes. | -| 08 | [Gapless invoice counters](08-gapless-invoice-counters.md) | Invoice and credit-note numbers come from per-org, per-fiscal-year atomic counters to satisfy CGST Rules 46/53. | -| 09 | [Webhook secret-rotation grace](09-webhook-rotation-grace.md) | Outbound webhook secret rotation dual-signs for 24 hours so receivers can cut over without a hard break. | -| 10 | [Session-generation clock](10-session-generation-clock.md) | Role changes bump `User.sessionGeneration` to force a membership refetch instead of revoking sessions. | -| 11 | [Live-payout submission freeze](11-live-payout-submission-freeze.md) | `ENABLE_LIVE_PAYOUTS` freezes only the gateway submission step; the whole pipeline upstream of it runs for real. | -| 12 | [PENDING_TRUST earnings parking](12-pending-trust-earnings-parking.md) | Earnings for unverified INVOICE-funded orgs park in `PENDING_TRUST` until the org verifies or pays, closing the ghost-org fraud hole (#687). | -| 13 | [Postgres-native concurrency](13-postgres-native-concurrency.md) | State transitions are guarded by CAS WHERE clauses, Serializable retries, version columns, and Redis cron locks — no Kafka, RabbitMQ, Temporal, or Inngest at this stage. | -| 14 | [Async and queue posture](14-async-queue-posture.md) | Background work stays queue-less for launch (GH Actions crons + `after()` + sweeper re-drives); Upstash QStash is the pre-approved escalation, gated on two named telemetry triggers. | -| 15 | [Currency as enum with display fields](15-currency-as-enum-with-display-fields.md) | Settlement currency stays the `Currency` enum; gateway and buyer codes live in free-text display fields, and the ledger is keyed INR-only (#783). | -| 16 | [Slot freshness without realtime](16-slot-freshness-without-realtime.md) | Slot freshness comes from server-authoritative 409 conflicts plus focused refetch and invalidate-on-mutation, not Supabase Realtime. | -| 17 | [Timezone pinned to IST for launch](17-timezone-pinned-to-ist-for-launch.md) | The platform pins to IST and removes the speculative DST materialization layer; the full IANA-TZID implementation is deferred to #872. | -| 18 | [Open B2B/B2C boundary](18-open-b2b-b2c-boundary.md) | Sponsors fund any marketplace consultant and collaborations stay org-blind by design; `ProgramConsultantAllowlist` and `Membership.exclusiveEngagement` began as schema stubs and have both been enforced at checkout since 2026-07-11. | -| 19 | [Personal-vs-org dashboard split](19-personal-vs-org-dashboard-split.md) | Dashboards split by the org-ness of the underlying session, plan or payment — views split, instruments do not; a nav entry must be a distinct destination, so scope variants become on-page toggles and filters become tabs; admin and staff keep two URL trees over one implementation, with access decided by a permission matrix rather than by which tree you landed in. | -| 20 | [Org visibility into member sessions](20-org-visibility-into-member-sessions.md) | An organization sees that a session happened — member, counterpart, plan title, time, status, cost — and never what happened in it; notes, feedback, recordings, document contents and chat stay with the two participants, enforced by select allowlists that branch on whether the scope constrains the caller to be one of them. | -| 21 | [Single writer for payment confirmation](21-single-writer-for-payment-confirmation.md) | `Payment.paymentStatus` is written by the confirmation pipeline and by nothing else; the webhook, the client's signature return, the on-demand sync and the reconcile cron all call `routeCapturedPayment` rather than recording the conclusion themselves, because a second writer turns `handlePaymentSuccess`'s already-SUCCEEDED guard from "this work is done" into "this work will never be done". | -| 22 | [Queue posture, revisited with measurements](22-queue-posture-revisited-with-measurements.md) | Measured cadence shows sub-hourly GitHub Actions schedules deliver roughly one tick per 100 minutes while nothing overlaps, so the defect is missed ticks rather than contention; the QStash escalation in ADR 14 is now authorised, Temporal stays out on cost and fit rather than on the architectural objection its 2026 Lambda workers retired, and Inngest is recorded with concrete adoption triggers. | -| 23 | [Notification scope](23-notification-scope.md) | A notification inherits the org-ness of the record that triggered it: dual-context payloads carry a required `NotificationScope`, deep links resolve to the owning dashboard rather than bouncing everyone to their personal tree, the Inbox filters the shared feed back apart by scope, and three org categories make the previously unmutable `ORG_*` family configurable. | -| 24 | [The offering content model](24-offering-content-model.md) | An offering is described by structured content rather than one free-text blob: `subtitle`, `targetAudience`, `whatsIncluded` and a polymorphic `PlanFaq` land on all four plan types, the two curriculum tables stay separate but both gain a free-text `sectionLabel` that groups sessions under a heading, `level` becomes the `PlanLevel` enum, all four plan types get a detail page gated by `isPlanViewable`, and the pricing toggle becomes a chooser with an Open-details and a booking CTA. | -| 25 | [Per-session reviews and the published score](25-per-session-reviews-and-published-score.md) | A review belongs to a session rather than to a relationship (`@@unique([appointmentId, consulteeProfileId])`), a group session contributes the mean of its attendees as one data point through the denormalized `ratingUnitId`, `ConsultantProfile.publishedRating` stays null below five distinct rated sessions, public reviews and the private CSAT in `AppointmentFeedback` remain separate objects because FTC 16 CFR §465.1(d) makes a bare star rating a consumer review, and every attendee of every held session is asked identically with no sentiment gate and no incentive (#705). | -| 27 | [State-as-outbox with a scheduled ticker](27-state-as-outbox-and-scheduled-ticker.md) | No generic outbox table is added — domain rows (`Payment`, `Appointment`, `Refund`, `WalletTopUp`, `WebhookEvent`, `OutboundWebhookDelivery`, `FailedEmail`) already carry the durable state one exists to provide; instead `netlify/functions/cron-tick.mts` POSTs the latency-sensitive `/api/cleanup/*` sweeps every five minutes, because GitHub Actions was measured delivering a sub-hourly schedule roughly once per hundred minutes (ADR 22), while Actions keeps the daily/weekly crons and the unbounded backstop (#866, #1010, #1356). | -| 26 | [GST — the platform bills as principal supplier](26-gst-principal-model.md) | The platform is the supplier of record for GST — 18% on the full discounted price, a B2C tax invoice and credit note on the platform's own gapless series, and no Section 52 TCS collection — paired with the pre-existing Section 194-O operator withholding on income tax, pending a CA opinion on whether that pairing holds (#1360, #1361, #1388). | +All twenty-seven ADRs below are written and live (#793 wrote the first twelve; #872 added 15–17; #971 added 18; the dashboard consolidation added 19–20; the money-productionization pass added 21–22; #1051 added 23; the offering content model added 24; #705 added 25; the 2026-09-03 financial audit added 26 and 27; and #1499 added 28); this index is the authoritative list. Numbers 13 and 14 predate the renumbering and remain in place, so the count is one short of the highest number. Each row links to its record. + +| # | ADR | Decision in one line | +| --- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 01 | [Double-entry journal over three logs](01-double-entry-over-three-logs.md) | One balanced `LedgerTransaction`/`LedgerEntry` journal replaced `FundingLedgerEntry`, `WalletEntry`, and `SettlementLedgerEntry` (#772). | +| 02 | [Integer paise and basis points](02-integer-paise-and-basis-points.md) | All money is integer paise and all splits are integer basis points, so no float ever touches a balance. | +| 03 | [Deterministic ledger-account IDs](03-deterministic-ledger-account-ids.md) | Ledger accounts use deterministic composite IDs (kind|org|consultant|currency) instead of UUIDs (#783). | +| 04 | [Batch payouts over streaming](04-batch-payouts-over-streaming.md) | Earnings settle in periodic idempotent batches rather than per-earning transfers. | +| 05 | [GitHub Actions crons](05-github-actions-crons.md) | Scheduled jobs run as GitHub Actions invoking `npx tsx jobs/**` directly (with `CRON_SECRET`-gated routes as a manual fallback) rather than Netlify scheduled functions. | +| 06 | [Typed Membership over BetterAuth Member](06-typed-membership-over-betterauth-member.md) | Every permission gate reads the typed `Membership` row, never BetterAuth's own member table. | +| 07 | [Upstash rate limiting](07-upstash-rate-limiting.md) | BetterAuth's built-in limiter stays off; Upstash sliding windows gate the sensitive routes. | +| 08 | [Gapless invoice counters](08-gapless-invoice-counters.md) | Invoice and credit-note numbers come from per-org, per-fiscal-year atomic counters to satisfy CGST Rules 46/53. | +| 09 | [Webhook secret-rotation grace](09-webhook-rotation-grace.md) | Outbound webhook secret rotation dual-signs for 24 hours so receivers can cut over without a hard break. | +| 10 | [Session-generation clock](10-session-generation-clock.md) | Role changes bump `User.sessionGeneration` to force a membership refetch instead of revoking sessions. | +| 11 | [Live-payout submission freeze](11-live-payout-submission-freeze.md) | `ENABLE_LIVE_PAYOUTS` freezes only the gateway submission step; the whole pipeline upstream of it runs for real. | +| 12 | [PENDING_TRUST earnings parking](12-pending-trust-earnings-parking.md) | Earnings for unverified INVOICE-funded orgs park in `PENDING_TRUST` until the org verifies or pays, closing the ghost-org fraud hole (#687). | +| 13 | [Postgres-native concurrency](13-postgres-native-concurrency.md) | State transitions are guarded by CAS WHERE clauses, Serializable retries, version columns, and Redis cron locks — no Kafka, RabbitMQ, Temporal, or Inngest at this stage. | +| 14 | [Async and queue posture](14-async-queue-posture.md) | Background work stays queue-less for launch (GH Actions crons + `after()` + sweeper re-drives); Upstash QStash is the pre-approved escalation, gated on two named telemetry triggers. | +| 15 | [Currency as enum with display fields](15-currency-as-enum-with-display-fields.md) | Settlement currency stays the `Currency` enum; gateway and buyer codes live in free-text display fields, and the ledger is keyed INR-only (#783). | +| 16 | [Slot freshness without realtime](16-slot-freshness-without-realtime.md) | Slot freshness comes from server-authoritative 409 conflicts plus focused refetch and invalidate-on-mutation, not Supabase Realtime. | +| 17 | [Timezone pinned to IST for launch](17-timezone-pinned-to-ist-for-launch.md) | The platform pins to IST and removes the speculative DST materialization layer; the full IANA-TZID implementation is deferred to #872. | +| 18 | [Open B2B/B2C boundary](18-open-b2b-b2c-boundary.md) | Sponsors fund any marketplace consultant and collaborations stay org-blind by design; `ProgramConsultantAllowlist` and `Membership.exclusiveEngagement` began as schema stubs and have both been enforced at checkout since 2026-07-11. | +| 19 | [Personal-vs-org dashboard split](19-personal-vs-org-dashboard-split.md) | Dashboards split by the org-ness of the underlying session, plan or payment — views split, instruments do not; a nav entry must be a distinct destination, so scope variants become on-page toggles and filters become tabs; admin and staff keep two URL trees over one implementation, with access decided by a permission matrix rather than by which tree you landed in. | +| 20 | [Org visibility into member sessions](20-org-visibility-into-member-sessions.md) | An organization sees that a session happened — member, counterpart, plan title, time, status, cost — and never what happened in it; notes, feedback, recordings, document contents and chat stay with the two participants, enforced by select allowlists that branch on whether the scope constrains the caller to be one of them. | +| 21 | [Single writer for payment confirmation](21-single-writer-for-payment-confirmation.md) | `Payment.paymentStatus` is written by the confirmation pipeline and by nothing else; the webhook, the client's signature return, the on-demand sync and the reconcile cron all call `routeCapturedPayment` rather than recording the conclusion themselves, because a second writer turns `handlePaymentSuccess`'s already-SUCCEEDED guard from "this work is done" into "this work will never be done". | +| 22 | [Queue posture, revisited with measurements](22-queue-posture-revisited-with-measurements.md) | Measured cadence shows sub-hourly GitHub Actions schedules deliver roughly one tick per 100 minutes while nothing overlaps, so the defect is missed ticks rather than contention; the QStash escalation in ADR 14 is now authorised, Temporal stays out on cost and fit rather than on the architectural objection its 2026 Lambda workers retired, and Inngest is recorded with concrete adoption triggers. | +| 23 | [Notification scope](23-notification-scope.md) | A notification inherits the org-ness of the record that triggered it: dual-context payloads carry a required `NotificationScope`, deep links resolve to the owning dashboard rather than bouncing everyone to their personal tree, the Inbox filters the shared feed back apart by scope, and three org categories make the previously unmutable `ORG_*` family configurable. | +| 24 | [The offering content model](24-offering-content-model.md) | An offering is described by structured content rather than one free-text blob: `subtitle`, `targetAudience`, `whatsIncluded` and a polymorphic `PlanFaq` land on all four plan types, the two curriculum tables stay separate but both gain a free-text `sectionLabel` that groups sessions under a heading, `level` becomes the `PlanLevel` enum, all four plan types get a detail page gated by `isPlanViewable`, and the pricing toggle becomes a chooser with an Open-details and a booking CTA. | +| 25 | [Per-session reviews and the published score](25-per-session-reviews-and-published-score.md) | A review belongs to a session rather than to a relationship (`@@unique([appointmentId, consulteeProfileId])`), a group session contributes the mean of its attendees as one data point through the denormalized `ratingUnitId`, `ConsultantProfile.publishedRating` stays null below five distinct rated sessions, public reviews and the private CSAT in `AppointmentFeedback` remain separate objects because FTC 16 CFR §465.1(d) makes a bare star rating a consumer review, and every attendee of every held session is asked identically with no sentiment gate and no incentive (#705). | +| 27 | [State-as-outbox with a scheduled ticker](27-state-as-outbox-and-scheduled-ticker.md) | No generic outbox table is added — domain rows (`Payment`, `Appointment`, `Refund`, `WalletTopUp`, `WebhookEvent`, `OutboundWebhookDelivery`, `FailedEmail`) already carry the durable state one exists to provide; instead `netlify/functions/cron-tick.mts` POSTs the latency-sensitive `/api/cleanup/*` sweeps every five minutes, because GitHub Actions was measured delivering a sub-hourly schedule roughly once per hundred minutes (ADR 22), while Actions keeps the daily/weekly crons and the unbounded backstop (#866, #1010, #1356). | +| 26 | [GST — the platform bills as principal supplier](26-gst-principal-model.md) | The platform is the supplier of record for GST — 18% on the full discounted price, a B2C tax invoice and credit note on the platform's own gapless series, and no Section 52 TCS collection — paired with the pre-existing Section 194-O operator withholding on income tax, pending a CA opinion on whether that pairing holds (#1360, #1361, #1388). | +| 28 | [Typed versioned cancellation policy](28-typed-versioned-cancellation-policy.md) | Refund terms are typed, versioned, immutable rows (`CancellationPolicy` + `CancellationPolicyTier`) pointed at by `Appointment.cancellationPolicyId` rather than a Json snapshot, so an organisation can publish its own ladder for the sessions it funds while a published version can never be rewritten under a booking that cites it; the Json column is frozen for the reset, event seats fall back to the platform ladder because one shared `Appointment` serves every registrant, and a fully credit-funded booking restores its credit in full inside any tier above zero and nothing inside a zero tier (#1499, #1500). | diff --git a/docs/enterprise/70-design-decisions/28-typed-versioned-cancellation-policy.md b/docs/enterprise/70-design-decisions/28-typed-versioned-cancellation-policy.md new file mode 100644 index 000000000..ccbf5271c --- /dev/null +++ b/docs/enterprise/70-design-decisions/28-typed-versioned-cancellation-policy.md @@ -0,0 +1,73 @@ +--- +title: Typed versioned cancellation policy +band: 70-design-decisions +audience: sde3 +status: live +last-reviewed: 2026-09-05 +--- + +# ADR 28 — Typed versioned cancellation policy + +## Context + +From the June 2026 refund work until now, the terms that governed a booking's refund were a Json snapshot written onto the appointment at checkout. `Appointment.cancellationPolicySnapshot` held a small object — a version number, a tier ladder, and optionally the organisation's prose policy — and `resolveCancellationPolicySnapshot()` produced it from hardcoded platform defaults. The snapshot solved the problem it was built for: it froze a buyer's terms at the moment of sale, so editing a policy later could not retroactively change what someone had already paid for. + +It did not solve anything else, and three things had accumulated against it. + +The first is that nothing was ever stored in it but the platform defaults. The function had an `ORG_DEFAULT` arm that no caller reached, because there was nowhere for an organisation to put its own tiers. Organisations have a free-text `defaultCancellationPolicy` field that renders as prose and binds nothing, so "our policy is 48 hours" was a sentence on a settings page while every refund on the platform settled on the same 24/50/2 ladder. The snapshot was a freeze mechanism with nothing varying behind it. + +The second is that every reader had to defend itself. Six call sites read the column, each through `parsePolicySnapshot()`, which validated an untyped `Prisma.JsonValue` at runtime and returned null when the shape did not match. A malformed row and an absent row were indistinguishable at the call site, so each reader independently fell back to the defaults. Six copies of one fallback is six chances to disagree about what a booking with no terms means. + +The third is that the subscription placeholder never carried a snapshot at all. Checkout wrote one for consultations and the allocator wrote one for each allocated session, but the placeholder appointment that actually carries a subscription's money was created without terms. `resolveBookingRefundContext` compensated with a fallback that reached across to "any session row's snapshot" — a workaround whose only reason to exist was the gap in the writer. + +Separately, #1500 needed an answer to a money question the snapshot model had no room for: what a partial refund tier means for a booking funded entirely with referral credit, where the credits rail can restore the whole credit or none of it and nothing in between. + +## Decision + +Refund terms become typed, versioned, immutable rows, and the Json column is frozen. + +1. `CancellationPolicy` is one published version of a policy. It carries its scope — `organizationId` null for the platform default, otherwise the owning organisation — a `version`, a `CancellationPolicyStatus` of `ACTIVE` or `ARCHIVED`, the percentage a consultant-initiated cancellation settles at, and an optional copy of the organisation's prose at publication time for the support trail. `CancellationPolicyTier` holds the rungs: a notice threshold in hours and a refund percentage. Both percentages are stored in basis points, per ADR 02. + +2. `Appointment.cancellationPolicyId` is a nullable FK at `onDelete: SetNull`. Null means the platform ladder, which is also what a booking sold before this change reads as, and the two are deliberately indistinguishable. `SetNull` rather than `Cascade` because a booking must survive its organisation being torn down; losing the pointer degrades to the platform ladder rather than deleting money history. + +3. **A published version is immutable.** Editing an organisation's ladder archives the current `ACTIVE` row and inserts a new one at the next version number; it never updates a row in place. This is what makes the freeze structural. The old snapshot's guarantee rested on the writer choosing not to backfill; this one rests on there being no code path that can rewrite a row an appointment cites. + +4. **An organisation's ladder governs the bookings that organisation funds.** Checkout resolves the version once, inside the booking transaction, passing the organisation id only on the sponsored path. A personal booking tagged to an organisation keeps the platform ladder, because the refund settles to the member and not to the organisation. + +5. The platform default lives at a fixed id, is created by the seed, and is created idempotently by `ensurePlatformCancellationPolicy()` on first use, so a database nobody seeded cannot fail a checkout. + +6. `Appointment.cancellationPolicySnapshot` stays in the schema, frozen: never written, never read, annotated as such, and dropped at the pre-MVP reset rather than now. A column that a currently-running deploy still reads must not be dropped under it, and the repo does not write backfill migrations. + +7. Reading and publishing are one module (`cancellation-policy-store.ts`) with one select shape; the tier maths stays in a Prisma-free module (`cancellation-policy.ts`) that is unit-tested with no mocks. One ladder-validation function is shared by the Zod body schema, the publish helper and the seed, so the editor cannot accept a ladder the quote cannot read. + +The #1500 credit rule rides on the same quote. A booking funded entirely by free or referral credit restores that credit **in full** inside any tier above zero per cent, and restores **nothing** inside a zero-per-cent tier. + +## Alternatives considered + +**Keep the Json column and put organisation tiers inside it.** This is the smallest change and it was rejected on integrity. A Json blob cannot be pointed at, so there is no way to ask which bookings a given set of terms governs, no referential guarantee that the terms cited by a booking still exist in a coherent form, and no place to hang an audit row saying who published them. It also keeps the runtime-validation tax at all six readers, and it does nothing about the missing write on the subscription placeholder. + +**Mutable per-organisation policy rows, with a snapshot copied at checkout.** This gives per-organisation tiers with a simpler editor: one row per organisation, edited in place, and the terms copied onto the booking at sale. It was rejected because it reintroduces the copy that the FK exists to remove. Two representations of the same terms drift, and the copy is the one that decides money while the row is the one humans read and edit. Versioning gets the freeze and a single representation at the same time. + +**Full temporal-table versioning, with validity ranges on every row.** Rejected as machinery out of proportion to the problem. The question this model has to answer is "which terms governed this sale", and an FK to an immutable row answers it exactly. Nothing here needs "what did this organisation's policy look like on an arbitrary date", and the archived versions plus their `createdAt` and `archivedAt` timestamps would answer even that. + +**Enforce one `ACTIVE` version per scope with a partial unique index now.** The index is written and staged in `prisma/sql/check-constraints.sql`, commented out. It needs the `NULLS NOT DISTINCT` form, because Postgres treats null keys as distinct and the platform row would otherwise escape a plain unique entirely. It stays commented because the sidecar checker strips comments and would demand an index that has not been applied, and because it can fail against pre-reset rows. Until it lands, one active version per scope is enforced by the Serializable rotation in `publishOrgCancellationPolicy()`, and readers order by `version desc` and take one so that a slip degrades to "the newest version wins" rather than to an arbitrary answer. + +**Allow partial credit restoration for #1500.** Not available: `refundBookingPayment` refuses an `amountPaise` on the credits rail, so a fraction of a credit is not a thing the system can pay. The real choice was between restoring the whole credit and escalating to a human, and escalation is what the code did. Restoring in full inside a partial tier was chosen because the buyer gave the notice the ladder rewards and the rail's inability to divide should not cost them the refund. **Restoring nothing inside the zero tier** was chosen for the mirror-image reason: paying a full credit back for a cancellation that earns a card buyer nothing would make free credit strictly better than money and would delete the late-cancel deterrent for exactly the bookings that cost the least to make. + +## Consequences + +Organisations can set their own refund terms for the sessions they fund, which is the feature; the prose `defaultCancellationPolicy` field stays as display copy beside it rather than being retired, because it says things a ladder cannot. The `MANUAL_REVIEW` refund status is gone from the cancel route, the client and the docs, and with it the operator queue it fed. + +The cost is a second write path at checkout and a new pair of tables to reason about, and the fact that "edit our policy" is really "publish a new version" — which the editor copy says explicitly, because a user who expects an edit and gets a version is entitled to know why their old one is still listed. + +Two limitations are known and accepted. Webinar and class seats fall back to the platform ladder, because one shared `Appointment` row serves every registrant across every funding organisation and cannot carry one buyer's terms; reaching event seats would mean moving the terms onto the participant row, and whole-event refunds already assume the platform ladder. And a `free_` intent carrying a non-zero amount is a mixed payment that settles on the money arm and is refused with `INVALID_AMOUNT`; that is pre-existing behaviour, untouched here. + +The decision should be revisited if organisations start needing per-plan rather than per-organisation ladders, or if event seats need organisation terms — either would move where the FK lives, not whether the model is typed. + +## Related + +- ADR 02 (integer paise and basis points) — why the percentages are stored in basis points. +- ADR 13 (Postgres-native concurrency) — the Serializable rotation that keeps one version active, in the absence of the staged index. +- ADR 18 (open B2B/B2C boundary) — the funding-source reading that decides an organisation's ladder governs what the organisation funds. +- `docs/booking/08-cancellation-flow.md` (where the tiers come from; the credit rule), `docs/booking/17-org-funded-checkout.md` (whose policy applies on the org rails). +- #1499 (the model), #1500 (the credit rule), #1503 (the booking closure train). diff --git a/lib/booking/cancellation-scope.ts b/lib/booking/cancellation-scope.ts index f2c95285f..0518af986 100644 --- a/lib/booking/cancellation-scope.ts +++ b/lib/booking/cancellation-scope.ts @@ -28,6 +28,11 @@ import type { Prisma } from "@prisma/client"; import prisma from "@/lib/prisma"; import { recordSystemError } from "@/lib/enterprise/system-events"; +import type { CancellationPolicyTerms } from "@/lib/payments/operations/cancellation-policy"; +import { + POLICY_TERMS_INCLUDE, + termsFromPolicyRow, +} from "@/lib/payments/operations/cancellation-policy-store"; import { REFUNDABLE_BALANCE_SELECT, refundableBalancePaise, @@ -52,8 +57,12 @@ export type BookingRefundContext = { */ refundablePaise: number; } | null; - /** Terms frozen at purchase; null falls back to the platform defaults. */ - policySnapshot: Prisma.JsonValue | null; + /** + * #1499 — the terms the buyer was sold under, loaded from the immutable policy + * version the booking points at. Always populated: a booking with no policy row + * resolves to the platform ladder rather than to null, so no caller has to. + */ + policy: CancellationPolicyTerms; /** * Hours until the earliest session that has not been delivered or cancelled, * or null when the booking has no live session at all (an unallocated @@ -112,7 +121,7 @@ export async function resolveBookingRefundContext( where: { ...bookingAppointmentFilter(ref), deletedAt: null }, select: { id: true, - cancellationPolicySnapshot: true, + cancellationPolicy: POLICY_TERMS_INCLUDE, payment: { where: { paymentStatus: "SUCCEEDED", @@ -173,19 +182,23 @@ export async function resolveBookingRefundContext( id: payment.id, amountPaise: Number(payment.amount), paymentIntent: payment.paymentIntent, - refundablePaise: refundableBalancePaise(Number(payment.amount), payment), + refundablePaise: refundableBalancePaise( + Number(payment.amount), + payment, + ), } : null; const payer = rows.find((r) => r.payment.length > 0); - // The terms that bind are the ones frozen on the row the buyer actually paid - // for. The subscription placeholder predates the snapshot write, so fall back - // to any session row's snapshot before dropping to the platform defaults. - const policySnapshot = - payer?.cancellationPolicySnapshot ?? - rows.find((r) => r.cancellationPolicySnapshot !== null) - ?.cancellationPolicySnapshot ?? - null; + // The terms that bind are the ones stamped on the row the buyer actually paid for. + // The "any session row" fallback survives only for bookings sold before #1499 wrote + // the FK onto the subscription placeholder; a booking with neither reads as the + // platform ladder, which is what it was sold under. + const policy = termsFromPolicyRow( + payer?.cancellationPolicy ?? + rows.find((r) => r.cancellationPolicy !== null)?.cancellationPolicy ?? + null, + ); const slots = rows.flatMap((r) => r.slotsOfAppointment); const liveStarts = slots @@ -197,7 +210,7 @@ export async function resolveBookingRefundContext( return { paidPayment, - policySnapshot, + policy, hoursUntilNextSession: liveStarts.length > 0 ? (liveStarts[0] - Date.now()) / 3_600_000 : null, sessionsCompleted: slots.filter((s) => s.completionStatus === "COMPLETED") diff --git a/lib/booking/rejection-refund.ts b/lib/booking/rejection-refund.ts index f6ca57829..e0eab5214 100644 --- a/lib/booking/rejection-refund.ts +++ b/lib/booking/rejection-refund.ts @@ -38,10 +38,7 @@ import { getAppUrl } from "@/lib/url"; import { recordSystemError } from "@/lib/enterprise/system-events"; import { notifyRefundProcessed } from "@/lib/novu"; import { notificationScope } from "@/lib/novu/workflows"; -import { - computeRefundPct, - parsePolicySnapshot, -} from "@/lib/payments/operations/cancellation-policy"; +import { computeRefundPct } from "@/lib/payments/operations/cancellation-policy"; import { isFreeCreditIntent, refundBookingPayment, @@ -95,7 +92,7 @@ export async function refundRejectedRequest(args: { if (!ctx.paidPayment) return null; const refundPct = computeRefundPct( - parsePolicySnapshot(ctx.policySnapshot), + ctx.policy, // Irrelevant on the consultant-initiated branch, but pass the real value // so a future policy that tiers consultant cancellations still works. ctx.hoursUntilNextSession ?? -1, diff --git a/lib/enterprise/audit-actions.ts b/lib/enterprise/audit-actions.ts index 09b2d1f9f..2d29a2f5f 100644 --- a/lib/enterprise/audit-actions.ts +++ b/lib/enterprise/audit-actions.ts @@ -149,6 +149,10 @@ export const AUDIT_ACTIONS = { // carry the context so an OWNER scanning the audit log can tell which // provider's cert is about to lapse. SSO_CERT_EXPIRING: "SSO_CERT_EXPIRING", + // #1499 — emitted by PUT /api/organizations/[orgId]/cancellation-policy. A + // published version is immutable, so the audit row plus the version number is + // the whole change history: `details` carries the ladder that was published. + CANCELLATION_POLICY_PUBLISHED: "CANCELLATION_POLICY_PUBLISHED", }, CONSENT: { CONSENT_GRANTED: "CONSENT_GRANTED", diff --git a/lib/payments/operations/cancellation-policy-store.ts b/lib/payments/operations/cancellation-policy-store.ts new file mode 100644 index 000000000..2a2d9a95e --- /dev/null +++ b/lib/payments/operations/cancellation-policy-store.ts @@ -0,0 +1,254 @@ +/** + * Loading and publishing cancellation policies (#1499). + * + * The maths lives in `cancellation-policy.ts` and stays Prisma-free; everything + * that talks to the database about a policy lives here, so there is exactly one + * select shape, one platform-row provisioner and one publish routine. + * + * Two rules shape this module. A published version is immutable — an edit + * publishes a new row at `version + 1` and archives the previous one, because + * `Appointment.cancellationPolicyId` points at the exact row that governed a sale + * and rewriting it would rewrite terms a buyer already agreed to. And a scope has + * at most one ACTIVE version, which the Serializable rotation in + * `publishOrgCancellationPolicy` enforces until the staged partial unique index in + * `prisma/sql/check-constraints.sql` is applied at the pre-MVP reset. + */ + +import { Prisma } from "@prisma/client"; + +import type { PrismaLike, Tx } from "@/lib/prisma"; +import { + isTwoDecimalPercent, + PLATFORM_DEFAULT_TERMS, + tiersFromBps, + validateTierLadder, + type CancellationPolicyTerms, + type RefundTier, +} from "@/lib/payments/operations/cancellation-policy"; + +/** + * The platform default lives at a fixed id rather than "the row where + * organizationId is null", so `ensurePlatformCancellationPolicy` can be an idempotent + * upsert instead of a read-then-write race, and so the seed and the runtime + * provisioner cannot create two of it. + */ +export const PLATFORM_CANCELLATION_POLICY_ID = + "00000000-0000-4000-8000-00000c471499"; + +/** Platform ladder as stored: 100% a day out, 50% inside the day, nothing inside two hours. */ +export const PLATFORM_DEFAULT_TIER_ROWS: { + hoursBefore: number; + refundBps: number; +}[] = [ + { hoursBefore: 24, refundBps: 10_000 }, + { hoursBefore: 2, refundBps: 5_000 }, + { hoursBefore: 0, refundBps: 0 }, +]; + +/** + * The one select every reader uses. Tiers arrive highest-notice first, which is the + * order `computeRefundPct` sorts into anyway — sorting at the source keeps the + * editor, the API response and the maths reading the same ladder. + */ +export const POLICY_TERMS_INCLUDE = { + select: { + id: true, + organizationId: true, + version: true, + consultantInitiatedBps: true, + tiers: { + orderBy: { hoursBefore: "desc" }, + select: { hoursBefore: true, refundBps: true }, + }, + }, +} satisfies { select: Prisma.CancellationPolicySelect }; + +/** The shape `POLICY_TERMS_INCLUDE` returns. */ +export type PolicyRow = { + id: string; + organizationId: string | null; + version: number; + consultantInitiatedBps: number; + tiers: { hoursBefore: number; refundBps: number }[]; +}; + +/** + * A loaded row (or the absence of one) as the terms the quote reads. A booking with + * no policy row is a pre-#1499 booking or a personal booking whose org never + * published, and both mean the platform ladder. + */ +export function termsFromPolicyRow( + row: PolicyRow | null | undefined, +): CancellationPolicyTerms { + if (!row) return PLATFORM_DEFAULT_TERMS; + return { + policyId: row.id, + source: row.organizationId ? "ORG" : "PLATFORM", + version: row.version, + tiers: tiersFromBps(row.tiers), + consultantInitiatedPct: row.consultantInitiatedBps / 100, + }; +} + +/** + * Make sure the platform default row exists, and answer its id. + * + * A fresh database must never fail a checkout because nobody ran the seed, so + * checkout calls this rather than assuming. The P2002 catch covers two checkouts + * racing on an empty database: the loser re-reads the winner's row instead of + * failing the sale. + * + * Call it on the GLOBAL Prisma client only, never through a `tx` (#1513 review). + * Inside a Serializable transaction a P2002 aborts the whole transaction, so the + * recovery below cannot run: the loser's re-read would fail with + * "current transaction is aborted" and take the sale down with it. On the global + * client each statement is its own autocommit unit, so the loser really does get + * to re-read the winner's row. + */ +export async function ensurePlatformCancellationPolicy( + db: PrismaLike, +): Promise { + const existing = await db.cancellationPolicy.findUnique({ + where: { id: PLATFORM_CANCELLATION_POLICY_ID }, + select: { id: true }, + }); + if (existing) return existing.id; + try { + const created = await db.cancellationPolicy.create({ + data: { + id: PLATFORM_CANCELLATION_POLICY_ID, + organizationId: null, + version: 1, + status: "ACTIVE", + consultantInitiatedBps: 10_000, + tiers: { create: PLATFORM_DEFAULT_TIER_ROWS }, + }, + select: { id: true }, + }); + return created.id; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return PLATFORM_CANCELLATION_POLICY_ID; + } + throw err; + } +} + +/** + * Which policy version governs a booking being sold right now. + * + * An org's ladder governs the bookings the ORG FUNDS — its money is what the refund + * moves — so the caller passes the organization id only on the org-sponsored path. + * A personal booking merely tagged to an org keeps the platform ladder. An org that + * has never published falls through to the platform row for the same reason. + * + * `version: desc` with `take: 1` rather than a bare "the ACTIVE one": until the + * staged partial unique lands, two ACTIVE rows are prevented only by the Serializable + * rotation, and the newest version is the deterministic answer if that ever slips. + * + * This resolver is READ-ONLY (#1513 review), because its one caller runs it inside + * checkout's Serializable transaction and a write that recovers from P2002 cannot + * survive there. Provisioning the platform row is `ensurePlatformCancellationPolicy`, + * which `handleCheckout` awaits on the global client just before opening that + * transaction — so by the time this runs the row exists and the throw below is + * unreachable. It is a loud failure rather than a silent null because a sale that + * cites no policy version has no terms to refund under. + */ +export async function resolveCheckoutCancellationPolicyId( + db: PrismaLike, + params: { organizationId: string | null }, +): Promise { + if (params.organizationId) { + const orgPolicy = await db.cancellationPolicy.findFirst({ + where: { organizationId: params.organizationId, status: "ACTIVE" }, + orderBy: { version: "desc" }, + select: { id: true }, + }); + if (orgPolicy) return orgPolicy.id; + } + const platform = await db.cancellationPolicy.findUnique({ + where: { id: PLATFORM_CANCELLATION_POLICY_ID }, + select: { id: true }, + }); + if (!platform) throw new Error("PLATFORM_CANCELLATION_POLICY_MISSING"); + return platform.id; +} + +/** Load the terms a booking was sold under, by policy id. */ +export async function loadPolicyTerms( + db: PrismaLike, + policyId: string | null | undefined, +): Promise { + if (!policyId) return PLATFORM_DEFAULT_TERMS; + const row = await db.cancellationPolicy.findUnique({ + where: { id: policyId }, + ...POLICY_TERMS_INCLUDE, + }); + return termsFromPolicyRow(row); +} + +/** + * Publish a new immutable version of an org's ladder and archive the previous one. + * + * Read-then-write (find the current ACTIVE version → archive it → insert at + * version + 1), so the caller must run it under `withSerializableRetry` with a + * Serializable transaction, exactly as `bumpRateCard` is run: two concurrent + * publishes would otherwise both read version N and both insert version N + 1. + */ +export async function publishOrgCancellationPolicy( + tx: Tx, + params: { + organizationId: string; + tiers: RefundTier[]; + consultantInitiatedPct: number; + policyText?: string | null; + publishedByUserId?: string | null; + }, +) { + const invalid = validateTierLadder(params.tiers); + if (invalid) throw Object.assign(new Error(invalid), { httpStatus: 400 }); + if ( + params.consultantInitiatedPct < 0 || + params.consultantInitiatedPct > 100 || + !isTwoDecimalPercent(params.consultantInitiatedPct) + ) { + throw Object.assign( + new Error( + "The consultant-initiated refund must be between 0 and 100 percent, with at most two decimal places", + ), + { httpStatus: 400 }, + ); + } + + const current = await tx.cancellationPolicy.findFirst({ + where: { organizationId: params.organizationId }, + orderBy: { version: "desc" }, + select: { version: true }, + }); + + await tx.cancellationPolicy.updateMany({ + where: { organizationId: params.organizationId, status: "ACTIVE" }, + data: { status: "ARCHIVED", archivedAt: new Date() }, + }); + + return tx.cancellationPolicy.create({ + data: { + organizationId: params.organizationId, + version: (current?.version ?? 0) + 1, + status: "ACTIVE", + consultantInitiatedBps: Math.round(params.consultantInitiatedPct * 100), + policyText: params.policyText ?? null, + publishedByUserId: params.publishedByUserId ?? null, + tiers: { + create: params.tiers.map((tier) => ({ + hoursBefore: tier.hoursBefore, + refundBps: Math.round(tier.refundPct * 100), + })), + }, + }, + ...POLICY_TERMS_INCLUDE, + }); +} diff --git a/lib/payments/operations/cancellation-policy.ts b/lib/payments/operations/cancellation-policy.ts index 55ae55340..ed31c65a6 100644 --- a/lib/payments/operations/cancellation-policy.ts +++ b/lib/payments/operations/cancellation-policy.ts @@ -1,53 +1,114 @@ /** - * Cancellation/refund policy — snapshot-at-booking (2026-06-10 decision). + * Cancellation/refund policy — the pure maths, and nothing else. * - * Tiered time-based refund windows are resolved at CHECKOUT and frozen onto - * `Appointment.cancellationPolicySnapshot`, so an org or platform editing its - * policy later never retroactively changes a buyer's terms. The cancel flow - * reads the snapshot; a null snapshot (pre-feature booking) falls back to the - * platform defaults below — same maths, just not frozen. + * The terms that govern a booking are typed, versioned rows since #1499 + * (`CancellationPolicy` + `CancellationPolicyTier`), pointed at by + * `Appointment.cancellationPolicyId`. A published version is immutable, so an org + * or the platform editing its ladder later never retroactively changes a buyer's + * terms — the same guarantee the old `Json` snapshot gave, now with FK integrity + * and per-org tiers behind it. * - * Org `defaultCancellationPolicy` is free TEXT today, so v1 snapshots always - * carry the platform tiers and record the org prose for the support trail; - * structured per-org tiers are a post-launch schema item. + * This module stays free of Prisma on purpose: it is imported by the cancel routes, + * the trial and event refund paths and the support context, and it is unit-tested + * with no mocks. Loading a policy row is `cancellation-policy-store.ts`; turning one + * into money is here. */ -export interface CancellationPolicyTier { +/** One rung of a notice ladder, in the units the API and the UI speak. */ +export interface RefundTier { /** Tier applies when the booking starts at least this many hours away. */ hoursBefore: number; - /** Whole-number percentage of the paid amount refunded. */ + /** Percentage of the paid amount refunded; may carry two decimals. */ refundPct: number; } -export interface CancellationPolicySnapshot { - version: 1; - source: "PLATFORM_DEFAULT" | "ORG_DEFAULT"; - tiers: CancellationPolicyTier[]; +/** A loaded policy version, flattened to what the maths needs. */ +export interface CancellationPolicyTerms { + /** The row these terms came from; null for the built-in platform fallback. */ + policyId: string | null; + source: "PLATFORM" | "ORG"; + version: number; + tiers: RefundTier[]; /** Consultant-initiated cancellations always refund this percentage. */ consultantInitiatedPct: number; - /** Org policy prose at booking time, for the support trail. */ - orgPolicyText?: string | null; } // Industry-standard defaults (Calendly/Cal.com-style): full refund a day out, // half inside the day, nothing inside two hours. Consultant-initiated is // always 100% — the buyer did nothing wrong. -export const PLATFORM_DEFAULT_TIERS: CancellationPolicyTier[] = [ +export const PLATFORM_DEFAULT_TIERS: RefundTier[] = [ { hoursBefore: 24, refundPct: 100 }, { hoursBefore: 2, refundPct: 50 }, { hoursBefore: 0, refundPct: 0 }, ]; -export function resolveCancellationPolicySnapshot(params?: { - orgPolicyText?: string | null; -}): CancellationPolicySnapshot { - return { - version: 1, - source: "PLATFORM_DEFAULT", - tiers: PLATFORM_DEFAULT_TIERS, - consultantInitiatedPct: 100, - orgPolicyText: params?.orgPolicyText ?? null, - }; +/** + * The ladder every booking falls back to: a booking with no policy row, a booking + * sold before #1499, and an org that has never published its own. + */ +export const PLATFORM_DEFAULT_TERMS: CancellationPolicyTerms = { + policyId: null, + source: "PLATFORM", + version: 1, + tiers: PLATFORM_DEFAULT_TIERS, + consultantInitiatedPct: 100, +}; + +/** A version may not carry more rungs than this — see `validateTierLadder`. */ +export const MAX_POLICY_TIERS = 6; + +/** + * Whether a percentage carries at most two decimal places, i.e. whether it + * survives the `Math.round(pct * 100)` that stores it as basis points. + * + * The obvious test, `Math.round(v * 100) !== v * 100`, rejects perfectly legal + * percentages: `0.07 * 100` is `7.000000000000001` in IEEE 754, so a 0.07% rung + * an OWNER typed was refused as "more than two decimal places". Compare against + * a tolerance instead — 1e-6 basis points is far below anything a percentage can + * legitimately mean, and far above the representation error (#1513 review). + */ +export function isTwoDecimalPercent(v: number): boolean { + const bps = v * 100; + return Number.isFinite(bps) && Math.abs(bps - Math.round(bps)) < 1e-6; +} + +/** Basis-point rows as stored → the percent tiers the maths and the API use. */ +export function tiersFromBps( + rows: { hoursBefore: number; refundBps: number }[], +): RefundTier[] { + return rows.map((row) => ({ + hoursBefore: row.hoursBefore, + refundPct: row.refundBps / 100, + })); +} + +/** + * The one ladder rule, shared by the Zod body schema, the publish helper and the + * seed, so a ladder that the editor accepts cannot be one the quote cannot read. + * Returns null when the ladder is valid, or the reason it is not. + * + * The last rung must be exactly `hoursBefore: 0` because `computeRefundPct` walks + * the rungs downwards and returns 0 if it falls off the end — a ladder that stops + * at 2 hours would silently mean "nothing inside two hours" without ever saying so. + */ +export function validateTierLadder(tiers: RefundTier[]): string | null { + if (tiers.length < 1) return "A policy needs at least one tier"; + if (tiers.length > MAX_POLICY_TIERS) + return `A policy may not have more than ${MAX_POLICY_TIERS} tiers`; + const sorted = [...tiers].sort((a, b) => b.hoursBefore - a.hoursBefore); + for (const [index, tier] of sorted.entries()) { + if (!Number.isInteger(tier.hoursBefore) || tier.hoursBefore < 0) + return "Each tier's notice must be a whole number of hours, zero or more"; + if (tier.refundPct < 0 || tier.refundPct > 100) + return "Each tier's refund must be between 0 and 100 percent"; + if (!isTwoDecimalPercent(tier.refundPct)) + return "A refund percentage may carry at most two decimal places"; + if (index > 0 && sorted[index - 1].hoursBefore === tier.hoursBefore) + return "Two tiers may not share the same notice period"; + } + if (sorted[sorted.length - 1].hoursBefore !== 0) + return "The last tier must start at 0 hours so every cancellation is covered"; + return null; } /** @@ -56,11 +117,11 @@ export function resolveCancellationPolicySnapshot(params?: { * refund nothing unless consultant-initiated. */ export function computeRefundPct( - snapshot: CancellationPolicySnapshot | null | undefined, + terms: CancellationPolicyTerms | null | undefined, hoursUntilStart: number, isConsultantInitiated: boolean, ): number { - const policy = snapshot ?? resolveCancellationPolicySnapshot(); + const policy = terms ?? PLATFORM_DEFAULT_TERMS; if (isConsultantInitiated) return policy.consultantInitiatedPct; if (hoursUntilStart < 0) return 0; // Tiers sorted descending by hoursBefore; first tier whose threshold the @@ -74,20 +135,10 @@ export function computeRefundPct( return 0; } -/** Parse the Json column back into the typed snapshot (defensive). */ -export function parsePolicySnapshot( - raw: unknown, -): CancellationPolicySnapshot | null { - if (!raw || typeof raw !== "object") return null; - const s = raw as Partial; - if (s.version !== 1 || !Array.isArray(s.tiers)) return null; - return s as CancellationPolicySnapshot; -} - /** Everything the quote needs, all of it read off `BookingRefundContext`. */ export interface BookingRefundQuoteInput { - /** The raw `Appointment.cancellationPolicySnapshot` column; parsed here. */ - policySnapshot: unknown; + /** The terms loaded from the booking's policy row; null falls back to platform. */ + policy: CancellationPolicyTerms | null; /** Null when the booking has no undelivered session left. */ hoursUntilNextSession: number | null; /** Slots of any status on the booking; zero means none was ever scheduled. */ @@ -97,6 +148,8 @@ export interface BookingRefundQuoteInput { /** Only a subscription prorates; every other booking refunds off the whole price. */ isSubscription: boolean; isConsultantInitiated: boolean; + /** #1500 — the whole booking was paid with referral/free credit (`free_`, amount 0). */ + isFreeCreditFunded: boolean; /** Gross captured on the booking's payment, in paise. */ grossPaise: number; /** Gross less anything already given back. */ @@ -104,7 +157,14 @@ export interface BookingRefundQuoteInput { } export interface BookingRefundQuote { + /** What the booking actually settles at, after the #1500 credit rule. */ refundPct: number; + /** + * What the notice ladder alone answered, before the credit rule rounded it up. + * Surfaced rather than recomputed so the refund reason can name the real tier + * without a second implementation of the ladder. + */ + tierRefundPct: number; /** The notice the tier table was asked about; infinite when never scheduled. */ noticeHours: number; /** The undelivered share of the price, before the tier percentage. */ @@ -113,6 +173,8 @@ export interface BookingRefundQuote { prorated: boolean; /** What the cancellation pays back, clamped to the refundable balance. */ refundPaise: number; + /** #1500 — the credit is restored whole rather than at the tier percentage. */ + creditRestoresInFull: boolean; } /** @@ -154,11 +216,19 @@ export function quoteBookingRefund( : (input.hoursUntilNextSession ?? -1); const refundPct = computeRefundPct( - parsePolicySnapshot(input.policySnapshot), + input.policy, noticeHours, input.isConsultantInitiated, ); + // #1500 — the credits rail cannot pay a fraction (refundBookingPayment refuses an + // amountPaise on a free_ intent), so a PARTIAL tier on a fully-credit-funded + // booking restores the credit IN FULL instead of escalating. A zero tier still + // returns nothing — that is the policy, not a rounding, so a late cancel bites a + // credit buyer exactly as it bites a card buyer. + const creditRestoresInFull = input.isFreeCreditFunded && refundPct > 0; + const effectivePct = creditRestoresInFull ? 100 : refundPct; + const isProratable = input.isSubscription && input.slotsTotal > 0; // Integer paise in BigInt: the products can leave the safe-integer range // long before the amounts stop being real money (repo rule for lib/payments). @@ -170,15 +240,17 @@ export function quoteBookingRefund( : input.grossPaise; // refundPct may carry two decimals; scale by 100 so the division is exact. const refundBeforeClamp = Number( - (BigInt(proratedBasePaise) * BigInt(Math.round(refundPct * 100))) / + (BigInt(proratedBasePaise) * BigInt(Math.round(effectivePct * 100))) / BigInt(10_000), ); return { - refundPct, + refundPct: effectivePct, + tierRefundPct: refundPct, noticeHours, proratedBasePaise, prorated: isProratable && input.sessionsRemaining < input.slotsTotal, refundPaise: Math.min(refundBeforeClamp, input.refundablePaise), + creditRestoresInFull, }; } diff --git a/lib/payments/operations/checkout.ts b/lib/payments/operations/checkout.ts index b78147544..7ae070587 100644 --- a/lib/payments/operations/checkout.ts +++ b/lib/payments/operations/checkout.ts @@ -111,7 +111,10 @@ import { } from "@/lib/novu/org-workflows"; import { sumPaise } from "@/lib/payments/utils/money"; import { MARKETPLACE_VISIBILITY } from "@/lib/api/plans/visibility"; -import { resolveCancellationPolicySnapshot } from "@/lib/payments/operations/cancellation-policy"; +import { + ensurePlatformCancellationPolicy, + resolveCheckoutCancellationPolicyId, +} from "@/lib/payments/operations/cancellation-policy-store"; import { isBusinessErrorCode } from "@/lib/errors/classification/payment-error-classification"; // Re-export for backward compatibility @@ -2227,6 +2230,11 @@ export async function handleConsultationCheckout( * was flagged in the May 2026 production-readiness audit. */ organizationId: string | null, + /** + * #1499 — the CancellationPolicy version this sale is governed by, resolved once + * by the caller so every appointment of one checkout cites the same row. + */ + cancellationPolicyId: string, ) { const plan = await tx.consultationPlan.findUnique({ where: { id: data.planId }, @@ -2290,10 +2298,9 @@ export async function handleConsultationCheckout( appointmentType: AppointmentsType.CONSULTATION, consultationId: consultation.id, organizationId, - // B1 — freeze the refund terms at booking; the cancel flow reads this. - cancellationPolicySnapshot: JSON.parse( - JSON.stringify(resolveCancellationPolicySnapshot()), - ), + // B1/#1499 — freeze the refund terms at booking by pointing at the immutable + // policy version; the cancel flow reads it back through this FK. + cancellationPolicyId, slotsOfAppointment: { create: slotAtoms }, }, }); @@ -2328,6 +2335,8 @@ export async function handleSubscriptionCheckout( _skipPayment: boolean, /** Resolved org context — see handleConsultationCheckout for rationale. */ organizationId: string | null, + /** #1499 — see handleConsultationCheckout. */ + cancellationPolicyId: string, ): Promise { const plan = await tx.subscriptionPlan.findUnique({ where: { id: data.planId }, @@ -2442,6 +2451,12 @@ export async function handleSubscriptionCheckout( appointmentType: AppointmentsType.SUBSCRIPTION, subscriptionId: subscription.id, organizationId, + // #1499 — the placeholder carries the money, so it must carry the terms too: + // the sessions allocated later inherit this row's policy, and `cancellation- + // scope` reads the terms off whichever row the Payment hangs on. The old Json + // snapshot was never written here, which is why the fallback in that module + // existed at all. + cancellationPolicyId, // No slots created - consultant allocates later via Requests tab }, }); @@ -2571,6 +2586,11 @@ export async function handleWebinarCheckout( // the first registrant's booking org. This makes "events we host" // discoverable in the org dashboard, and avoids first-registrant- // wins org leakage. + // #1499 — for the same reason no cancellationPolicyId is stamped: one row + // cannot carry one buyer's terms when several orgs are seated on it. A null + // FK reads as the platform ladder, which is what whole-event refunds already + // assume. Org tiers therefore do not reach event seats — a documented + // limitation, not an oversight. appointment = await tx.appointment.create({ data: { appointmentType: AppointmentsType.WEBINAR, @@ -3382,6 +3402,18 @@ export async function handleCheckout( const exhaustedBell: { programAssignmentId: string | null } = { programAssignmentId: null, }; + + // #1513 review — provision the platform policy row HERE, on the global + // client, not from inside the transaction below. The provisioner recovers + // from a P2002 by re-reading the winner's row, and inside a Serializable + // transaction a P2002 aborts the transaction, so that re-read would fail + // and take the sale with it. On the global client every statement is its + // own autocommit unit, so the loser of the race really does get to re-read. + // It must also stay OUTSIDE any `$transaction` callback: PG_POOL_MAX=1 + // means a global-client query issued while a transaction holds the single + // connection deadlocks (#1435, see lib/prisma.ts). + await ensurePlatformCancellationPolicy(prisma); + const result = await withSerializableRetry(async () => { await renewOrAbort(perAttemptTtl); return prisma.$transaction( @@ -3459,6 +3491,15 @@ export async function handleCheckout( const skipPayment = isMockPayment || isZeroAmountPayment || isOrgSponsoredPayment; + // #1499 — whose ladder governs this sale, resolved once inside the + // booking transaction. Org-funded means the ORG'S MONEY moves on a + // refund, so the org's published version binds; a personal booking + // merely tagged to an org keeps the platform ladder. + const cancellationPolicyId = + await resolveCheckoutCancellationPolicyId(tx, { + organizationId: isOrgSponsoredPayment ? organizationId : null, + }); + // Create appointment based on type (with isTentative flag) switch (validatedData.appointmentType) { case "CONSULTATION": { @@ -3469,6 +3510,7 @@ export async function handleCheckout( userId, skipPayment, organizationId, + cancellationPolicyId, ); createdAppointment = consultationResult.appointment; engagementsForCap = 1; @@ -3482,6 +3524,7 @@ export async function handleCheckout( consulteeProfileId, skipPayment, organizationId, + cancellationPolicyId, ); // Use placeholder appointment for payment linkage // This ensures webhook uses NEW FLOW (confirm) not LEGACY FLOW (create duplicate) diff --git a/lib/payments/operations/event-refunds.ts b/lib/payments/operations/event-refunds.ts index 2ec5425c2..61cc2f397 100644 --- a/lib/payments/operations/event-refunds.ts +++ b/lib/payments/operations/event-refunds.ts @@ -18,10 +18,11 @@ import { refundBookingPayment, type FundingRail, } from "./booking-refund"; +import { computeRefundPct } from "./cancellation-policy"; import { - computeRefundPct, - parsePolicySnapshot, -} from "./cancellation-policy"; + POLICY_TERMS_INCLUDE, + termsFromPolicyRow, +} from "./cancellation-policy-store"; import { findLiveEventSlot } from "@/lib/appointments/live-event-slot"; /** @@ -133,7 +134,11 @@ export async function refundWholeEventPayments( // handles any CHARGE_MEMBER overage credit-back internally). for (const p of gateway) { try { - const r = await refundPayment({ paymentId: p.id, reason, initiatedByUserId }); + const r = await refundPayment({ + paymentId: p.id, + reason, + initiatedByUserId, + }); summary.refundsIssued += 1; summary.refundedPaise += r.amountRefundedPaise; summary.childRefundIds.push(r.refundId); @@ -177,7 +182,11 @@ export async function refundWholeEventPayments( refundId: `event:${kind}:${eventId}`, initiatedByUserId, }), - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, maxWait: 10_000, timeout: 15_000 }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + maxWait: 10_000, + timeout: 15_000, + }, ), ); summary.refundsIssued += result.childRefundIds.length; @@ -185,7 +194,9 @@ export async function refundWholeEventPayments( summary.childRefundIds.push(...result.childRefundIds); for (const c of result.cascades) { if (c.memberOverageRefundDue) { - memberOverageFollowUps.push(c.memberOverageRefundDue.overagePaymentId); + memberOverageFollowUps.push( + c.memberOverageRefundDue.overagePaymentId, + ); } } } catch (err) { @@ -218,7 +229,10 @@ export async function refundWholeEventPayments( (err.code === "ALREADY_FULLY_REFUNDED" || err.code === "PAYMENT_NOT_SUCCEEDED"); if (!benign) { - summary.failures.push({ paymentId: overagePaymentId, error: errMsg(err) }); + summary.failures.push({ + paymentId: overagePaymentId, + error: errMsg(err), + }); void recordSystemError({ organizationId: null, category: "PAYMENT", @@ -286,7 +300,8 @@ export async function refundRemovedAttendeeSeat(args: { ? { webinarId: args.eventId } : { classId: args.eventId }; // Missing flag = legacy organiser path; do not flip the money default. - const isOrganiserInitiated = (args.initiatedBy ?? "organiser") === "organiser"; + const isOrganiserInitiated = + (args.initiatedBy ?? "organiser") === "organiser"; // Hoisted so the catch can scope its ops event to the funding organisation; // a failure reported against `null` never reaches the org that is owed it. @@ -313,7 +328,7 @@ export async function refundRemovedAttendeeSeat(args: { currency: true, organizationId: true, ...REFUNDABLE_BALANCE_SELECT, - appointment: { select: { cancellationPolicySnapshot: true } }, + appointment: { select: { cancellationPolicy: POLICY_TERMS_INCLUDE } }, }, }); if (!payment) return null; @@ -340,7 +355,7 @@ export async function refundRemovedAttendeeSeat(args: { } const refundPct = computeRefundPct( - parsePolicySnapshot(payment.appointment?.cancellationPolicySnapshot), + termsFromPolicyRow(payment.appointment?.cancellationPolicy), hoursUntilStart, isOrganiserInitiated, ); @@ -363,7 +378,8 @@ export async function refundRemovedAttendeeSeat(args: { policyRefundPaise, refundableBalancePaise(grossPaise, payment), ); - if (amountPaise <= 0) return { amountRefundedPaise: 0, refundPct, rail: null }; + if (amountPaise <= 0) + return { amountRefundedPaise: 0, refundPct, rail: null }; const actorLabel = isOrganiserInitiated ? "organiser" : "attendee"; const result = await refundBookingPayment({ diff --git a/lib/support/context.ts b/lib/support/context.ts index 778ad1ee5..bafd248c4 100644 --- a/lib/support/context.ts +++ b/lib/support/context.ts @@ -5,10 +5,11 @@ */ import prisma from "@/lib/prisma"; +import { computeRefundPct } from "@/lib/payments/operations/cancellation-policy"; import { - computeRefundPct, - parsePolicySnapshot, -} from "@/lib/payments/operations/cancellation-policy"; + POLICY_TERMS_INCLUDE, + termsFromPolicyRow, +} from "@/lib/payments/operations/cancellation-policy-store"; import { hasOrgPermission } from "@/lib/auth/org-permissions"; import type { SupportContext, SupportStage } from "./types"; @@ -27,7 +28,7 @@ export async function buildSupportContext( id: true, appointmentType: true, organizationId: true, - cancellationPolicySnapshot: true, + cancellationPolicy: POLICY_TERMS_INCLUDE, slotsOfAppointment: { // Current-or-next active slot only — a past SCHEDULED row would // otherwise drive stage/startsAt/endsAt stale when a rebooked slot @@ -48,8 +49,12 @@ export async function buildSupportContext( subscription: { select: { subscriptionPlan: { select: { consultantProfileId: true } } }, }, - webinar: { select: { webinarPlan: { select: { consultantProfileId: true } } } }, - class: { select: { classPlan: { select: { consultantProfileId: true } } } }, + webinar: { + select: { webinarPlan: { select: { consultantProfileId: true } } }, + }, + class: { + select: { classPlan: { select: { consultantProfileId: true } } }, + }, }, }); if (!appt) return null; @@ -106,13 +111,15 @@ export async function buildSupportContext( }); // Policy refund % if cancelled now (consultee-initiated). Only meaningful when - // there's a policy snapshot + a start time; the caller re-derives the real - // amount at execution time (this is a preview for the flow). + // there is a start time to measure notice against; the caller re-derives the real + // amount at execution time (this is a preview for the flow). #1499 — the guard no + // longer requires a stored policy: a booking with none is governed by the platform + // ladder, so the percentage is knowable either way. let refundPctIfCancelledNow: number | null = null; - if (appt.cancellationPolicySnapshot && startsAt) { + if (startsAt) { const hoursUntilStart = (startsAt.getTime() - Date.now()) / 3_600_000; refundPctIfCancelledNow = computeRefundPct( - parsePolicySnapshot(appt.cancellationPolicySnapshot), + termsFromPolicyRow(appt.cancellationPolicy), hoursUntilStart, false, ); @@ -132,7 +139,8 @@ export async function buildSupportContext( }, select: { role: true }, }); - isOrgOperator = !!membership && hasOrgPermission(membership.role, "operations.read"); + isOrgOperator = + !!membership && hasOrgPermission(membership.role, "operations.read"); } return { diff --git a/lib/trials/cancellation.ts b/lib/trials/cancellation.ts index 6f93ec866..210475383 100644 --- a/lib/trials/cancellation.ts +++ b/lib/trials/cancellation.ts @@ -23,10 +23,11 @@ import { REFUNDABLE_BALANCE_SELECT, refundableBalancePaise, } from "@/lib/payments/refundable-balance"; +import { computeRefundPct } from "@/lib/payments/operations/cancellation-policy"; import { - computeRefundPct, - parsePolicySnapshot, -} from "@/lib/payments/operations/cancellation-policy"; + POLICY_TERMS_INCLUDE, + termsFromPolicyRow, +} from "@/lib/payments/operations/cancellation-policy-store"; import { refundBookingPayment } from "@/lib/payments/operations/booking-refund"; export type TrialRefundOutcome = { @@ -111,7 +112,7 @@ export async function refundCancelledTrial(args: { ? await prisma.appointment.findUnique({ where: { id: appointmentId }, select: { - cancellationPolicySnapshot: true, + cancellationPolicy: POLICY_TERMS_INCLUDE, slotsOfAppointment: { orderBy: { startsAt: "asc" }, take: 1, @@ -127,7 +128,7 @@ export async function refundCancelledTrial(args: { : -1; const refundPct = computeRefundPct( - parsePolicySnapshot(appointment?.cancellationPolicySnapshot), + termsFromPolicyRow(appointment?.cancellationPolicy), hoursUntilStart, args.isConsultantInitiated, ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d42cc6c8..d5f8a732f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -146,25 +146,27 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - invitations Invitation[] - ssoproviders SsoProvider[] - erasureRequests ErasureRequest[] + invitations Invitation[] + ssoproviders SsoProvider[] + erasureRequests ErasureRequest[] /// Erasure requests this user processed as an admin. Named relation /// distinguishes it from `erasureRequests` above (requests filed /// against this user) — Prisma requires the disambiguation since /// both edges target ErasureRequest. - erasureRequestsProcessed ErasureRequest[] @relation("ErasureRequestProcessor") + erasureRequestsProcessed ErasureRequest[] @relation("ErasureRequestProcessor") // Disputes this admin owns (#269). - disputesAssigned Dispute[] @relation("DisputeAssignee") + disputesAssigned Dispute[] @relation("DisputeAssignee") // A8 — documents this consultant reviewed (back-relation for the FK-ified // AppointmentDocument.reviewedById; named to avoid the implicit-relation guess). - documentsReviewed AppointmentDocument[] @relation("DocumentReviewer") + documentsReviewed AppointmentDocument[] @relation("DocumentReviewer") // Soft-delete audit — who tombstoned an appointment document. - documentsDeleted AppointmentDocument[] @relation("DocumentDeleter") + documentsDeleted AppointmentDocument[] @relation("DocumentDeleter") // #366 — publish-time redistribution-consent attestations made by consultants. - recordingsConsentAttested Recording[] @relation("RecordingConsentAttestor") + recordingsConsentAttested Recording[] @relation("RecordingConsentAttestor") // #366 — replay purchases this user made. - recordingPurchases RecordingPurchase[] @relation("RecordingPurchaseBuyer") + recordingPurchases RecordingPurchase[] @relation("RecordingPurchaseBuyer") + // #1499 — cancellation-policy versions this OWNER published, for the audit trail. + publishedCancellationPolicies CancellationPolicy[] @relation("CancellationPolicyPublisher") @@index([consultantProfileId]) @@index([consulteeProfileId]) @@ -1032,6 +1034,8 @@ model Organization { // #674 personal-vs-org scope split — appointments / recordings booked or // produced by org members. Mirrors TrialsByOrg. appointmentsByOrg Appointment[] @relation("AppointmentByOrg") + // #1499 — every version of this org's cancellation ladder, live and archived. + cancellationPolicies CancellationPolicy[] @relation("CancellationPolicyByOrg") rescheduleRequests RescheduleRequest[] @relation("RescheduleByOrg") appointmentParticipants AppointmentParticipant[] @relation("AppointmentParticipantByOrg") bookingStatusHistory BookingStatusHistory[] @relation("BookingStatusHistoryByOrg") @@ -4179,13 +4183,21 @@ enum WaitlistSource { // Generic Appointment Model model Appointment { - /// Refund-policy snapshot taken at booking (2026-06-10 decision): tiered - /// time-based windows (e.g. [{hoursBefore:24,refundPct:100},...]) resolved - /// from org/platform defaults at checkout, so later policy edits never - /// retroactively change a buyer's terms. Read by the cancel flow; null = - /// pre-snapshot booking (platform default tiers apply). + /// FROZEN as of #1499 (2026-09-05): never written and never read. The refund terms + /// that govern a booking now live in typed versioned rows behind + /// `cancellationPolicyId`. The column stays because production code read it until + /// this release and a column a running deploy still reads must never be dropped + /// under it; it is removed at the pre-MVP reset (doctrine section 6, no backfill). cancellationPolicySnapshot Json? + /// #1499 — the exact CancellationPolicy version that governed this sale. NULL means + /// the platform ladder applies, which is also what a pre-feature row reads as, so the + /// two are deliberately indistinguishable. SetNull rather than Cascade: a booking must + /// survive its org being torn down, and losing the pointer degrades to the platform + /// ladder rather than deleting money history. + cancellationPolicy CancellationPolicy? @relation(fields: [cancellationPolicyId], references: [id], onDelete: SetNull) + cancellationPolicyId String? + id String @id @default(uuid()) appointmentType AppointmentsType slotsOfAppointment SlotOfAppointment[] @@ -4271,6 +4283,9 @@ model Appointment { @@index([chatChannelEnsuredAt, deletedAt, createdAt]) // #1169 PR 9 — `deletedAt: null` rides nearly every hot filter unindexed. @@index([deletedAt]) + // #1499 — the org policy editor asks "how many bookings cite this version" and the + // SetNull on teardown scans by policy id. + @@index([cancellationPolicyId]) } /// #1319 A9 (adopted, schema-only) — the participant edge the implicit @@ -4372,6 +4387,58 @@ enum AppointmentsType { TRIAL } +/// #1499 — the refund policy that governed a booking, as typed versioned rows. +/// A published version is IMMUTABLE: an edit publishes a NEW row at version+1 and +/// archives the old one, because Appointment.cancellationPolicyId points at the exact +/// row that governed the sale. +/// Scope: exactly one row has organizationId = NULL (the platform default, fixed id +/// PLATFORM_CANCELLATION_POLICY_ID, provisioned by the seed and idempotently on first +/// use); every other row belongs to one organization. At most one ACTIVE row per scope, +/// enforced by the Serializable rotation in publishOrgCancellationPolicy; the structural +/// partial unique index is staged for the reset in prisma/sql/check-constraints.sql +/// (doctrine section 6). +model CancellationPolicy { + id String @id @default(uuid()) + organization Organization? @relation("CancellationPolicyByOrg", fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String? + version Int @default(1) + status CancellationPolicyStatus @default(ACTIVE) + /// Basis points (repo integer convention: RateCard.platformBps, TdsRate.rateBps); + /// the API speaks percent. + consultantInitiatedBps Int @default(10000) + /// The org's prose policy at publication time, for the support trail; never read by + /// the maths. + policyText String? @db.Text + tiers CancellationPolicyTier[] + appointments Appointment[] + publishedBy User? @relation("CancellationPolicyPublisher", fields: [publishedByUserId], references: [id], onDelete: SetNull) + publishedByUserId String? + createdAt DateTime @default(now()) @db.Timestamptz + archivedAt DateTime? @db.Timestamptz + + @@unique([organizationId, version]) + @@index([organizationId, status]) +} + +/// One rung of a version's notice ladder. Immutable with its parent. The lowest rung is +/// always hoursBefore 0, so the ladder is total. +model CancellationPolicyTier { + id String @id @default(uuid()) + policy CancellationPolicy @relation(fields: [policyId], references: [id], onDelete: Cascade) + policyId String + hoursBefore Int + /// 0-10000. Basis points for the same reason consultantInitiatedBps is. + refundBps Int + + @@unique([policyId, hoursBefore]) + @@index([policyId]) +} + +enum CancellationPolicyStatus { + ACTIVE + ARCHIVED +} + /// A request to move booked slots, carrying the concrete times proposed to /// replace them. /// diff --git a/prisma/seed.ts b/prisma/seed.ts index 388182a40..1f2b3f877 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -65,6 +65,7 @@ import { createOrgCatalog } from "./seedFiles/15b-create-org-catalog"; // Phase 16: Statutory lookups (#778 §D) import { createTdsRates } from "./seedFiles/16a-create-tds-rates"; +import { createPlatformCancellationPolicy } from "./seedFiles/16b-create-cancellation-policy"; async function seed() { console.log("Starting seed process..."); @@ -213,6 +214,11 @@ async function seed() { console.log("\n[Phase 16] Seeding statutory TDS rates..."); await createTdsRates(); + // #1499 — the platform refund ladder every booking falls back to. Appointment + // seeds leave the FK null on purpose, which reads as this ladder anyway. + console.log("Seeding the platform cancellation policy..."); + await createPlatformCancellationPolicy(); + // Summary const endTime = Date.now(); const timeElapsed = (endTime - startTime) / 1000; diff --git a/prisma/seedFiles/16b-create-cancellation-policy.ts b/prisma/seedFiles/16b-create-cancellation-policy.ts new file mode 100644 index 000000000..aa4c2135b --- /dev/null +++ b/prisma/seedFiles/16b-create-cancellation-policy.ts @@ -0,0 +1,31 @@ +import prisma from "../../lib/prisma"; +import { + PLATFORM_CANCELLATION_POLICY_ID, + PLATFORM_DEFAULT_TIER_ROWS, +} from "../../lib/payments/operations/cancellation-policy-store"; + +/** + * #1499 — the platform default cancellation policy, at a fixed id. + * + * Every booking that is not governed by an org's own published version points at + * this row, so a database without it cannot quote a refund. The upsert is keyed on + * the fixed id and never rewrites an existing row: a published version is immutable, + * and re-seeding must not silently re-cut terms that bookings already cite. The + * runtime guarantee is `ensurePlatformCancellationPolicy`, which provisions the same + * row on first use for a database nobody seeded. + */ +export async function createPlatformCancellationPolicy() { + await prisma.cancellationPolicy.upsert({ + where: { id: PLATFORM_CANCELLATION_POLICY_ID }, + create: { + id: PLATFORM_CANCELLATION_POLICY_ID, + organizationId: null, + version: 1, + status: "ACTIVE", + consultantInitiatedBps: 10_000, + tiers: { create: PLATFORM_DEFAULT_TIER_ROWS }, + }, + update: {}, + }); + console.log("✅ Seeded the platform CancellationPolicy"); +} diff --git a/prisma/sql/check-constraints.sql b/prisma/sql/check-constraints.sql index b8b92269f..9de830356 100644 --- a/prisma/sql/check-constraints.sql +++ b/prisma/sql/check-constraints.sql @@ -538,4 +538,17 @@ ALTER TABLE "OrganizationPayout" ADD CONSTRAINT "org_payout_tds_fy_format" -- CHECK ("totalSessions" >= 1); -- ALTER TABLE "ClassPlan" ADD CONSTRAINT "class_plan_total_sessions_min" -- CHECK ("totalSessions" >= 1); +-- +-- 4. #1499 — "at most one ACTIVE cancellation policy per scope" and "one row per +-- (scope, version)" are enforced today only by the Serializable rotation in +-- publishOrgCancellationPolicy. Postgres treats NULLs as distinct in a plain +-- unique, so the platform row (organizationId IS NULL) escapes the Prisma +-- @@unique entirely; NULLS NOT DISTINCT closes that. These stay COMMENTED — +-- check-db-sidecars strips comments and would demand an index that is not +-- applied, and the partial unique can fail against pre-reset rows: +-- CREATE UNIQUE INDEX "cancellation_policy_one_active_per_scope" +-- ON "CancellationPolicy" ("organizationId") NULLS NOT DISTINCT +-- WHERE "status" = 'ACTIVE'; +-- CREATE UNIQUE INDEX "cancellation_policy_scope_version" +-- ON "CancellationPolicy" ("organizationId", "version") NULLS NOT DISTINCT; -- ============================================================================ diff --git a/utils/slotAllocation/SlotAllocationService.ts b/utils/slotAllocation/SlotAllocationService.ts index 87dd5adf9..dbaf49bc9 100644 --- a/utils/slotAllocation/SlotAllocationService.ts +++ b/utils/slotAllocation/SlotAllocationService.ts @@ -98,7 +98,6 @@ import { assertCollaboratorsAvailableForWindows, CollaboratorUnavailableError, } from "@/lib/collaborators/availability"; -import { resolveCancellationPolicySnapshot } from "@/lib/payments/operations/cancellation-policy"; import { notifyAppointmentBooked, notifyAppointmentPartiallyScheduled, @@ -3525,6 +3524,24 @@ export class SlotAllocationService { ); } + // #1499 — sessions allocated later inherit the terms the booking was SOLD + // under, read off the row checkout created (the oldest appointment of this + // event). Resolving fresh here would hand a buyer whatever ladder the org + // published since, which is precisely what versioning exists to prevent. A + // reused appointment already carries its own FK, so it is skipped. + let inheritedPolicyId: string | null = null; + if (!reuseAppointmentId) { + const relationField = this.getEventRelationField(eventType); + const originating = await tx.appointment.findFirst({ + where: { + [`${relationField}Id`]: eventId, + } as Prisma.AppointmentWhereInput, + orderBy: { createdAt: "asc" }, + select: { cancellationPolicyId: true }, + }); + inheritedPolicyId = originating?.cancellationPolicyId ?? null; + } + // Create appointment for each call. A concurrent booking that overlaps an // existing confirmed slot trips the #440 exclusion constraint (or the unique // guard); convert it to a typed 409 here at the source so classifyError can @@ -3557,8 +3574,8 @@ export class SlotAllocationService { // #898 — REUSE the preserved 1:1 appointment: attach the new slots to // it rather than creating a second row on the @unique event FK. Its - // event link and booking-time cancellationPolicySnapshot are already - // set, so leave them untouched. + // event link and booking-time cancellationPolicyId are already set, so + // leave them untouched. if (reuseAppointmentId) { return tx.appointment.update({ where: { id: reuseAppointmentId }, @@ -3582,10 +3599,8 @@ export class SlotAllocationService { }, ...idempotencyData, ...(organizationId ? { organizationId } : {}), - // B1 — freeze the refund terms at booking (see cancellation-policy.ts). - cancellationPolicySnapshot: JSON.parse( - JSON.stringify(resolveCancellationPolicySnapshot()), - ), + // B1/#1499 — inherit the terms the booking was sold under. + cancellationPolicyId: inheritedPolicyId, slotsOfAppointment: { create: slotsToCreate, },