Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion __tests__/payments/approval-path-correctness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,12 @@ describe("checkout hardening (#1093 tail + tentative visibility)", () => {
const step5 = checkout.indexOf(
"// STEP 5: Create tentative appointment + payment record",
);
const window = checkout.slice(step5, step5 + 600);
// #1435 — the window is measured in characters, so the comment block
// between the marker and the call decides whether this passes. Strip the
// comments and assert on the code instead of enlarging it again.
const window = checkout
.slice(step5, step5 + 2000)
.replace(/^\s*\/\/.*$/gm, "");
expect(window).toContain("withSerializableRetry(");
});
});
164 changes: 164 additions & 0 deletions __tests__/payments/checkout-pool-1-nesting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* @jest-environment node
*/

/**
* #1421 — checkout must never issue a query on the global Prisma client while
* one of its own interactive transactions is open.
*
* Netlify runs this app with a pg pool of `PG_POOL_MAX=1` and a 3 s connect
* timeout. An interactive `$transaction` checks out that single connection and
* holds it until it commits, so any query sent to the global client in the
* meantime queues for a connection that only the blocked transaction can
* release. The request cannot make progress and pg gives up with "timeout
* exceeded when trying to connect", which is exactly how every consultation
* checkout failed on the deploy preview while sibling read routes on the same
* deploy answered normally.
*
* `validateSlotAvailability` is the site that fired: it is called from inside
* three separate transactions on the plain Razorpay consultation path, and its
* DPDP consent gate used to read on the global client. The mock below models
* the pool faithfully — a global-client call raised while a transaction is
* open throws the same pg error the preview logged — so this test fails
* against the unfixed code and passes once the gate reads through `tx`.
*/

import type { CheckoutInput } from "@/schemas/checkout";

let mockTxDepth = 0;
const mockGlobalTouches: string[] = [];

const mockTxClient = {
consentArtifact: {
findFirst: jest.fn(async () => ({ id: "consent-artifact-1" })),
},
slotOfAppointment: {
findFirst: jest.fn(async () => null),
},
};

jest.mock("../../lib/prisma", () => {
// Any model reached on the global client answers through this proxy, so the
// test does not have to enumerate the models a future call site might touch.
const globalModel = (model: string) =>
new Proxy(
{},
{
get: (_target, operation: string) => async (): Promise<null> => {
mockGlobalTouches.push(`${model}.${operation}`);
if (mockTxDepth > 0) {
throw new Error("timeout exceeded when trying to connect");
}
return null;
},
},
);

const client: Record<string, unknown> = {
$transaction: async (fn: (tx: unknown) => unknown) => {
mockTxDepth += 1;
try {
return await fn(mockTxClient);
} finally {
mockTxDepth -= 1;
}
},
};

return {
__esModule: true,
default: new Proxy(client, {
get: (target, prop: string) =>
prop in target ? target[prop] : globalModel(prop),
}),
};
});

// Boundary mocks. `lib/payments/operations/checkout` transitively imports the
// auth stack through the payouts barrel, which is ESM-only and cannot be
// required under this Jest transform; the gateway and the Redis lock helpers
// are infrastructure this suite never exercises. Note that
// `lib/compliance/dpdp` is deliberately NOT mocked — its real body is the code
// under test.
jest.mock("../../lib/payments/payouts", () => ({
__esModule: true,
createEarningsFromPayment: jest.fn(),
}));

jest.mock("../../lib/payments/index", () => ({
__esModule: true,
createPaymentIntent: jest.fn(),
cancelPaymentIntent: jest.fn(),
}));

jest.mock("../../utils/appointmentlock", () => ({
__esModule: true,
CHECKOUT_WAIT_RETRY_CONFIG: { retryCount: 5 },
CHECKOUT_LOCK_TTL_MS: {},
EventFullError: class extends Error {},
lockSlotBooking: jest.fn(),
unlockSlotBooking: jest.fn(),
lockEventCheckout: jest.fn(),
unlockEventCheckout: jest.fn(),
lockConsulteeBooking: jest.fn(),
unlockConsulteeBooking: jest.fn(),
extendLock: jest.fn(),
extendSlotInterval: jest.fn(),
}));

