diff --git a/__tests__/payments/approval-payment-appointment-link.test.ts b/__tests__/payments/approval-payment-appointment-link.test.ts new file mode 100644 index 000000000..5a592a8bd --- /dev/null +++ b/__tests__/payments/approval-payment-appointment-link.test.ts @@ -0,0 +1,300 @@ +/** + * @jest-environment node + */ + +/** + * #1181 — approval payments carry their appointment. + * + * Approval-flow mints used to leave appointmentId null, which made three + * guards inert (the duplicate-payment walk over appointment.payment, the + * approval route's own hasPayment check and its PaidWithoutAppointmentError) + * and sent the capture webhook down the legacy-create path — building a twin + * Appointment for a one-to-one Consultation. State-based prisma mock (same + * idiom as cancel-pending-checkout.test.ts): here we pin that + * + * 1. the mint threads appointmentId into both the gateway metadata and the + * Payment row, exactly like direct checkout; + * 2. the duplicate-payment guard now MATCHES a PENDING payment already + * hanging off the same appointment and REUSES it (same intent, no second + * gateway order) instead of minting a parallel one; + * 3. a SUCCEEDED payment still refuses; an EXPIRED one falls through to a + * fresh mint; + * 4. both approval routes actually pass the appointment through (source + * contract, so a revert fails loudly). + */ + +import fs from "fs"; +import path from "path"; +import { Currency, PaymentStatus } from "@prisma/client"; + +const CUID = "clw0000000000000000000000"; +const PLAN_CUID = "clw1111111111111111111111"; +const APPT_CUID = "clw2222222222222222222222"; +const CONS_CUID = "clw3333333333333333333333"; + +interface State { + user: Record | null; + consultationPlan: Record | null; + /** What the duplicate guard's walk over consultation.appointment.payment finds. */ + appointmentPayments: Array>; + /** What findExistingLivePayment's trial arm reads off TrialSession.payment. */ + trialPayment?: Record | null; +} + +let state: State; + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + user: { + findUnique: jest.fn(async () => state.user), + }, + consultationPlan: { + findUnique: jest.fn(async () => state.consultationPlan), + }, + consultation: { + // Hydrates the include shape findExistingLivePayment walks. + findUnique: jest.fn(async () => ({ + id: CONS_CUID, + appointment: { id: APPT_CUID, payment: state.appointmentPayments }, + })), + }, + payment: { + create: jest.fn(async ({ data }: any) => ({ id: "pay-new", ...data })), + }, + trialSession: { + // Hydrates the include shape findExistingLivePayment's trial arm walks. + findUnique: jest.fn(async () => ({ + id: "trial-1", + payment: state.trialPayment, + })), + }, + subscriptionPlan: { + // Trial pricing reads the parent subscription plan (trialPriceInPaise + // fallback path in calculateAmount). + findUnique: jest.fn(async () => ({ + title: "Trial Plan", + price: 500_000, + priceCurrency: Currency.INR, + trialEnabled: true, + trialPriceInPaise: 250_000, + })), + }, + }, +})); + +const mockCreatePaymentIntent = jest.fn(); + +jest.mock("../../lib/payments/index", () => ({ + __esModule: true, + createPaymentIntent: (...a: unknown[]) => + mockCreatePaymentIntent(...(a as [])), +})); + +jest.mock("../../lib/redis", () => ({ + __esModule: true, + acquireLock: jest.fn(async () => "lock-token"), + releaseLock: jest.fn(async () => undefined), +})); + +import prisma from "../../lib/prisma"; +import { createApprovalPaymentIntent } from "../../lib/payments/operations/approval-payment"; + +const mockedPaymentCreate = prisma.payment.create as jest.Mock; + +function freshState(): State { + return { + user: { id: CUID, consulteeProfile: { id: "consultee-1" } }, + consultationPlan: { + title: "Career Clarity", + price: 500_000, + priceCurrency: Currency.INR, + }, + appointmentPayments: [], + }; +} + +beforeEach(() => { + state = freshState(); + jest.clearAllMocks(); + mockCreatePaymentIntent.mockResolvedValue({ + id: "order_new", + client_secret: "order_new", + amount: 500_000, + currency: "INR", + status: "created", + }); +}); + +function mintParams() { + return { + userId: CUID, + appointmentType: "CONSULTATION" as const, + consultationId: CONS_CUID, + planId: PLAN_CUID, + appointmentId: APPT_CUID, + paymentGateway: "RAZORPAY" as const, + startsAt: "2026-09-01T10:00:00.000Z", + endsAt: "2026-09-01T10:30:00.000Z", + }; +} + +describe("approval mint threads appointmentId (#1181)", () => { + it("sends the real appointment id in the gateway metadata, not the pending sentinel", async () => { + await createApprovalPaymentIntent(mintParams()); + + expect(mockCreatePaymentIntent).toHaveBeenCalledTimes(1); + const intentArg = mockCreatePaymentIntent.mock.calls[0][0]; + expect(intentArg.metadata.appointmentId).toBe(APPT_CUID); + expect(intentArg.metadata.isApprovalFlow).toBe("true"); + }); + + it("stamps appointmentId onto the Payment row", async () => { + const result = await createApprovalPaymentIntent(mintParams()); + + expect(result.paymentIntentId).toBe("order_new"); + const created = mockedPaymentCreate.mock.calls[0][0].data; + expect(created.appointmentId).toBe(APPT_CUID); + expect(created.paymentStatus).toBe(PaymentStatus.PENDING); + expect(created.amount).toBe(500_000); + }); +}); + +describe("duplicate-payment guard sees approval payments (#1181)", () => { + it("REUSES a PENDING payment hanging off the same appointment instead of minting a parallel order", async () => { + state.appointmentPayments = [ + { + paymentStatus: PaymentStatus.PENDING, + paymentIntent: "order_existing", + amount: 500_000, + currency: Currency.INR, + }, + ]; + + const result = await createApprovalPaymentIntent(mintParams()); + + // Same intent handed back — Razorpay's checkout url IS the order id, so + // this reconstructs the original pay-link without a gateway round-trip. + expect(result).toEqual({ + paymentIntentId: "order_existing", + checkoutUrl: "order_existing", + amount: 500_000, + currency: Currency.INR, + }); + expect(mockCreatePaymentIntent).not.toHaveBeenCalled(); + expect(mockedPaymentCreate).not.toHaveBeenCalled(); + }); + + it("refuses when the appointment's payment already SUCCEEDED", async () => { + state.appointmentPayments = [ + { + paymentStatus: PaymentStatus.SUCCEEDED, + paymentIntent: "order_paid", + amount: 500_000, + currency: Currency.INR, + }, + ]; + + await expect(createApprovalPaymentIntent(mintParams())).rejects.toThrow( + /already been paid/, + ); + expect(mockCreatePaymentIntent).not.toHaveBeenCalled(); + expect(mockedPaymentCreate).not.toHaveBeenCalled(); + }); + + it("falls through to a fresh mint for EXPIRED payments only", async () => { + state.appointmentPayments = [ + { + paymentStatus: PaymentStatus.EXPIRED, + paymentIntent: "order_dead", + amount: 500_000, + currency: Currency.INR, + }, + ]; + + const result = await createApprovalPaymentIntent(mintParams()); + + expect(result.paymentIntentId).toBe("order_new"); + expect(mockCreatePaymentIntent).toHaveBeenCalledTimes(1); + expect(mockedPaymentCreate).toHaveBeenCalledTimes(1); + }); + + // CodeRabbit triage — the trial arm of findExistingLivePayment returned + // the TrialSession's payment UNFILTERED, so an EXPIRED order would have + // been handed back as a "reusable" checkout link (a dead intent) instead + // of minting fresh. + it("trial arm: an EXPIRED trial payment falls through to a fresh mint", async () => { + state.trialPayment = { + paymentStatus: PaymentStatus.EXPIRED, + paymentIntent: "order_trial_dead", + amount: 250_000, + currency: Currency.INR, + }; + + await createApprovalPaymentIntent({ + ...mintParams(), + appointmentType: "TRIAL" as never, + consultationId: undefined, + trialId: "trial-1", + } as never); + + expect(mockCreatePaymentIntent).toHaveBeenCalledTimes(1); + expect(mockedPaymentCreate).toHaveBeenCalledTimes(1); + }); + + it("trial arm: a PENDING trial payment is reused, not duplicated", async () => { + state.trialPayment = { + paymentStatus: PaymentStatus.PENDING, + paymentIntent: "order_trial_live", + amount: 250_000, + currency: Currency.INR, + }; + + const result = await createApprovalPaymentIntent({ + ...mintParams(), + appointmentType: "TRIAL" as never, + consultationId: undefined, + trialId: "trial-1", + } as never); + + expect(result.paymentIntentId).toBe("order_trial_live"); + expect(mockCreatePaymentIntent).not.toHaveBeenCalled(); + expect(mockedPaymentCreate).not.toHaveBeenCalled(); + }); +}); + +describe("approval routes thread the appointment (source contract)", () => { + const read = (rel: string) => + fs.readFileSync(path.join(process.cwd(), rel), "utf8"); + + it("the consultation route passes its request-time appointment", () => { + const src = read( + "app/api/bookings/consultations/[consultationId]/route.ts", + ); + const fn = src.slice(src.indexOf("async function generatePaymentLink")); + expect(fn).toContain("appointmentId: appointment?.id ?? undefined"); + }); + + it("the subscription route passes its first request-time appointment", () => { + const src = read( + "app/api/bookings/subscriptions/[subscriptionId]/route.ts", + ); + const fn = src.slice( + src.indexOf("async function generatePaymentLinkForSubscription"), + ); + expect(fn).toContain("appointmentId,"); + expect(fn).toContain("subscription.appointments[0]?.id ?? undefined"); + }); + + it("every approval mint site names appointmentId explicitly", () => { + // The twin-prone default (leaving it unset) may only survive where no + // appointment exists yet; the call sites must at least name the param. + for (const rel of [ + "app/api/bookings/consultations/[consultationId]/route.ts", + "app/api/bookings/subscriptions/[subscriptionId]/route.ts", + "app/api/trials/[trialId]/route.ts", + ]) { + expect(read(rel)).toMatch(/appointmentId:/); + } + }); +}); diff --git a/__tests__/payments/pending-payments-frozen-amount.test.ts b/__tests__/payments/pending-payments-frozen-amount.test.ts new file mode 100644 index 000000000..4372b35c8 --- /dev/null +++ b/__tests__/payments/pending-payments-frozen-amount.test.ts @@ -0,0 +1,200 @@ +/** + * @jest-environment node + */ + +/** + * #1182 — pending-payments quotes the FROZEN Payment.amount, not the plan. + * + * An approval-pending item's charge was fixed onto its Payment row when the + * pay-link was minted; the plan's price stays mutable underneath. Quoting the + * plan meant a consultant who repriced after accepting turned this widget + * into a number the gateway would not honour. Mirrors #1180's rule on the + * trial checkout page: frozen payment first, plan only as the pre-mint quote. + */ + +import { GET } from "../../app/api/dashboard/consultee/[consulteeId]/pending-payments/route"; +import { requireApiAuth } from "@/lib/auth-helpers"; +import prisma from "@/lib/prisma"; + +jest.mock("../../lib/auth-helpers", () => ({ + __esModule: true, + requireApiAuth: jest.fn(), + isPrivileged: jest.fn(() => false), + forbiddenResponse: jest.fn( + (message: string) => + new Response(JSON.stringify({ error: message }), { status: 403 }), + ), +})); + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + consulteeProfile: { findUnique: jest.fn() }, + consultation: { findMany: jest.fn() }, + subscription: { findMany: jest.fn() }, + payment: { findMany: jest.fn() }, + trialSession: { findMany: jest.fn() }, + }, +})); + +const mockedAuth = requireApiAuth as jest.Mock; +const mockedConsultations = prisma.consultation.findMany as jest.Mock; +const mockedSubscriptions = prisma.subscription.findMany as jest.Mock; +const mockedGatewayPayments = prisma.payment.findMany as jest.Mock; +const mockedTrials = prisma.trialSession.findMany as jest.Mock; + +function frozenPayment(amount: number, currency = "INR") { + return [{ amount, currency }]; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockedAuth.mockResolvedValue({ + session: { + user: { id: "user-1", role: "USER", consulteeProfileId: "consultee-1" }, + }, + }); + mockedConsultations.mockResolvedValue([]); + mockedSubscriptions.mockResolvedValue([]); + mockedGatewayPayments.mockResolvedValue([]); + mockedTrials.mockResolvedValue([]); + (prisma.consulteeProfile.findUnique as jest.Mock).mockResolvedValue({ + id: "consultee-1", + userId: "user-1", + }); +}); + +async function getPendingPayments() { + const res = await GET(new Request("http://localhost/api"), { + params: Promise.resolve({ consulteeId: "consultee-1" }), + }); + expect(res.status).toBe(200); + return (await res.json()).pendingPayments as Array<{ + id: string; + type: string; + amount: number; + currency: string; + }>; +} + +describe("pending-payments quotes the frozen Payment.amount (#1182)", () => { + it("an approval-pending consultation shows the minted amount after the plan was repriced", async () => { + // Link minted at ₹3,500; the plan has since gone up to ₹4,000. The + // gateway will charge the frozen figure, so that is what is quoted. + mockedConsultations.mockResolvedValue([ + { + id: "cons-1", + updatedAt: new Date("2026-08-01T10:00:00Z"), + pendingPaymentUrl: "order_123", + appointment: { id: "appt-1", payment: frozenPayment(350_000) }, + consultationPlan: { + title: "Career Clarity", + price: 400_000, + priceCurrency: "INR", + consultantProfile: { user: { name: "Advisor" } }, + }, + }, + ]); + + const items = await getPendingPayments(); + + const item = items.find((i) => i.id === "cons-1"); + expect(item?.amount).toBe(350_000); + expect(item?.currency).toBe("INR"); + }); + + it("falls back to the plan price before any payment exists (the genuine pre-mint quote)", async () => { + mockedConsultations.mockResolvedValue([ + { + id: "cons-2", + updatedAt: new Date("2026-08-01T10:00:00Z"), + pendingPaymentUrl: null, + appointment: { id: "appt-2", payment: [] }, + consultationPlan: { + title: "Career Clarity", + price: 400_000, + priceCurrency: "INR", + consultantProfile: { user: { name: "Advisor" } }, + }, + }, + ]); + + const items = await getPendingPayments(); + + expect(items.find((i) => i.id === "cons-2")?.amount).toBe(400_000); + }); + + it("an approval-pending subscription shows its appointment's frozen amount and currency", async () => { + mockedSubscriptions.mockResolvedValue([ + { + id: "sub-1", + updatedAt: new Date("2026-08-01T10:00:00Z"), + pendingPaymentUrl: "order_456", + appointments: [ + { + id: "appt-s1", + payment: frozenPayment(1_250_000), + }, + ], + subscriptionPlan: { + title: "Weekly Mentoring", + price: 999_999, // repriced down after acceptance + priceCurrency: "INR", + consultantProfile: { user: { name: "Mentor" } }, + }, + }, + ]); + + const items = await getPendingPayments(); + + const item = items.find((i) => i.id === "sub-1"); + expect(item?.amount).toBe(1_250_000); + expect(item?.currency).toBe("INR"); + }); + + it("a paid trial awaiting payment shows the frozen trial charge, not the current trialPriceInPaise", async () => { + mockedTrials.mockResolvedValue([ + { + id: "trial-1", + updatedAt: new Date("2026-08-01T10:00:00Z"), + paymentDueAt: new Date("2026-08-05T10:00:00Z"), + pendingPaymentUrl: "order_789", + appointment: { id: "appt-t1" }, + payment: { amount: 100_000, currency: "INR" }, + subscriptionPlan: { + title: "Deep Dive", + trialPriceInPaise: 200_000, // repriced after acceptance + priceCurrency: "INR", + consultantProfile: { user: { name: "Guide" } }, + }, + }, + ]); + + const items = await getPendingPayments(); + + expect(items.find((i) => i.id === "trial-1")?.amount).toBe(100_000); + }); + + it("a free trial (no payment ever minted) still quotes zero via the plan fallback", async () => { + mockedTrials.mockResolvedValue([ + { + id: "trial-free", + updatedAt: new Date("2026-08-01T10:00:00Z"), + paymentDueAt: null, + pendingPaymentUrl: null, + appointment: { id: "appt-t2" }, + payment: null, + subscriptionPlan: { + title: "Taster", + trialPriceInPaise: 0, + priceCurrency: "INR", + consultantProfile: { user: { name: "Guide" } }, + }, + }, + ]); + + const items = await getPendingPayments(); + + expect(items.find((i) => i.id === "trial-free")?.amount).toBe(0); + }); +}); diff --git a/app/api/bookings/consultations/[consultationId]/route.ts b/app/api/bookings/consultations/[consultationId]/route.ts index f16971768..3b8f72444 100644 --- a/app/api/bookings/consultations/[consultationId]/route.ts +++ b/app/api/bookings/consultations/[consultationId]/route.ts @@ -731,7 +731,7 @@ export async function PATCH( // If duplicate, return early — EXCEPT an APPROVED_PENDING_PAYMENT whose // pay-link mint previously failed (#1169 PR 2): fall through so a - // re-approval actually re-mints the link instead of parroting + // re-approval restores the link instead of parroting // "already in progress" forever. const needsLinkRetry = result.duplicate && @@ -774,12 +774,12 @@ export async function PATCH( ("needsPaymentLink" in result && result.needsPaymentLink) || needsLinkRetry ) { - // The 502 below invites a retry, and the retry re-mints — so it may - // only ever be reached while NO link exists. Everything after a + // The 502 below invites a retry; the retry reuses the same PENDING + // payment (#1181) rather than minting a parallel order — so a second + // live link can never reach the consultee. Everything after a // successful mint therefore reports and continues: a 502 past this - // point would hand the consultee a second live link (the duplicate - // guard walks appointment.payment, which approval payments never - // populate — see #1166). + // point would still be wrong, because it reads as a failure the + // consultant should answer by re-approving. let paymentResult; try { paymentResult = await generatePaymentLink(result.data); @@ -989,6 +989,11 @@ async function generatePaymentLink(consultation: ConsultationWithDetails) { userId: requestedBy.user.id, appointmentType: "CONSULTATION", consultationId: consultation.id, + // #1181 — the request created this appointment at submit time; threading + // it stamps Payment.appointmentId so capture confirms THAT row instead of + // building a twin off metadata, and the duplicate-payment guard (which + // walks appointment.payment) can see approval payments at all. + appointmentId: appointment?.id ?? undefined, planId: consultationPlan.id, // #1165 — settlement is INR-only and Razorpay is the KYC'd primary // gateway; the trial path already minted on it. Param stays configurable diff --git a/app/api/bookings/subscriptions/[subscriptionId]/route.ts b/app/api/bookings/subscriptions/[subscriptionId]/route.ts index 85e3d7add..551c5ef8c 100644 --- a/app/api/bookings/subscriptions/[subscriptionId]/route.ts +++ b/app/api/bookings/subscriptions/[subscriptionId]/route.ts @@ -704,7 +704,7 @@ export async function PATCH( // If duplicate, return early — EXCEPT an APPROVED_PENDING_PAYMENT whose // pay-link mint previously failed (#1169 PR 2): fall through so a - // re-approval actually re-mints the link. + // re-approval restores the link. const needsLinkRetry = result.duplicate && result.data.status === AppointmentStatus.APPROVED_PENDING_PAYMENT && @@ -718,7 +718,8 @@ export async function PATCH( // #1169 PR 2 — mint the pay-link AFTER the transaction commits (see the // in-tx comment). Mint failure leaves APPROVED_PENDING_PAYMENT with no - // link; re-approval re-enters via needsLinkRetry. + // link; re-approval re-enters via needsLinkRetry and reuses the PENDING + // payment the first attempt persisted (#1181). let mintedLink: { paymentUrl: string; paymentAmount: number; @@ -728,9 +729,10 @@ export async function PATCH( ("needsPaymentLink" in result && result.needsPaymentLink) || needsLinkRetry ) { - // The 502 below invites a retry, and the retry re-mints — so it may - // only ever be reached while NO link exists. Everything after a - // successful mint therefore reports and continues (#1166). + // The 502 below invites a retry; the retry reuses the same PENDING + // payment (#1181) rather than minting a parallel order — so a second + // live link can never reach the consultee. Everything after a + // successful mint therefore reports and continues. let paymentResult; try { paymentResult = await generatePaymentLinkForSubscription( @@ -999,11 +1001,21 @@ async function generatePaymentLinkForSubscription( schedulingPeriodEndsAt: Date, ) { const { subscriptionPlan, requestedBy } = subscription; + // #1181 — the request-time appointment (direct checkout creates a + // placeholder for exactly this linkage; proposed-times creates real rows). + // First under the route's deterministic createdAt/id ordering, the same row + // the confirm flip and org resolution read. Threading it stamps + // Payment.appointmentId so capture confirms THAT row instead of building a + // twin subscription off metadata, and the duplicate-payment guard (which + // walks appointments.payment) can see approval payments at all. Unset only + // when no appointment exists yet — nothing to confirm, mint as before. + const appointmentId = subscription.appointments[0]?.id ?? undefined; return await createApprovalPaymentIntent({ userId: requestedBy.user.id, appointmentType: "SUBSCRIPTION", subscriptionId: subscription.id, + appointmentId, planId: subscriptionPlan.id, // #1165 — settlement is INR-only; Razorpay is the KYC'd primary gateway, // matching the trial path. Param stays configurable for a scale decision. diff --git a/app/api/dashboard/consultee/[consulteeId]/pending-payments/route.ts b/app/api/dashboard/consultee/[consulteeId]/pending-payments/route.ts index 3451e30cd..8662e2259 100644 --- a/app/api/dashboard/consultee/[consulteeId]/pending-payments/route.ts +++ b/app/api/dashboard/consultee/[consulteeId]/pending-payments/route.ts @@ -45,6 +45,21 @@ export async function GET( ); } + // #1182 — what an unpaid item QUOTES is the amount frozen onto its + // Payment row when the pay-link was minted, never the plan's live price + // (a consultant repricing after acceptance must not move the number the + // gateway will charge). Newest live payment per appointment — a re-mint + // (expired/failed order replaced, #1181 reuse semantics) freezes the + // CURRENT quote onto a newer row, so the newest is the operative charge. + // Falls back to the plan only before any payment exists, where the plan + // price genuinely is the quote. + const frozenPaymentSelect = { + where: { deletedAt: null }, + orderBy: { createdAt: "desc" as const }, + take: 1, + select: { amount: true, currency: true }, + } as const; + const planInclude = { select: { title: true, @@ -87,8 +102,9 @@ export async function GET( }, }, // Cancel affordance keys on the Appointment record id - // (POST /api/appointments/[appointmentId]/cancel). - appointment: { select: { id: true } }, + // (POST /api/appointments/[appointmentId]/cancel); the frozen + // charge rides the same appointment (#1182). + appointment: { select: { id: true, payment: frozenPaymentSelect } }, }, orderBy: { updatedAt: "desc" }, }), @@ -112,7 +128,18 @@ export async function GET( }, // Any one appointment id suffices — the cancel route resolves // the parent subscription from it and transitions the whole row. - appointments: { select: { id: true }, take: 1 }, + // Ordered like the mint's pick (#1181) so take: 1 lands on the + // appointment the pay-link anchored to, which carries the frozen + // charge (#1182). Pinned to PERSONAL appointments (matching this + // surface's organizationId: null filter): a mixed subscription + // with an earlier org-funded appointment must not leak its frozen + // amount onto the personal dashboard. + appointments: { + where: { organizationId: null }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { id: true, payment: frozenPaymentSelect }, + take: 1, + }, }, orderBy: { updatedAt: "desc" }, }), @@ -172,6 +199,10 @@ export async function GET( }, }, appointment: { select: { id: true } }, + // A paid trial owns its Payment directly (TrialSession.paymentId); + // it holds the frozen charge the same way (#1182). Free trials + // have none and keep quoting the plan's trial price. + payment: { select: { amount: true, currency: true } }, }, orderBy: { updatedAt: "desc" }, }), @@ -185,6 +216,8 @@ export async function GET( ); // 48 hours from approval const isExpiringSoon = expiresAt.getTime() - Date.now() < 24 * 60 * 60 * 1000; + // #1182 — frozen charge first; the plan is only the pre-mint quote. + const frozen = consultation.appointment?.payment[0]; return { id: consultation.id, @@ -194,8 +227,10 @@ export async function GET( consultantName: consultation.consultationPlan?.consultantProfile?.user?.name || "Consultant", - amount: consultation.consultationPlan?.price || 0, - currency: consultation.consultationPlan?.priceCurrency || "INR", + amount: frozen?.amount ?? (consultation.consultationPlan?.price || 0), + currency: + (frozen?.currency ?? consultation.consultationPlan?.priceCurrency) || + "INR", paymentUrl: consultation.pendingPaymentUrl || "", approvedAt: consultation.updatedAt.toISOString(), expiresAt: expiresAt.toISOString(), @@ -209,6 +244,8 @@ export async function GET( ); const isExpiringSoon = expiresAt.getTime() - Date.now() < 24 * 60 * 60 * 1000; + // #1182 — frozen charge first; the plan is only the pre-mint quote. + const frozen = subscription.appointments[0]?.payment[0]; return { id: subscription.id, @@ -218,8 +255,11 @@ export async function GET( consultantName: subscription.subscriptionPlan?.consultantProfile?.user?.name || "Consultant", - amount: subscription.subscriptionPlan?.price || 0, - currency: subscription.subscriptionPlan?.priceCurrency || "INR", + amount: + frozen?.amount ?? (subscription.subscriptionPlan?.price || 0), + currency: + (frozen?.currency ?? subscription.subscriptionPlan?.priceCurrency) || + "INR", paymentUrl: subscription.pendingPaymentUrl || "", approvedAt: subscription.updatedAt.toISOString(), expiresAt: expiresAt.toISOString(), @@ -233,6 +273,10 @@ export async function GET( const expiresAt = trial.paymentDueAt ?? trial.updatedAt; const isExpiringSoon = expiresAt.getTime() - Date.now() < 24 * 60 * 60 * 1000; + // #1182 — frozen charge first; the plan's trial price is only the + // pre-mint quote (and the whole quote for free trials, which never + // mint one). + const frozen = trial.payment; return { id: trial.id, @@ -242,8 +286,12 @@ export async function GET( consultantName: trial.subscriptionPlan?.consultantProfile?.user?.name || "Consultant", - amount: Number(trial.subscriptionPlan?.trialPriceInPaise ?? 0), - currency: trial.subscriptionPlan?.priceCurrency || "INR", + amount: + frozen?.amount ?? + Number(trial.subscriptionPlan?.trialPriceInPaise ?? 0), + currency: + (frozen?.currency ?? trial.subscriptionPlan?.priceCurrency) || + "INR", paymentUrl: trial.pendingPaymentUrl || "", approvedAt: trial.updatedAt.toISOString(), expiresAt: expiresAt.toISOString(), diff --git a/docs/booking/06-booking-lifecycle.md b/docs/booking/06-booking-lifecycle.md index 41025111d..0d72dc1bf 100644 --- a/docs/booking/06-booking-lifecycle.md +++ b/docs/booking/06-booking-lifecycle.md @@ -1276,6 +1276,6 @@ T+30 days (If request was never acted on) ## Approval-path corrections (2026-08-14, #1169 PR 2) -Three behaviors of the approve flow changed together. First, the payment link is minted **after** the approval transaction commits, never inside it: a gateway round-trip inside the Serializable transaction pinned a pooled connection, could exceed the 30-second budget, and on rollback left a live payment link for an approval that never persisted. If minting fails, the request stays `APPROVED_PENDING_PAYMENT` with no link and the response says so with a 502; approving again re-mints, because the duplicate guard deliberately falls through while the link is missing. That 502 is reserved for the mint itself. Once a link exists, a failure to write it to `pendingPaymentUrl` or to email it is reported to Sentry and the request still succeeds, because answering with a 502 there would invite a retry that mints a **second** live payment link for the same booking — the duplicate guard inside `createApprovalPaymentIntent` cannot catch it, since it looks for payments hanging off the appointment and approval-flow payments are created without one. Second, confirming a paid request's tentative slots excludes `RESCHEDULED` rows, which keep their original `startsAt` — flipping them re-confirmed exactly the time the consultee had asked to move away from. Third, a request that is paid but has no appointment row (its capture webhook has not landed) is **refused** with a 409 rather than papered over: the old fallback fabricated a confirmed slot at now+1h with no availability check, no lock, and no `consultantProfileId`, on the global client, so it even survived the transaction's rollback. The `reconcile-orphaned-confirmations` sweep (#830) settles that state, after which approval succeeds normally. +Three behaviors of the approve flow changed together. First, the payment link is minted **after** the approval transaction commits, never inside it: a gateway round-trip inside the Serializable transaction pinned a pooled connection, could exceed the 30-second budget, and on rollback left a live payment link for an approval that never persisted. If minting fails, the request stays `APPROVED_PENDING_PAYMENT` with no link and the response says so with a 502; approving again re-mints, because while the gateway call itself failed no Payment row exists for the duplicate guard to find. That 502 is reserved for the mint itself. Once a link exists, a failure to write it to `pendingPaymentUrl` or to email it is reported to Sentry and the request still succeeds, because answering with a 502 there would invite a retry that mints a **second** live payment link for the same booking — since #1181 the duplicate guard inside `createApprovalPaymentIntent` does see approval payments (they carry the request-time `appointmentId`, so the guard's walk over appointment payments matches), and its answer to an already-minted PENDING payment is to **reuse** it — returning the same intent instead of minting a parallel order. Second, confirming a paid request's tentative slots excludes `RESCHEDULED` rows, which keep their original `startsAt` — flipping them re-confirmed exactly the time the consultee had asked to move away from. Third, a request that is paid but has no appointment row (its capture webhook has not landed) is **refused** with a 409 rather than papered over: the old fallback fabricated a confirmed slot at now+1h with no availability check, no lock, and no `consultantProfileId`, on the global client, so it even survived the transaction's rollback. The `reconcile-orphaned-confirmations` sweep (#830) settles that state, after which approval succeeds normally. Approval links also now mint on RAZORPAY across all three request types (#1165), and org sponsorship survives the flow end-to-end (#1166): the request validates an ACTIVE membership of a `canSponsor` organization that is itself transactable (`ACTIVE` or `PENDING_VERIFICATION`, the same standing the checkout path demands), stamps `Appointment.organizationId` at creation, and the approval payment carries the org onto the `Payment` row and gateway metadata. Approval payments now also write the `CARD` funding leg that every `Payment` is required to carry; this was the one gateway path that created a payment with no leg at all, which mattered little while the rows were untagged and matters a great deal now that they carry an organization. diff --git a/lib/payments/operations/approval-payment.ts b/lib/payments/operations/approval-payment.ts index d51960a26..320038015 100644 --- a/lib/payments/operations/approval-payment.ts +++ b/lib/payments/operations/approval-payment.ts @@ -31,10 +31,13 @@ export interface CreateApprovalPaymentParams { /** Required when appointmentType is TRIAL. */ trialId?: string; /** - * Link the intent to an appointment that ALREADY exists. Trials create the - * appointment when the consultant accepts (to hold the slot), so the webhook - * should confirm it rather than build a new one. Consultations and - * subscriptions leave this unset — their appointment is made on capture. + * Link the intent to the appointment that ALREADY exists. Every approval + * arm creates it before minting — trials hold their slot when the + * consultant accepts, consultations/subscriptions create it at request + * time (#1181) — so the webhook confirms THAT appointment instead of + * building a twin (Consultation.appointment is one-to-one; a capture-time + * creation either collides on the unique or strands the request-time row), + * and the duplicate-payment guard can see approval payments at all. */ appointmentId?: string; /** @@ -110,17 +113,34 @@ export async function createApprovalPaymentIntent( } try { - // FIX Issue #7: Check for existing payment to prevent duplicates - const hasExistingPayment = await checkExistingPayment({ + // FIX Issue #7 / #1181 — duplicate-payment guard, now live for every + // approval arm: the walk below reads the payments hanging off the + // request's own appointment(s), which only match once the mint threads + // appointmentId through (see CreateApprovalPaymentParams). + const existingPayment = await findExistingLivePayment({ consultationId: params.consultationId, subscriptionId: params.subscriptionId, trialId: params.trialId, }); - if (hasExistingPayment) { - throw new Error( - "A payment link has already been generated for this request", - ); + if (existingPayment) { + if (existingPayment.paymentStatus === PaymentStatus.SUCCEEDED) { + throw new Error("This request has already been paid"); + } + + // #1181 — a PENDING payment from a previous mint attempt is reused, + // not duplicated. Before the appointment back-link existed this state + // was invisible (the retry minted a parallel gateway order); now it + // resolves the #1172 deadlock shape where the link was minted but its + // persist never landed and re-approval must recover it. The pay-link + // reconstructs from the stored intent because Razorpay's client_secret + // IS the order id (#1165 pins approval mints to RAZORPAY). + return { + paymentIntentId: existingPayment.paymentIntent, + checkoutUrl: existingPayment.paymentIntent, + amount: existingPayment.amount, + currency: existingPayment.currency, + }; } // BUG-D: Validate user has consultee profile (required for webhook to succeed) @@ -169,8 +189,9 @@ export async function createApprovalPaymentIntent( userId: params.userId, expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000), // 48 hours expiration isMockPayment: false, - // Null for consultations/subscriptions, whose appointment is created - // on capture; trials pass the appointment they already hold. + // #1181 — the request-time appointment anchors capture to the NEW + // flow (confirm the existing row, never create a twin). Null only + // when the caller genuinely had no appointment to offer. appointmentId: params.appointmentId ?? null, // Every Payment must carry at least one PaymentLeg // (docs/enterprise/10-money-and-ledger/09-payment-legs.md); checkout @@ -316,8 +337,10 @@ function buildApprovalMetadata(params: CreateApprovalPaymentParams): { [key: string]: string; } { const metadata: Record = { - // Trials pass a real id (their appointment exists before the intent); - // everything else links after capture. + // Same shape as direct checkout (buildPaymentMetadata): the real anchor + // lives on the Payment row, which is what the capture handler branches + // on; the sentinel here only feeds the legacy-create path when there is + // genuinely no appointment yet. appointmentId: params.appointmentId ?? "pending", appointmentType: params.appointmentType, userId: params.userId, @@ -367,27 +390,54 @@ function buildApprovalMetadata(params: CreateApprovalPaymentParams): { } /** - * Check if payment already exists for consultation/subscription - * Prevents duplicate payment generation + * Find the live payment already attached to this request's appointment(s). + * Returns null when nothing has been minted (or only EXPIRED/FAILED rows + * remain, which a fresh mint may supersede). This is the duplicate-payment + * guard's lookup — it can only ever match once callers thread appointmentId, + * because it walks payments hanging off the appointment (#1181). */ -export async function checkExistingPayment(params: { +export async function findExistingLivePayment(params: { consultationId?: string; subscriptionId?: string; trialId?: string; -}): Promise { +}): Promise<{ + paymentStatus: PaymentStatus; + paymentIntent: string; + amount: number; + currency: Currency; +} | null> { if (params.trialId) { // A trial owns its Payment directly (TrialSession.paymentId), so unlike the // consultation/subscription arms there is no appointment to walk through — // the appointment doesn't exist until the trial is paid and scheduled. const trial = await prisma.trialSession.findUnique({ where: { id: params.trialId }, - select: { payment: { select: { paymentStatus: true } } }, + select: { + payment: { + select: { + paymentStatus: true, + paymentIntent: true, + amount: true, + currency: true, + }, + }, + }, }); - const status = trial?.payment?.paymentStatus; - return ( - status === PaymentStatus.SUCCEEDED || status === PaymentStatus.PENDING - ); + const payment = trial?.payment; + + // Same live-status filter as the consultation/subscription arms below: + // an EXPIRED or FAILED gateway order cannot be paid — returning it would + // have the reuse path hand the payer a dead checkout link (the stored + // intent) instead of minting a fresh one. + if ( + !payment || + (payment.paymentStatus !== PaymentStatus.SUCCEEDED && + payment.paymentStatus !== PaymentStatus.PENDING) + ) { + return null; + } + return payment; } if (params.consultationId) { @@ -409,7 +459,13 @@ export async function checkExistingPayment(params: { p.paymentStatus === PaymentStatus.PENDING, ); - return !!payment; + if (!payment) return null; + return { + paymentStatus: payment.paymentStatus, + paymentIntent: payment.paymentIntent, + amount: payment.amount, + currency: payment.currency, + }; } if (params.subscriptionId) { @@ -425,16 +481,24 @@ export async function checkExistingPayment(params: { }); // Check any appointment for payment - const hasPayment = subscription?.appointments.some((apt) => - apt.payment?.some( + for (const apt of subscription?.appointments ?? []) { + const payment = apt.payment?.find( (p) => p.paymentStatus === PaymentStatus.SUCCEEDED || p.paymentStatus === PaymentStatus.PENDING, - ), - ); + ); + if (payment) { + return { + paymentStatus: payment.paymentStatus, + paymentIntent: payment.paymentIntent, + amount: payment.amount, + currency: payment.currency, + }; + } + } - return !!hasPayment; + return null; } - return false; + return null; }