diff --git a/__tests__/booking-algorithm/reschedule-respond.test.ts b/__tests__/booking-algorithm/reschedule-respond.test.ts new file mode 100644 index 000000000..0ebe25633 --- /dev/null +++ b/__tests__/booking-algorithm/reschedule-respond.test.ts @@ -0,0 +1,483 @@ +/** + * @jest-environment node + */ + +/** + * #1163 / #1169 PR 4 (API half) — the counterparty's answer to a reschedule + * proposal, exercised as behavior. + * + * This is the only coverage for a new authorization path that moves a booking's + * slots, so it drives the real `acceptProposal` / `declineProposal` and the real + * route against a mocked Prisma and a mocked allocator, and asserts what they + * DO: which times reach the allocator and under which lock, which CAS + * transition is written and from which from-set, which slots are touched (none, + * on decline — that is the module's documented contract), and which status code + * each refusal answers with. + * + * `transitionRescheduleRequest` is deliberately NOT mocked: the from-state guard + * it builds is the thing under test on the lost-race cases. + */ + +const mockRequestFindUnique = jest.fn(); +const mockRequestFindFirst = jest.fn(); +const mockAllocate = jest.fn(); +const mockGetSession = jest.fn(); +const mockHasActiveDispute = jest.fn(); + +const txStub = { + rescheduleRequest: { updateMany: jest.fn() }, + // Present so a decline that wrote slots would be caught rather than silently + // passing: "the released slots stay released" is the contract. + slotOfAppointment: { updateMany: jest.fn() }, +}; + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + $transaction: async (fn: (tx: unknown) => unknown) => fn(txStub), + rescheduleRequest: { + findUnique: (...a: unknown[]) => mockRequestFindUnique(...a), + findFirst: (...a: unknown[]) => mockRequestFindFirst(...a), + }, + }, +})); + +jest.mock("../../utils/slotAllocation/SlotAllocationService", () => ({ + SlotAllocationService: { allocate: (...a: unknown[]) => mockAllocate(...a) }, +})); + +jest.mock("../../lib/auth-server", () => ({ + getSession: (...a: unknown[]) => mockGetSession(...a), +})); + +jest.mock("../../lib/payments/dispute-guard", () => ({ + hasActiveDisputeForAppointment: (...a: unknown[]) => + mockHasActiveDispute(...a), +})); + +jest.mock("../../lib/observability/report", () => ({ + reportSentryError: jest.fn(), +})); + +import fs from "fs"; +import path from "path"; + +import { + acceptProposal, + declineProposal, +} from "@/lib/booking/reschedule-respond"; +import { POST as respondHandler } from "@/app/api/appointments/[appointmentId]/reschedule/respond/route"; + +const HOUR = 3_600_000; +const APPT = "appt-1"; +const REQ = "resched-1"; +const CONSULTANT_USER = "consultant-user-1"; +const CONSULTEE_USER = "consultee-user-1"; + +function makeParams(id: string = APPT) { + return { params: Promise.resolve({ appointmentId: id }) }; +} + +function makeRequest(body: Record = { action: "accept" }) { + return new Request( + `http://localhost/api/appointments/${APPT}/reschedule/respond`, + { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }, + ) as never; +} + +/** The row `acceptProposal` reads. Open and in-date unless told otherwise. */ +function proposalRow(overrides: Record = {}) { + return { + id: REQ, + status: "PENDING_REVIEW", + expiresAt: new Date(Date.now() + 48 * HOUR), + proposedSlots: [ + { startsAt: new Date("2026-09-01T10:00:00.000Z") }, + { startsAt: new Date("2026-09-08T10:00:00.000Z") }, + ], + ...overrides, + }; +} + +/** The row the route reads: a CONSULTANT-initiated proposal on a consultation. */ +function openRequestRow(overrides: Record = {}) { + return { + id: REQ, + initiatedById: CONSULTANT_USER, + appointment: { + consultationId: "cons-1", + subscriptionId: null, + consultation: { + requestedBy: { userId: CONSULTEE_USER }, + consultationPlan: { consultantProfile: { userId: CONSULTANT_USER } }, + }, + subscription: null, + }, + ...overrides, + }; +} + +function sessionOf(userId: string) { + return { user: { id: userId } }; +} + +beforeEach(() => { + jest.clearAllMocks(); + txStub.rescheduleRequest.updateMany.mockResolvedValue({ count: 1 }); + txStub.slotOfAppointment.updateMany.mockResolvedValue({ count: 0 }); + mockAllocate.mockResolvedValue({ success: true }); + mockHasActiveDispute.mockResolvedValue(false); + mockRequestFindUnique.mockResolvedValue(proposalRow()); + mockRequestFindFirst.mockResolvedValue(openRequestRow()); + mockGetSession.mockResolvedValue(sessionOf(CONSULTEE_USER)); +}); + +describe("accept re-validates through the allocator before anything is written", () => { + it("sends the proposed times through manual allocation under the wide lock", async () => { + const out = await acceptProposal({ + rescheduleRequestId: REQ, + eventType: "consultation", + eventId: "cons-1", + resolvedById: CONSULTEE_USER, + }); + + expect(out).toEqual({ done: true }); + // The exact machinery auto-confirm trusts: the allocator does the + // availability / caps / conflict validation, so nothing is hand-written. + expect(mockAllocate).toHaveBeenCalledWith({ + eventType: "consultation", + eventId: "cons-1", + mode: "manual", + slots: [ + "2026-09-01T10:00:00.000Z", + "2026-09-08T10:00:00.000Z", + ], + // Day-sharded keys would let two concurrent confirmations pass a + // per-week cap on stale counts. + wideLock: true, + }); + }); + + it("finalizes ACCEPTED with a from-state guard, not a blind write", async () => { + await acceptProposal({ + rescheduleRequestId: REQ, + eventType: "consultation", + eventId: "cons-1", + resolvedById: CONSULTEE_USER, + }); + + const [args] = txStub.rescheduleRequest.updateMany.mock.calls[0] as [ + { + where: { id: string; status: { in: string[] } }; + data: Record; + }, + ]; + expect(args.where.id).toBe(REQ); + expect(args.where.status.in).toEqual( + expect.arrayContaining(["PENDING_REVIEW", "COUNTERED"]), + ); + // An already-expired row must not be reachable from the accept edge. + expect(args.where.status.in).not.toContain("EXPIRED"); + expect(args.data).toMatchObject({ + status: "ACCEPTED", + resolvedById: CONSULTEE_USER, + openForAppointmentId: null, + }); + }); + + it("writes nothing and leaves the proposal open when the allocator refuses", async () => { + mockAllocate.mockResolvedValue({ + success: false, + errorCode: "SLOT_CONFLICT", + }); + + const out = await acceptProposal({ + rescheduleRequestId: REQ, + eventType: "consultation", + eventId: "cons-1", + resolvedById: CONSULTEE_USER, + }); + + expect(out).toEqual({ done: false, reason: "SLOT_CONFLICT" }); + expect(txStub.rescheduleRequest.updateMany).not.toHaveBeenCalled(); + }); + + it("refuses a lapsed proposal before the allocator is asked", async () => { + // The hourly expiry job leaves a lapsed proposal PENDING_REVIEW for up to + // an hour. Expiry is min(now + 72h, earliest released session − 24h), so + // accepting one is how a booking lands inside the 24-hour window the + // reschedule route refuses to move it into. + mockRequestFindUnique.mockResolvedValue( + proposalRow({ expiresAt: new Date(Date.now() - HOUR) }), + ); + + const out = await acceptProposal({ + rescheduleRequestId: REQ, + eventType: "consultation", + eventId: "cons-1", + resolvedById: CONSULTEE_USER, + }); + + expect(out).toEqual({ done: false, reason: "PROPOSAL_EXPIRED" }); + expect(mockAllocate).not.toHaveBeenCalled(); + expect(txStub.rescheduleRequest.updateMany).not.toHaveBeenCalled(); + }); + + it("has nothing to accept on a preference-only request", async () => { + mockRequestFindUnique.mockResolvedValue(proposalRow({ proposedSlots: [] })); + + const out = await acceptProposal({ + rescheduleRequestId: REQ, + eventType: "consultation", + eventId: "cons-1", + resolvedById: CONSULTEE_USER, + }); + + expect(out).toEqual({ done: false, reason: "NO_PROPOSED_TIMES" }); + expect(mockAllocate).not.toHaveBeenCalled(); + }); + + it("refuses a proposal that was already answered", async () => { + mockRequestFindUnique.mockResolvedValue( + proposalRow({ status: "WITHDRAWN" }), + ); + + const out = await acceptProposal({ + rescheduleRequestId: REQ, + eventType: "consultation", + eventId: "cons-1", + resolvedById: CONSULTEE_USER, + }); + + expect(out).toEqual({ done: false, reason: "PROPOSAL_NOT_OPEN" }); + expect(mockAllocate).not.toHaveBeenCalled(); + }); +}); + +describe("decline ends the request and leaves the released slots released", () => { + it("transitions to DECLINED without touching a single slot", async () => { + const out = await declineProposal({ + rescheduleRequestId: REQ, + resolvedById: CONSULTEE_USER, + }); + + expect(out).toEqual({ done: true }); + const [args] = txStub.rescheduleRequest.updateMany.mock.calls[0] as [ + { data: Record }, + ]; + expect(args.data).toMatchObject({ + status: "DECLINED", + resolvedById: CONSULTEE_USER, + }); + // The documented semantics: the initiator still wants to move, so the + // booking belongs in the consultant's allocate queue. Restoring the slots + // here is withdraw's job, not decline's. + expect(txStub.slotOfAppointment.updateMany).not.toHaveBeenCalled(); + expect(mockAllocate).not.toHaveBeenCalled(); + }); + + it("reports a lost CAS race as a conflict instead of throwing", async () => { + txStub.rescheduleRequest.updateMany.mockResolvedValue({ count: 0 }); + + const out = await declineProposal({ + rescheduleRequestId: REQ, + resolvedById: CONSULTEE_USER, + }); + + expect(out).toEqual({ done: false, reason: "PROPOSAL_NOT_OPEN" }); + }); +}); + +describe("the respond route answers 404 to everyone who is not the counterparty", () => { + it("404s when the booking holds no open request", async () => { + mockRequestFindFirst.mockResolvedValue(null); + + const res = await respondHandler(makeRequest(), makeParams()); + + expect(res.status).toBe(404); + expect(mockAllocate).not.toHaveBeenCalled(); + }); + + it("404s a stranger rather than confirming the booking exists", async () => { + mockGetSession.mockResolvedValue(sessionOf("someone-else")); + + const res = await respondHandler(makeRequest(), makeParams()); + + expect(res.status).toBe(404); + expect(mockAllocate).not.toHaveBeenCalled(); + }); + + it("404s the initiator — they have withdraw, not accept", async () => { + mockGetSession.mockResolvedValue(sessionOf(CONSULTANT_USER)); + + const res = await respondHandler(makeRequest(), makeParams()); + + expect(res.status).toBe(404); + expect(mockAllocate).not.toHaveBeenCalled(); + }); + + it("401s an unauthenticated caller", async () => { + mockGetSession.mockResolvedValue(null); + + const res = await respondHandler(makeRequest(), makeParams()); + + expect(res.status).toBe(401); + }); + + it("400s an action that is neither accept nor decline", async () => { + const res = await respondHandler( + makeRequest({ action: "maybe" }), + makeParams(), + ); + + expect(res.status).toBe(400); + }); +}); + +describe("the respond route drives the loop for the counterparty", () => { + it("accepts and reports the booking as moved", async () => { + const res = await respondHandler(makeRequest(), makeParams()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.accepted).toBe(true); + expect(mockAllocate).toHaveBeenCalledTimes(1); + }); + + it("declines without asking the allocator for anything", async () => { + const res = await respondHandler( + makeRequest({ action: "decline" }), + makeParams(), + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.declined).toBe(true); + expect(mockAllocate).not.toHaveBeenCalled(); + }); + + it("answers 422 — not 409 — when there are no concrete times to accept", async () => { + mockRequestFindUnique.mockResolvedValue(proposalRow({ proposedSlots: [] })); + + const res = await respondHandler(makeRequest(), makeParams()); + const body = await res.json(); + + expect(res.status).toBe(422); + expect(body.code).toBe("NO_PROPOSED_TIMES"); + }); + + it("answers 409 when the proposal lapsed before it was answered", async () => { + mockRequestFindUnique.mockResolvedValue( + proposalRow({ expiresAt: new Date(Date.now() - HOUR) }), + ); + + const res = await respondHandler(makeRequest(), makeParams()); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.code).toBe("PROPOSAL_EXPIRED"); + }); + + it("answers 409 when the allocator cannot confirm the times", async () => { + mockAllocate.mockResolvedValue({ + success: false, + errorCode: "OUTSIDE_AVAILABILITY", + }); + + const res = await respondHandler(makeRequest(), makeParams()); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.code).toBe("OUTSIDE_AVAILABILITY"); + }); + + it("resolves a subscription proposal against the subscription event", async () => { + mockRequestFindFirst.mockResolvedValue( + openRequestRow({ + appointment: { + consultationId: null, + subscriptionId: "sub-1", + consultation: null, + subscription: { + requestedBy: { userId: CONSULTEE_USER }, + subscriptionPlan: { + consultantProfile: { userId: CONSULTANT_USER }, + }, + }, + }, + }), + ); + + const res = await respondHandler(makeRequest(), makeParams()); + + expect(res.status).toBe(200); + expect(mockAllocate).toHaveBeenCalledWith( + expect.objectContaining({ eventType: "subscription", eventId: "sub-1" }), + ); + }); +}); + +describe("#1008 — a disputed booking is frozen against acceptance", () => { + it("refuses to move the slots while a payment dispute is live", async () => { + mockHasActiveDispute.mockResolvedValue(true); + + const res = await respondHandler(makeRequest(), makeParams()); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.code).toBe("DISPUTE_ACTIVE"); + expect(mockAllocate).not.toHaveBeenCalled(); + }); + + it("still 404s a stranger, so the guard is not a dispute oracle", async () => { + mockHasActiveDispute.mockResolvedValue(true); + mockGetSession.mockResolvedValue(sessionOf("someone-else")); + + const res = await respondHandler(makeRequest(), makeParams()); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(body.code).toBeUndefined(); + }); + + it("lets the counterparty decline — decline moves nothing", async () => { + mockHasActiveDispute.mockResolvedValue(true); + + const res = await respondHandler( + makeRequest({ action: "decline" }), + makeParams(), + ); + + expect(res.status).toBe(200); + }); +}); + +/** + * Wiring checks, deliberately not behavioral: these two side-effects fire in + * the participants routes and the Razorpay webhook handler, which own their own + * suites and harnesses. What this PR changed is that the calls exist at all, so + * that is what is guarded here. + */ +describe("lifecycle hygiene wiring", () => { + const read = (rel: string) => + fs.readFileSync(path.join(process.cwd(), rel), "utf8"); + + it("removed attendees lose event-channel access at refund time", () => { + for (const rel of [ + "app/api/participants/webinar/[webinarId]/route.ts", + "app/api/participants/class/[classId]/route.ts", + ]) { + expect(read(rel)).toContain("removeUserFromEventChannel("); + } + }); + + it("the booked notification carries the session time (#1085)", () => { + expect(read("lib/payments/webhooks/handlers.ts")).toContain( + "dateTime: firstSlot?.startsAt.toISOString()", + ); + }); +}); diff --git a/__tests__/payments/cancel-route-refund.test.ts b/__tests__/payments/cancel-route-refund.test.ts index 45b7dc39d..1f9b1f5a6 100644 --- a/__tests__/payments/cancel-route-refund.test.ts +++ b/__tests__/payments/cancel-route-refund.test.ts @@ -25,6 +25,7 @@ const mockPaymentFindMany = jest.fn(); const mockRefundBookingPayment = jest.fn(); const mockRecordSystemError = jest.fn(); const mockGetSession = jest.fn(); +const mockMembershipFindUnique = jest.fn(); /** Flipped by the $transaction stub, mirroring the slot terminalisation. */ let txCommitted = false; @@ -54,6 +55,8 @@ jest.mock("../../lib/prisma", () => ({ }, payment: { findMany: (...a: unknown[]) => mockPaymentFindMany(...a) }, dispute: { findFirst: jest.fn().mockResolvedValue(null) }, + // #1166 — what `isOrgAdminOfAppointment` reads. + membership: { findUnique: (...a: unknown[]) => mockMembershipFindUnique(...a) }, }, })); @@ -164,8 +167,15 @@ function subscriptionAppointment() { function bookingRows(opts: { liveSlotHours?: number[]; completedSlotHours?: number[]; + /** Sessions terminalised BEFORE this cancellation — a session the plan held. */ + cancelledSlotHours?: number[]; + /** Past sessions with no MeetingSession row (offline, most likely held). */ + unverifiedSlotHours?: number[]; paymentRefunds?: { amountPaise: number; status: string }[]; noPayment?: boolean; + /** Defaults to a gateway-funded payment. */ + paymentAmount?: number; + paymentIntent?: string; }) { const live = (opts.liveSlotHours ?? []).map((h) => ({ startsAt: new Date(Date.now() + h * HOUR), @@ -175,6 +185,14 @@ function bookingRows(opts: { startsAt: new Date(Date.now() + h * HOUR), completionStatus: "COMPLETED", })); + const gone = (opts.cancelledSlotHours ?? []).map((h) => ({ + startsAt: new Date(Date.now() + h * HOUR), + completionStatus: "CANCELLED", + })); + const unverified = (opts.unverifiedSlotHours ?? []).map((h) => ({ + startsAt: new Date(Date.now() + h * HOUR), + completionStatus: "UNVERIFIED", + })); return [ { id: APPT, @@ -184,12 +202,13 @@ function bookingRows(opts: { : [ { id: "pay-1", - amount: GROSS, + amount: opts.paymentAmount ?? GROSS, + paymentIntent: opts.paymentIntent ?? "pi_gateway_1", refunds: opts.paymentRefunds ?? [], disputes: [], }, ], - slotsOfAppointment: [...done, ...live], + slotsOfAppointment: [...done, ...gone, ...unverified, ...live], }, ]; } @@ -232,6 +251,7 @@ beforeEach(() => { txStub.slotOfAppointment.updateMany.mockResolvedValue({ count: 2 }); txStub.rescheduleRequest.updateMany.mockResolvedValue({ count: 0 }); mockPaymentFindMany.mockResolvedValue([]); + mockMembershipFindUnique.mockResolvedValue(null); mockRecordSystemError.mockResolvedValue(undefined); mockRefundBookingPayment.mockImplementation( async ({ amountPaise }: { amountPaise: number }) => ({ @@ -379,7 +399,11 @@ describe("subscriptions", () => { expect(body.refund.amountRefundedPaise).toBe(GROSS); }); - it("escalates a partly-consumed plan instead of guessing a proration", async () => { + it("prorates a partly-consumed plan to the undelivered share (#1006)", async () => { + // This case used to escalate to MANUAL_REVIEW and refund ₹0, which killed + // the remaining sessions while returning nothing. #1006 replaced the + // escalation with the linear rule: the base is the undelivered share, and + // the frozen policy tier applies to that base. mockGetSession.mockResolvedValue(sessionAs("consultee")); mockAppointmentFindUnique.mockResolvedValue(subscriptionAppointment()); mockAppointmentFindMany.mockImplementation(async () => @@ -389,19 +413,211 @@ describe("subscriptions", () => { const res = await cancelHandler(makeRequest(), makeParams(APPT)); const body = await res.json(); - expect(body.refund.requiresManualReview).toBe(true); - expect(body.refund.status).toBe("MANUAL_REVIEW"); + // 1 of 3 delivered, next session 72h out, so the 100% tier applies to + // two-thirds of the price: floor(500000 × 2/3) = 333333. + const undeliveredShare = Math.floor((GROSS * 2) / 3); + expect(body.refund.status).toBe("REFUNDED"); + expect(body.refund.refundPct).toBe(100); + expect(body.refund.amountRefundedPaise).toBe(undeliveredShare); + expect(mockRefundBookingPayment).toHaveBeenCalledWith( + expect.objectContaining({ amountPaise: undeliveredShare }), + ); + // The rule is agreed now, so nothing is owed to an ops queue. + expect(mockRecordSystemError).not.toHaveBeenCalled(); + }); + + it("measures the undelivered share against the WHOLE plan, not the surviving part", async () => { + // A session cancelled earlier still belongs to the plan the buyer bought. + // Summing only completed + live drops it out of the denominator, and the + // remaining share is then measured against a plan that has shrunk. + mockGetSession.mockResolvedValue(sessionAs("consultee")); + mockAppointmentFindUnique.mockResolvedValue(subscriptionAppointment()); + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ + completedSlotHours: [-48], + cancelledSlotHours: [-24], + liveSlotHours: [72, 96], + }), + ); + + const res = await cancelHandler(makeRequest(), makeParams(APPT)); + const body = await res.json(); + + // 4 sessions bought, 2 still owed: the 100% tier applies to HALF the price. + // Against a completed+live denominator of 3 this would pay floor(2/3) — + // ₹833 more than the plan's per-session price justifies. + expect(body.refund.amountRefundedPaise).toBe(GROSS / 2); + expect(body.refund.amountRefundedPaise).not.toBe(Math.floor((GROSS * 2) / 3)); + }); + + it("counts an unverified past session as delivered, not as owed", async () => { + // UNVERIFIED is "past, no MeetingSession row" — an offline session that most + // likely happened. It is neither COMPLETED nor live, so a completed+live + // denominator made a 30%-consumed plan score 7/7 and refund the whole price. + mockGetSession.mockResolvedValue(sessionAs("consultee")); + mockAppointmentFindUnique.mockResolvedValue(subscriptionAppointment()); + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ + unverifiedSlotHours: [-72, -48, -24], + liveSlotHours: [72, 96, 120, 144, 168, 192, 216], + }), + ); + + const res = await cancelHandler(makeRequest(), makeParams(APPT)); + const body = await res.json(); + + expect(body.refund.refundPct).toBe(100); + expect(body.refund.amountRefundedPaise).toBe(Math.floor((GROSS * 7) / 10)); + expect(body.refund.amountRefundedPaise).not.toBe(GROSS); + }); +}); + +describe("#1161 — a credit-funded booking refunds as a credit restoration", () => { + /** Fully covered by referral credits: zero captured, synthetic intent. */ + const freeFunded = { + paymentAmount: 0, + paymentIntent: "free_1730000000000_ab12cd34", + }; + + it("restores in full and reports what came back, not a hardcoded zero", async () => { + mockGetSession.mockResolvedValue(sessionAs("consultee")); + mockAppointmentFindUnique.mockResolvedValue(consultationAppointment()); + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ liveSlotHours: [120], ...freeFunded }), + ); + mockRefundBookingPayment.mockResolvedValue({ + refundId: "r-internal", + amountRefundedPaise: 25_000, + rail: "INTERNAL", + }); + + const res = await cancelHandler(makeRequest(), makeParams(APPT)); + const body = await res.json(); + + // The restoration rail is reached at all — the amount floor on the payment + // lookup used to make this branch dead code. + expect(mockRefundBookingPayment).toHaveBeenCalledWith( + expect.objectContaining({ paymentId: "pay-1" }), + ); + expect(body.refund.status).toBe("REFUNDED"); + expect(body.refund.amountRefundedPaise).toBe(25_000); + }); + + it("escalates a partial window instead of guessing a partial restoration", async () => { + mockGetSession.mockResolvedValue(sessionAs("consultee")); + mockAppointmentFindUnique.mockResolvedValue(consultationAppointment()); + // Inside the day: a partial tier, which credit restoration has no rule for. + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ liveSlotHours: [12], ...freeFunded }), + ); + + const res = await cancelHandler(makeRequest(), makeParams(APPT)); + const body = await res.json(); + expect(mockRefundBookingPayment).not.toHaveBeenCalled(); - // A Sentry breadcrumb is not a queue — this has to be durable. + expect(body.refund.status).toBe("MANUAL_REVIEW"); + expect(body.refund.requiresManualReview).toBe(true); expect(mockRecordSystemError).toHaveBeenCalledWith( - expect.objectContaining({ - category: "PAYMENT", - context: expect.objectContaining({ - sessionsCompleted: 1, - sessionsRemaining: 2, - }), - }), + expect.objectContaining({ category: "PAYMENT" }), + ); + }); + + it("surfaces a failed restoration rather than reporting it as refunded", async () => { + mockGetSession.mockResolvedValue(sessionAs("consultee")); + mockAppointmentFindUnique.mockResolvedValue(consultationAppointment()); + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ liveSlotHours: [120], ...freeFunded }), + ); + mockRefundBookingPayment.mockRejectedValue(new Error("no refundable balance")); + + const res = await cancelHandler(makeRequest(), makeParams(APPT)); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.refund.status).toBe("FAILED"); + expect(body.refund.requiresManualReview).toBe(true); + }); +}); + +describe("#1166 — an admin of the funding org acts on the payer side", () => { + function orgFundedAppointment() { + return { ...consultationAppointment(), organizationId: "org-1" }; + } + + it("lets an OWNER of the funding org cancel, and tiers them as the buyer", async () => { + mockGetSession.mockResolvedValue({ + user: { + id: "org-owner-1", + name: "Org Owner", + consultantProfileId: null, + consulteeProfileId: null, + }, + }); + mockAppointmentFindUnique.mockResolvedValue(orgFundedAppointment()); + mockMembershipFindUnique.mockResolvedValue({ + status: "ACTIVE", + role: "OWNER", + }); + // Inside the final two hours: the buyer's own tier pays nothing here, while + // the consultant tier would pay in full. The org admin must score as the + // buyer they act for. + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ liveSlotHours: [1] }), + ); + + const res = await cancelHandler(makeRequest(), makeParams(APPT)); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.refund.refundPct).toBe(0); + expect(body.refund.amountRefundedPaise).toBe(0); + }); + + it("refuses an EXPERT of the same org", async () => { + mockGetSession.mockResolvedValue({ + user: { + id: "org-expert-1", + name: "Org Expert", + consultantProfileId: null, + consulteeProfileId: null, + }, + }); + mockAppointmentFindUnique.mockResolvedValue(orgFundedAppointment()); + mockMembershipFindUnique.mockResolvedValue({ + status: "ACTIVE", + role: "EXPERT", + }); + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ liveSlotHours: [120] }), ); + + const res = await cancelHandler(makeRequest(), makeParams(APPT)); + + expect(res.status).toBe(403); + expect(mockRefundBookingPayment).not.toHaveBeenCalled(); + }); + + it("refuses an invited-but-inactive admin", async () => { + mockGetSession.mockResolvedValue({ + user: { + id: "org-owner-2", + name: "Pending Owner", + consultantProfileId: null, + consulteeProfileId: null, + }, + }); + mockAppointmentFindUnique.mockResolvedValue(orgFundedAppointment()); + mockMembershipFindUnique.mockResolvedValue({ + status: "INVITED", + role: "OWNER", + }); + mockAppointmentFindMany.mockImplementation(async () => + bookingRows({ liveSlotHours: [120] }), + ); + + const res = await cancelHandler(makeRequest(), makeParams(APPT)); + + expect(res.status).toBe(403); }); }); diff --git a/app/api/appointments/[appointmentId]/cancel/route.ts b/app/api/appointments/[appointmentId]/cancel/route.ts index f65242a96..dbfd10417 100644 --- a/app/api/appointments/[appointmentId]/cancel/route.ts +++ b/app/api/appointments/[appointmentId]/cancel/route.ts @@ -17,6 +17,7 @@ import { getSession } from "@/lib/auth-server"; import { isPrivileged } from "@/lib/auth-helpers"; import { recordSystemError } from "@/lib/enterprise/system-events"; import { refundBookingPayment } from "@/lib/payments/operations/booking-refund"; +import { isOrgAdminOfAppointment } from "@/lib/booking/org-actor"; import { resolveBookingRefundContext } from "@/lib/booking/cancellation-scope"; import { refundWholeEventPayments, @@ -169,7 +170,18 @@ export async function POST( const isPrivilegedUser = isPrivileged(session.user.role); - if (!isParticipant && !isPrivilegedUser) { + // #1166 — an admin of the org that FUNDS this booking may cancel it. They + // act on the payer side: the tier logic below must never read them as + // consultant-initiated. + const isOrgAdminActor = + !isParticipant && + !isPrivilegedUser && + (await isOrgAdminOfAppointment( + session.user.id, + appointment.organizationId, + )); + + if (!isParticipant && !isPrivilegedUser && !isOrgAdminActor) { return NextResponse.json( { error: "You are not authorized to cancel this appointment" }, { status: 403 }, @@ -428,6 +440,14 @@ export async function POST( session.user.id !== undefined && 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). + const isFreeCreditFunded = + paidPayment.amountPaise === 0 && + paidPayment.paymentIntent.startsWith("free_"); // A booking with no session ever scheduled has INFINITE notice, not // negative notice. Mapping "never allocated" onto the same -1 as // "already started" made cancelling earlier score worse than @@ -450,47 +470,81 @@ export async function POST( // payment with an earlier partial refund the gross overshoots, the // operation throws AMOUNT_EXCEEDS_REFUNDABLE, and the catch below turns // that into "refunded 0" — the buyer loses the remainder they were owed. + // #1006 — linear per-session proration. The refundable base is the + // undelivered share of the plan price; the policy tier then applies to + // that base. A fully-undelivered subscription reduces to the old + // whole-price behavior. + // The denominator is every session the plan ever held time for, which + // is `slotsTotal` — NOT completed+live. Summing only those two drops + // every terminal-but-not-completed session out of the plan, and the + // undelivered share is then measured against a plan that has shrunk: + // three UNVERIFIED past sessions (held offline, no MeetingSession row) + // and seven live ones scored 7/7 and refunded the whole price for a + // plan that was 30% consumed. + // + // `slotsTotal === 0` keeps the full gross deliberately: that is the + // never-scheduled plan, which `neverScheduled` above already tiers at + // 100%. Zeroing the base there would refund nothing for a plan the + // buyer paid for and never received a minute of. + const proratedBasePaise = + appointment.subscription && bookingCtx.slotsTotal > 0 + ? Math.floor( + (paidPayment.amountPaise * bookingCtx.sessionsRemaining) / + bookingCtx.slotsTotal, + ) + : paidPayment.amountPaise; const refundAmount = Math.min( - Math.floor((paidPayment.amountPaise * refundPct) / 100), + Math.floor((proratedBasePaise * refundPct) / 100), paidPayment.refundablePaise, ); - // #1006 — a subscription that has already delivered sessions has no - // agreed proration rule, and the tier alone would hand back the full - // plan price for sessions the consultant has already held. The - // cancellation stands; the refund is escalated rather than guessed. - const needsProrationRule = - !!appointment.subscription && bookingCtx.sessionsCompleted > 0; - - if (needsProrationRule) { - // Durable, not just a log line: this is money owed to a buyer whose - // sessions have already been cancelled, so it has to land somewhere an - // ops surface actually drains. recordSystemError is that surface. - await recordSystemError({ - organizationId: appointment.organizationId ?? null, - category: "PAYMENT", - summary: - `Cancelled a partially-consumed subscription (${bookingCtx.sessionsCompleted} of ` + - `${bookingCtx.sessionsCompleted + bookingCtx.sessionsRemaining} sessions delivered); ` + - `refund needs the #1006 proration rule and is owed manually`, - err: new Error("SUBSCRIPTION_PRORATION_UNDEFINED"), - context: { - appointmentId, - subscriptionId: appointment.subscription?.id, - paymentId: paidPayment.id, - paidPaise: paidPayment.amountPaise, - refundablePaise: paidPayment.refundablePaise, - sessionsCompleted: bookingCtx.sessionsCompleted, - sessionsRemaining: bookingCtx.sessionsRemaining, - tierRefundPct: refundPct, - }, - }).catch(() => {}); - refund = { - amountRefundedPaise: 0, - refundPct, - status: "MANUAL_REVIEW", - requiresManualReview: true, - }; + // 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, + }; + } 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 { + 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 }, + }).catch(() => {}); + refund = { + amountRefundedPaise: 0, + refundPct, + status: "MANUAL_REVIEW", + requiresManualReview: true, + }; + } } else if (refundAmount > 0) { try { const r = await refundBookingPayment({ @@ -650,10 +704,14 @@ export async function POST( name: session.user.name || "User", image: session.user.image, }; + // #1169 PR 4 — three-way, matching the notification payload above: a + // platform/org actor is "system", never mislabeled as the consultee. const cancelledBy = session.user.id === notificationMeta.consultantUserId ? ("consultant" as const) - : ("consultee" as const); + : isParticipant + ? ("consultee" as const) + : ("system" as const); if (appointment.consultation) { const cpId = diff --git a/app/api/appointments/[appointmentId]/reschedule/respond/route.ts b/app/api/appointments/[appointmentId]/reschedule/respond/route.ts new file mode 100644 index 000000000..81212b452 --- /dev/null +++ b/app/api/appointments/[appointmentId]/reschedule/respond/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getSession } from "@/lib/auth-server"; +import prisma from "@/lib/prisma"; +import { apiError } from "@/lib/errors"; +import { + acceptProposal, + declineProposal, +} from "@/lib/booking/reschedule-respond"; +import { RESCHEDULE_OPEN_STATUSES } from "@/lib/booking/transitions"; +import { hasActiveDisputeForAppointment } from "@/lib/payments/dispute-guard"; +import type { EventType } from "@/utils/slotAllocation/types"; + +const RespondSchema = z.object({ action: z.enum(["accept", "decline"]) }); + +/** Why an accept was refused, in the counterparty's words. */ +const ACCEPT_FAILURE_COPY: Record = { + NO_PROPOSED_TIMES: + "This request proposes no concrete times — place times on the calendar instead.", + PROPOSAL_EXPIRED: + "This proposal has expired. The released times are back with the consultant to place.", +}; +const ACCEPT_FAILURE_FALLBACK = "The proposed times could not be confirmed."; + +/** + * POST /api/appointments/[appointmentId]/reschedule/respond + * + * The counterparty answers the open proposal (#1163). Accept re-validates the + * proposed times through the full allocator; decline ends the request and + * deliberately leaves the released slots in the consultant's allocate queue. + * The initiator has withdraw, which is the restoring exit. + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ appointmentId: string }> }, +) { + try { + const { appointmentId } = await params; + const session = await getSession(true); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const parsed = RespondSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { error: "action must be \"accept\" or \"decline\"" }, + { status: 400 }, + ); + } + + const open = await prisma.rescheduleRequest.findFirst({ + where: { + appointmentId, + status: { in: RESCHEDULE_OPEN_STATUSES }, + }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + initiatedById: true, + appointment: { + select: { + consultationId: true, + subscriptionId: true, + consultation: { + select: { + requestedBy: { select: { userId: true } }, + consultationPlan: { + select: { consultantProfile: { select: { userId: true } } }, + }, + }, + }, + subscription: { + select: { + requestedBy: { select: { userId: true } }, + subscriptionPlan: { + select: { consultantProfile: { select: { userId: true } } }, + }, + }, + }, + }, + }, + }, + }); + + // Same anti-oracle discipline as the withdraw route: "no open request", + // "not a participant" and "you are the initiator" all answer 404, so this + // route cannot be walked to learn which bookings hold live reschedules. + // Read each relation on its own rather than casting the union: a cast still + // compiles when the select shape changes, and would silently drop the + // consultant from the authorization set. + const consultation = open?.appointment?.consultation; + const subscription = open?.appointment?.subscription; + const participants = [ + consultation?.requestedBy?.userId ?? subscription?.requestedBy?.userId, + consultation?.consultationPlan?.consultantProfile?.userId ?? + subscription?.subscriptionPlan?.consultantProfile?.userId, + ].filter((id): id is string => !!id); + const isCounterparty = + !!open && + participants.includes(session.user.id) && + open.initiatedById !== session.user.id; + if (!open || !isCounterparty) { + return NextResponse.json( + { error: "No open reschedule request for this booking." }, + { status: 404 }, + ); + } + + if (parsed.data.action === "decline") { + const result = await declineProposal({ + rescheduleRequestId: open.id, + resolvedById: session.user.id, + }); + if (!result.done) { + return NextResponse.json( + { error: "This proposal can no longer be answered.", code: result.reason }, + { status: 409 }, + ); + } + return NextResponse.json({ + declined: true, + message: + "Proposal declined. The released times stay in the allocate queue until new times are placed.", + }); + } + + // #1008 — accept MOVES the booking's slots to new times, and a booking with + // a live payment dispute is frozen: its state is evidence and must not move + // while the dispute is contested. Both sibling routes (cancel, reschedule) + // refuse the same movement. + // + // Deliberately placed AFTER the counterparty gate, not before it: answering + // 409 to an unauthorized caller would turn this route into the dispute + // oracle the 404 discipline above exists to prevent. Decline is exempt — + // it moves nothing (the slots were released when the proposal opened, and + // the hourly expiry job reaches the same terminal state regardless). + if (await hasActiveDisputeForAppointment(appointmentId)) { + return NextResponse.json( + { + error: + "This appointment has an open payment dispute and can't be rescheduled until it resolves.", + code: "DISPUTE_ACTIVE", + }, + { status: 409 }, + ); + } + + let eventType: EventType | null = null; + if (open.appointment?.consultationId) eventType = "consultation"; + else if (open.appointment?.subscriptionId) eventType = "subscription"; + const eventId = + open.appointment?.consultationId ?? open.appointment?.subscriptionId; + if (!eventType || !eventId) { + return NextResponse.json( + { error: "This booking type cannot accept proposals." }, + { status: 422 }, + ); + } + + const result = await acceptProposal({ + rescheduleRequestId: open.id, + eventType, + eventId, + resolvedById: session.user.id, + }); + if (!result.done) { + // Only "there is nothing here to accept" is a request-shape problem; every + // other refusal is a state conflict. + const status = result.reason === "NO_PROPOSED_TIMES" ? 422 : 409; + return NextResponse.json( + { + error: ACCEPT_FAILURE_COPY[result.reason] ?? ACCEPT_FAILURE_FALLBACK, + code: result.reason, + }, + { status }, + ); + } + return NextResponse.json({ + accepted: true, + message: "Proposal accepted — the booking has moved to the proposed times.", + }); + } catch (error) { + return apiError({ tag: "[Reschedule.Respond]", error }); + } +} diff --git a/app/api/appointments/[appointmentId]/reschedule/route.ts b/app/api/appointments/[appointmentId]/reschedule/route.ts index a596d9805..3ab334acc 100644 --- a/app/api/appointments/[appointmentId]/reschedule/route.ts +++ b/app/api/appointments/[appointmentId]/reschedule/route.ts @@ -33,6 +33,7 @@ import { RESCHEDULABLE_FROM, SLOT_RESCHEDULABLE_FROM, } from "@/lib/booking/transitions"; +import { isOrgAdminOfAppointment } from "@/lib/booking/org-actor"; import { IllegalTransitionError } from "@/lib/enterprise/transitions"; import { isUniqueViolation } from "@/lib/db/pg-errors"; @@ -143,6 +144,22 @@ export async function POST( // No body or invalid JSON - that's fine, every field here is optional } + // #1166 — resolve the funding org's admin membership BEFORE the interactive + // transaction opens. `isOrgAdminOfAppointment` runs on the global client, so + // calling it from inside the callback issues a query on a SECOND pooled + // connection while this transaction is already holding one — the exact + // shape #908 documents (below, on the auto-confirm check) as having 500'd + // with "Unable to start a transaction in the given time". A membership row + // is not transaction state, so reading it early costs nothing. + const orgScope = await prisma.appointment.findUnique({ + where: { id: appointmentId }, + select: { organizationId: true }, + }); + const actorIsFundingOrgAdmin = await isOrgAdminOfAppointment( + session.user.id, + orgScope?.organizationId, + ); + // Start transaction const result = await prisma.$transaction( async (tx) => { @@ -237,7 +254,16 @@ export async function POST( // Allow ADMIN/STAFF bypass const isPrivilegedUser = isPrivileged(session.user.role); - if (!isParticipant && !isPrivilegedUser) { + // #1166 — an admin of the FUNDING org may reschedule the booking. They + // act on the payer side, so their proposals carry the CONSULTEE role: + // same auto-confirm consent semantics as the buyer they act for. + const isOrgAdminActor = + !isParticipant && !isPrivilegedUser && actorIsFundingOrgAdmin; + if (isOrgAdminActor) { + initiatorRole = "CONSULTEE"; + } + + if (!isParticipant && !isPrivilegedUser && !isOrgAdminActor) { throw new RescheduleAuthorizationError(); } diff --git a/app/api/participants/class/[classId]/route.ts b/app/api/participants/class/[classId]/route.ts index 1b13ad35d..9edc1cf4f 100644 --- a/app/api/participants/class/[classId]/route.ts +++ b/app/api/participants/class/[classId]/route.ts @@ -7,6 +7,7 @@ import { forbiddenResponse, } from "@/lib/auth-helpers"; import { refundRemovedAttendeeSeat } from "@/lib/payments/operations/event-refunds"; +import { removeUserFromEventChannel } from "@/actions/stream/chat/event-channel.action"; import { findLiveEventSlot } from "@/lib/appointments/live-event-slot"; import { applyRateLimit, @@ -229,6 +230,25 @@ export async function DELETE( initiatedBy: isSelfLeave ? "attendee" : "organiser", }); + // #1169 PR 4 — a removed/refunded attendee must not keep reading the event + // chat until the nightly expiry job notices. Non-throwing by contract. + const channelRemoval = await removeUserFromEventChannel( + "class", + classId, + userId, + ); + if (!channelRemoval.success) { + console.warn( + JSON.stringify({ + event: "attendee_channel_removal_failed", + eventType: "class", + eventId: classId, + userId, + timestamp: new Date().toISOString(), + }), + ); + } + return NextResponse.json({ removed: true, refund }); } catch (error) { Sentry.captureException( diff --git a/app/api/participants/webinar/[webinarId]/route.ts b/app/api/participants/webinar/[webinarId]/route.ts index f5e6256f6..2308a49df 100644 --- a/app/api/participants/webinar/[webinarId]/route.ts +++ b/app/api/participants/webinar/[webinarId]/route.ts @@ -7,6 +7,7 @@ import { forbiddenResponse, } from "@/lib/auth-helpers"; import { refundRemovedAttendeeSeat } from "@/lib/payments/operations/event-refunds"; +import { removeUserFromEventChannel } from "@/actions/stream/chat/event-channel.action"; import { findLiveEventSlot } from "@/lib/appointments/live-event-slot"; import { applyRateLimit, @@ -229,6 +230,25 @@ export async function DELETE( initiatedBy: isSelfLeave ? "attendee" : "organiser", }); + // #1169 PR 4 — a removed/refunded attendee must not keep reading the event + // chat until the nightly expiry job notices. Non-throwing by contract. + const channelRemoval = await removeUserFromEventChannel( + "webinar", + webinarId, + userId, + ); + if (!channelRemoval.success) { + console.warn( + JSON.stringify({ + event: "attendee_channel_removal_failed", + eventType: "webinar", + eventId: webinarId, + userId, + timestamp: new Date().toISOString(), + }), + ); + } + return NextResponse.json({ removed: true, refund }); } catch (error) { Sentry.captureException( diff --git a/docs/booking/07-rescheduling-flow.md b/docs/booking/07-rescheduling-flow.md index 39090ae87..d3aa6adb2 100644 --- a/docs/booking/07-rescheduling-flow.md +++ b/docs/booking/07-rescheduling-flow.md @@ -1148,3 +1148,10 @@ In practice, this edge case is unlikely because sessions rarely span midnight. B - [API Reference](./04-api-reference.md) -- Validate and allocate endpoints (used after reschedule) - [Rescheduling Payment Flow](../payments/cancellations-rescheduling/02-rescheduling-payment-flow.md) -- Payment reuse details - [Cancellation Payment Flow](../payments/cancellations-rescheduling/01-cancellation-payment-flow.md) -- When user cancels instead of rescheduling + +## The response loop (2026-08-14, #1163 / #1169 PR 4) + +A proposal can now be answered by the other side. `POST /api/appointments/[appointmentId]/reschedule/respond` with `{ "action": "accept" }` re-validates the proposed times through the full allocator (manual mode under the consultant-wide lock — the same machinery auto-confirm uses, so nothing is written unless validation commits) and finalizes the request to `ACCEPTED`; `{ "action": "decline" }` is a guarded transition to `DECLINED` that deliberately leaves the released slots in the consultant's allocate queue, because the initiator still wants to move. The initiator's own exit remains `withdraw`, which restores the booking. Authorization is the counterparty alone, with the withdraw route's anti-oracle 404 discipline. The consultee's event reads now carry the live proposal (`rescheduleRequests` with `proposedSlots`), so a consultant-initiated reschedule finally renders on the consultee side instead of an indefinite "Awaiting schedule confirmation". Admins of the organization funding a booking may cancel and reschedule it, acting on the payer side of the policy tiers; their proposals carry the consultee role, so the same auto-confirm consent rules apply. + +Two refusals guard the accept path specifically, because accept is the action that moves the booking's slots to new times. A proposal that has passed its `expiresAt` is refused with `PROPOSAL_EXPIRED` before the allocator is asked for anything. The deadline cannot be inferred from the status alone: `expireRescheduleProposals` runs hourly, so a lapsed proposal remains `PENDING_REVIEW` for up to an hour after it stops being answerable. This matters beyond tidiness, because the deadline is `min(now + 72h, earliest released session − 24h)` — accepting a lapsed proposal is precisely how a booking would land inside the 24-hour window that the reschedule route itself refuses to move it into. The race between that check and the final transition needs no lock of its own, since `EXPIRED` is not an allowed from-state for `ACCEPTED` and a cron that wins the race therefore makes the transition fail rather than accept. A booking carrying a live payment dispute is refused with `DISPUTE_ACTIVE`, matching the freeze that the cancel and reschedule routes already apply: while a dispute is contested the booking's state is evidence and must not move. That guard sits deliberately after the counterparty check rather than before it, because answering `409` to an unauthorized caller would turn the endpoint into the dispute oracle that the surrounding 404 discipline exists to prevent. Decline is exempt from both refusals, as it moves nothing. + diff --git a/lib/activity/log-activity.ts b/lib/activity/log-activity.ts index 00827da39..8f88bd3dc 100644 --- a/lib/activity/log-activity.ts +++ b/lib/activity/log-activity.ts @@ -110,7 +110,7 @@ export async function logConsultationCancelled( consultationId: string, actor: ActivityActor, planTitle: string, - cancelledBy: "consultant" | "consultee", + cancelledBy: "consultant" | "consultee" | "system", ) { return logActivity({ activityType: "CONSULTATION_CANCELLED", @@ -174,7 +174,7 @@ export async function logSubscriptionCancelled( subscriptionId: string, actor: ActivityActor, planTitle: string, - cancelledBy: "consultant" | "consultee", + cancelledBy: "consultant" | "consultee" | "system", ) { return logActivity({ activityType: "SUBSCRIPTION_CANCELLED", diff --git a/lib/booking/cancellation-scope.ts b/lib/booking/cancellation-scope.ts index 32624fdd3..f2c95285f 100644 --- a/lib/booking/cancellation-scope.ts +++ b/lib/booking/cancellation-scope.ts @@ -37,11 +37,13 @@ import { const LIVE_SLOT_STATUSES = ["SCHEDULED", "RESCHEDULED"] as const; export type BookingRefundContext = { - /** The single SUCCEEDED, non-zero payment funding this booking, if any. */ + /** The single SUCCEEDED payment funding this booking (zero-amount credit-funded included), if any. */ paidPayment: { id: string; /** Gross captured — the base the policy percentage applies to. */ amountPaise: number; + /** #1161 — free_ (credit-funded) detection for the restoration rail. */ + paymentIntent: string; /** * Gross less anything already given back. Callers must clamp to this: a * percentage of the gross overshoots on a payment with an earlier partial @@ -114,13 +116,19 @@ export async function resolveBookingRefundContext( payment: { where: { paymentStatus: "SUCCEEDED", - amount: { gt: 0 }, + // #1161 — no amount floor: a fully-credit-funded payment (amount 0, + // free_ intent) must surface here or the cancel route's credit- + // restoration branch can never fire (it was dead code behind this + // filter — caught by the #1180 preview work). deletedAt: null, ...(payerUserId ? { userId: payerUserId } : {}), }, select: { id: true, amount: true, + // #1161 — free_ detection: a fully-credit-funded payment refunds as + // credit restoration, which the amount-based tier math cannot see. + paymentIntent: true, ...REFUNDABLE_BALANCE_SELECT, }, orderBy: { createdAt: "asc" }, @@ -164,6 +172,7 @@ export async function resolveBookingRefundContext( ? { id: payment.id, amountPaise: Number(payment.amount), + paymentIntent: payment.paymentIntent, refundablePaise: refundableBalancePaise(Number(payment.amount), payment), } : null; diff --git a/lib/booking/org-actor.ts b/lib/booking/org-actor.ts new file mode 100644 index 000000000..1eaa0d894 --- /dev/null +++ b/lib/booking/org-actor.ts @@ -0,0 +1,23 @@ +import prisma from "@/lib/prisma"; + +/** + * #1166 ORG-9 half — lifecycle authorization for the org that funds a booking. + * An OWNER/MAINTAINER of the appointment's organization may cancel or + * reschedule it: the org is the payer, so they act on the PAYER side of the + * policy tiers (never the consultant side). EXPERT and other roles are + * deliberately excluded, matching the availability route's admin floor. + */ +export async function isOrgAdminOfAppointment( + userId: string, + organizationId: string | null | undefined, +): Promise { + if (!organizationId) return false; + const membership = await prisma.membership.findUnique({ + where: { userId_organizationId: { userId, organizationId } }, + select: { status: true, role: true }, + }); + return ( + membership?.status === "ACTIVE" && + (membership.role === "OWNER" || membership.role === "MAINTAINER") + ); +} diff --git a/lib/booking/reschedule-respond.ts b/lib/booking/reschedule-respond.ts new file mode 100644 index 000000000..d0dbfdf4b --- /dev/null +++ b/lib/booking/reschedule-respond.ts @@ -0,0 +1,137 @@ +/** + * The counterparty's answer to a reschedule proposal — the half of the loop + * #1064 never shipped (#1163). ACCEPTED and DECLINED existed only as enum + * members: the sole DECLINED writer was the cancel route closing proposals as + * a side-effect, ACCEPTED only ever arrived as a by-product of the consultant + * allocating, and the consultee had no way to answer at all while the + * consultant's toast claimed "the consultee has been asked to accept". + * + * Accept mirrors auto-confirm's design: the proposed times go straight to the + * allocator (`manual` mode, wide lock), which performs the full availability / + * caps / conflict validation under the correct locks — nothing is written + * unless it commits. The one difference is consent: auto-confirm requires the + * times to fall inside published availability because nobody is asked; + * an explicit accept IS the asking, so the initiator-role gate does not apply. + * + * Decline deliberately leaves the released slots released (the withdraw + * module's doc states the rule: the initiator still wants to move, so the + * booking belongs in the consultant's allocate queue). It is a status + * transition and nothing else. + */ + +import prisma from "@/lib/prisma"; +import { reportSentryError } from "@/lib/observability/report"; +import type { EventType } from "@/utils/slotAllocation/types"; +import { SlotAllocationService } from "@/utils/slotAllocation/SlotAllocationService"; +import { transitionRescheduleRequest } from "@/lib/booking/transitions"; +import { IllegalTransitionError } from "@/lib/enterprise/transitions"; + +export type RespondOutcome = + | { done: true } + | { done: false; reason: string }; + +export async function acceptProposal(args: { + rescheduleRequestId: string; + eventType: EventType; + eventId: string; + resolvedById: string; +}): Promise { + const request = await prisma.rescheduleRequest.findUnique({ + where: { id: args.rescheduleRequestId }, + select: { + id: true, + status: true, + expiresAt: true, + proposedSlots: { + orderBy: { startsAt: "asc" }, + select: { startsAt: true }, + }, + }, + }); + if (!request) return { done: false, reason: "PROPOSAL_NOT_FOUND" }; + if (request.status !== "PENDING_REVIEW") { + return { done: false, reason: "PROPOSAL_NOT_OPEN" }; + } + // The status alone does not mean "still answerable": `expireRescheduleProposals` + // runs hourly, so a lapsed proposal stays PENDING_REVIEW for up to an hour. + // Honouring the deadline here matters beyond tidiness — expiry is + // min(now + 72h, earliest released session − 24h), so accepting a lapsed + // proposal is exactly how a booking lands inside the 24-hour window the + // reschedule route refuses to move it into. + // + // The window between this read and the ACCEPTED write needs no lock of its + // own: EXPIRED is not in `RESCHEDULE_ALLOWED_FROM.ACCEPTED`, so a cron that + // wins that race makes the final CAS transition fail rather than accept. + if (request.expiresAt.getTime() <= Date.now()) { + return { done: false, reason: "PROPOSAL_EXPIRED" }; + } + if (request.proposedSlots.length === 0) { + // A preference-only request (#1065) proposes no concrete times — there is + // nothing to accept as-is; the consultant answers it by allocating. + return { done: false, reason: "NO_PROPOSED_TIMES" }; + } + + const result = await SlotAllocationService.allocate({ + eventType: args.eventType, + eventId: args.eventId, + mode: "manual", + slots: request.proposedSlots.map((p) => p.startsAt.toISOString()), + // Same reasoning as auto-confirm: these times were not day-picked by a + // human on the grid, so the day-sharded key would let two concurrent + // confirmations pass a per-week cap on stale counts. + wideLock: true, + }); + if (!result.success) { + // Nothing was written; the proposal stays open. + return { done: false, reason: result.errorCode ?? "VALIDATION_FAILED" }; + } + + try { + await prisma.$transaction(async (tx) => { + await transitionRescheduleRequest(tx, { + where: { id: request.id }, + to: "ACCEPTED", + data: { resolvedById: args.resolvedById }, + }); + }); + } catch (err) { + const isLostRace = err instanceof IllegalTransitionError; + // Same shape as auto-confirm's finalize: the booking moved, the paperwork + // must not silently fail to catch up. + reportSentryError(err, { + subsystem: "bookings", + op: "reschedule-accept", + expected: isLostRace, + extra: { rescheduleRequestId: args.rescheduleRequestId }, + }); + throw err; + } + + return { done: true }; +} + +export async function declineProposal(args: { + rescheduleRequestId: string; + resolvedById: string; +}): Promise { + try { + await prisma.$transaction(async (tx) => { + await transitionRescheduleRequest(tx, { + where: { id: args.rescheduleRequestId }, + to: "DECLINED", + data: { resolvedById: args.resolvedById }, + }); + }); + } catch (err) { + if (err instanceof IllegalTransitionError) { + return { done: false, reason: "PROPOSAL_NOT_OPEN" }; + } + reportSentryError(err, { + subsystem: "bookings", + op: "reschedule-decline", + extra: { rescheduleRequestId: args.rescheduleRequestId }, + }); + throw err; + } + return { done: true }; +} diff --git a/lib/data/consultee-events-read.ts b/lib/data/consultee-events-read.ts index 816707db8..7ba4e6f3b 100644 --- a/lib/data/consultee-events-read.ts +++ b/lib/data/consultee-events-read.ts @@ -22,6 +22,35 @@ import { toPlain } from "@/lib/data/serialize"; import { consultantPublicScalars } from "@/lib/data/consultant-public"; import type { TConsulteeEventsResponse } from "@/types/consultee-events"; +/** + * #1163 — the live proposal, so the consultee SEES a consultant-initiated + * reschedule and can answer it (accept / decline / withdraw), instead of + * "Awaiting schedule confirmation" forever. + * + * Only the two 1:1 kinds carry proposals (`supportsProposals`), and the respond + * route refuses anything else, so this rides consultation + subscription and + * nothing else — loading it on a group event would cost a relation read per row + * on a TTFB-bound query to render an answer nobody can give. + */ +const liveProposalInclude = { + where: { status: { in: ["PENDING_REVIEW", "COUNTERED"] } }, + orderBy: { createdAt: "desc" }, + take: 1, + select: { + id: true, + status: true, + reason: true, + round: true, + expiresAt: true, + initiatorRole: true, + initiatedById: true, + proposedSlots: { + orderBy: { startsAt: "asc" }, + select: { startsAt: true, endsAt: true }, + }, + }, +} satisfies Prisma.Appointment$rescheduleRequestsArgs; + /** Thrown when the consulteeId has no profile — route maps to 404. */ export class ConsulteeProfileNotFoundError extends Error { constructor(consulteeId: string) { @@ -119,6 +148,7 @@ export async function readConsulteeEvents( }, appointment: { include: { + rescheduleRequests: liveProposalInclude, slotsOfAppointment: { orderBy: { startsAt: "asc" }, include: { @@ -162,6 +192,7 @@ export async function readConsulteeEvents( }, appointments: { include: { + rescheduleRequests: liveProposalInclude, slotsOfAppointment: { orderBy: { startsAt: "asc" }, include: { diff --git a/lib/payments/webhooks/handlers.ts b/lib/payments/webhooks/handlers.ts index d71094c33..5bcbc533d 100644 --- a/lib/payments/webhooks/handlers.ts +++ b/lib/payments/webhooks/handlers.ts @@ -832,9 +832,17 @@ ACTION REQUIRED: Customer was charged but appointment was NOT created! notifUserIds.push(consultantUserId); } + // #1085 — the template renders a session time; omitting it left an empty + // placeholder in the user's very first booking notification. + const firstSlot = await prisma.slotOfAppointment.findFirst({ + where: { appointmentId }, + orderBy: { startsAt: "asc" }, + select: { startsAt: true }, + }); void notifyAppointmentBooked(notifUserIds, { ...scope, appointmentId, + dateTime: firstSlot?.startsAt.toISOString(), appointmentType: metadata.appointmentType, consultantName: consultantNameForNotif, consulteeName: userName || "User",