import prisma, { type Tx } from "../../lib/prisma";
import { validateSlotAvailability } from "../../lib/payments/operations/checkout";

const HOUR_MS = 60 * 60 * 1000;

function slotInput(): CheckoutInput {
const startsAt = new Date(Date.now() + 48 * HOUR_MS);
const endsAt = new Date(startsAt.getTime() + HOUR_MS);
return {
appointmentType: "CONSULTATION",
planId: "plan-1",
paymentGateway: "RAZORPAY",
startsAt: startsAt.toISOString(),
endsAt: endsAt.toISOString(),
} as unknown as CheckoutInput;
}

/** The shape every real caller uses: the helper runs inside an open tx. */
function validateInsideTransaction(): Promise<void> {
return prisma.$transaction(async (tx) =>
validateSlotAvailability(
tx as unknown as Tx,
slotInput(),
"consultee-user",
"expert-user",
),
);
}

describe("#1421 checkout does not starve the single-connection pool", () => {
beforeEach(() => {
mockTxDepth = 0;
mockGlobalTouches.length = 0;
mockTxClient.consentArtifact.findFirst.mockResolvedValue({
id: "consent-artifact-1",
});
});

it("runs the consent gate on the transaction client, not the global one", async () => {
await expect(validateInsideTransaction()).resolves.toBeUndefined();

expect(mockTxClient.consentArtifact.findFirst).toHaveBeenCalledTimes(1);
expect(mockGlobalTouches).toEqual([]);
});

it("still blocks a consultant who withdrew session-delivery consent", async () => {
mockTxClient.consentArtifact.findFirst.mockResolvedValue(
null as unknown as { id: string },
);

await expect(validateInsideTransaction()).rejects.toThrow(
/withdrawn session-delivery consent/,
);
expect(mockGlobalTouches).toEqual([]);
});
});
24 changes: 17 additions & 7 deletions lib/compliance/dpdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
*/

import { createHash } from "node:crypto";
import prisma from "@/lib/prisma";
import prisma, { type Tx } from "@/lib/prisma";
import type { PurposeCode } from "./purpose-codes";

/**
Expand Down Expand Up @@ -183,15 +183,24 @@ export function buildConsentArtifact(
* - If the most recent artifact is past its retention window → false
* (operator must refresh the consent before re-enabling processing).
* - Otherwise → true.
*
* `db` defaults to the global client but MUST be the transaction client when
* the caller is already inside an interactive `$transaction`. Netlify runs
* `PG_POOL_MAX=1`, so a read issued on the global client while a transaction
* holds the pool's only connection waits for a second connection that can
* never arrive and dies at the 3 s pg connect timeout (#1421).
*/
export async function checkConsent(params: {
userId: string;
purposeCode: PurposeCode;
}): Promise<boolean> {
export async function checkConsent(
params: {
userId: string;
purposeCode: PurposeCode;
},
db: Tx | typeof prisma = prisma,
): Promise<boolean> {
const { userId, purposeCode } = params;
const now = new Date();

const artifact = await prisma.consentArtifact.findFirst({
const artifact = await db.consentArtifact.findFirst({
where: {
userId,
purposeCodes: { has: purposeCode },
Expand Down Expand Up @@ -277,7 +286,8 @@ export async function withdrawConsent(params: {
// when. The checkout path (validateSlotAvailability) independently
// checks this consent fail-closed at booking time.
if (purposeCode === "SESSION_BOOKING" && count > 0) {
const { recordSystemEvent } = await import("@/lib/enterprise/system-events");
const { recordSystemEvent } =
await import("@/lib/enterprise/system-events");
void recordSystemEvent({
organizationId: null,
category: "CONSENT",
Expand Down
108 changes: 73 additions & 35 deletions lib/payments/billing/overage-settlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export interface RecordOverageInput {
paymentGateway: PaymentGateway;
}

/** The member-due bell, deferred until the caller's transaction has committed. */
export interface PendingOverageNotification {
userId: string;
programAssignmentId: string;
marginalPaise: number;
overageEventId: string;
}

/**
* Record one over-cap booking. Assumes the caller already metered the booking
* and saw `wasOverage = true` (BLOCK behaviour throws inside the metering
Expand All @@ -47,10 +55,14 @@ export interface RecordOverageInput {
* Throws `PROGRAM_CAP_EXHAUSTED` (httpStatus 402) when the circuit breaker
* vetoes — same shape as the BLOCK path so the dashboard can explain the
* cycle ceiling vs the per-member allocation.
*
* Returns the member-due bell to ring, or null when there is nothing to tell
* anyone. The caller rings it AFTER its transaction commits — see
* `notifyOverageDueAfterCommit`.
*/
export async function recordOverageAtCheckout(
input: RecordOverageInput,
): Promise<void> {
): Promise<PendingOverageNotification | null> {
const {
tx,
programAssignmentId,
Expand Down Expand Up @@ -146,17 +158,20 @@ export async function recordOverageAtCheckout(
);
// Modelled outcome (the circuit breaker working as designed), not a
// fault — captured for volume/pattern visibility only.
reportSentryError(capExhaustedErr, { subsystem: "payments", expected: true });
reportSentryError(capExhaustedErr, {
subsystem: "payments",
expected: true,
});
throw capExhaustedErr;
}

if (marginalPaise <= 0) return;
if (marginalPaise <= 0) return null;

const bu = await tx.bookingUtilization.findUnique({
where: { paymentId },
select: { id: true },
});
if (!bu) return;
if (!bu) return null;

if (overage.chargeTo === "MEMBER") {
// Instant member charge. The booking proceeds; create a parent-linked
Expand Down Expand Up @@ -239,37 +254,18 @@ export async function recordOverageAtCheckout(
});
}

// Tell the member they owe the marginal + deep-link to the pay surface.
// Fire-and-forget on the outer prisma (committed-state lookup; not part of
// this rolling-back-able tx).
void prisma.programAssignment
.findUnique({
where: { id: programAssignmentId },
select: {
program: {
select: {
name: true,
contract: {
select: { organization: { select: { name: true } } },
},
},
},
},
})
.then((ctx) => {
if (!ctx) return;
return notifyOrgProgramOverageDue(userId, {
orgName: ctx.program.contract.organization.name,
programName: ctx.program.name,
amountPaise: marginalPaise,
payUrl: `/dashboard/overage?charge=${memberOverageEvent.id}`,
});
})
.catch((notifyErr) => {
console.error("[notifyOrgProgramOverageDue] failed:", notifyErr);
reportSentryError(notifyErr, { subsystem: "payments", level: "warning" });
});
return;
// Tell the member they owe the marginal + deep-link to the pay surface —
// handed back for the caller to ring post-commit rather than rung here.
// #1435 — the lookup this bell needs runs on the global client, and under
// PG_POOL_MAX=1 a global-client query issued inside the transaction queues
// behind the transaction's own connection and dies at the 3 s pg connect
// timeout, which the .catch swallowed: the bell was lost silently.
return {
userId,
programAssignmentId,
marginalPaise,
overageEventId: memberOverageEvent.id,
};
}

if (overage.chargeTo === "ORG") {
Expand Down Expand Up @@ -325,4 +321,46 @@ export async function recordOverageAtCheckout(
},
});
}

// CHARGE_ORG bills through the monthly rollup; nobody is told anything now.
return null;
}

/**
* Ring the member-due bell for a committed overage. Fire-and-forget: a booking
* that is already paid for must not fail because a notification did not go out.
*/
export function notifyOverageDueAfterCommit(
pending: PendingOverageNotification,
): void {
void prisma.programAssignment
.findUnique({
where: { id: pending.programAssignmentId },
select: {
program: {
select: {
name: true,
contract: {
select: { organization: { select: { name: true } } },
},
},
},
},
})
.then((ctx) => {
if (!ctx) return;
return notifyOrgProgramOverageDue(pending.userId, {
orgName: ctx.program.contract.organization.name,
programName: ctx.program.name,
amountPaise: pending.marginalPaise,
payUrl: `/dashboard/overage?charge=${pending.overageEventId}`,
});
})
.catch((notifyErr) => {
console.error("[notifyOrgProgramOverageDue] failed:", notifyErr);
reportSentryError(notifyErr, {
subsystem: "payments",
level: "warning",
});
});
}
Loading
Loading