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
74 changes: 73 additions & 1 deletion __tests__/enterprise/overage-settlement-legsum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,16 @@ function makeTx(opts: {
surchargeBps?: number | null;
priceCap?: number | null;
overageBehavior?: "CHARGE_ORG" | "CHARGE_MEMBER";
/** #1458 — which funding rail wrote the parent's base leg. */
baseSource?: "INVOICE_ACCRUAL" | "WALLET" | "LICENSE";
}) {
const legs: Leg[] = [{ source: "INVOICE_ACCRUAL", amountPaise: opts.price }];
const legs: Leg[] = [
{
source: opts.baseSource ?? "INVOICE_ACCRUAL",
// A licence leg is deliberately zero-value: the contract already paid.
amountPaise: opts.baseSource === "LICENSE" ? 0 : opts.price,
},
];
const payment = { amount: opts.price };
const children: { amount: number }[] = [];
let childSeq = 0;
Expand Down Expand Up @@ -168,6 +176,70 @@ describe("recordOverageAtCheckout — CHARGE_ORG leg-sum invariant (#785)", () =
});
});

describe("recordOverageAtCheckout — CHARGE_ORG on the WALLET rail (#1458)", () => {
it("leaves the payment at the wallet debit, adds no leg, and records the overage as collected", async () => {
const walletDebit = 258_326;
const { state, tx } = makeTx({
price: walletDebit,
cap: 5,
used: 5,
baseSource: "WALLET",
});
await recordOverageAtCheckout({
tx: tx as unknown as Tx,
...callArgs(walletDebit),
});

// The wallet already took the whole price at commit, so the marginal is
// collected: no OVERAGE_INVOICE_ACCRUAL leg, no amount bump.
expect(state.legs).toEqual([
{ source: "WALLET", amountPaise: walletDebit },
]);
expect(state.payment.amount).toBe(walletDebit);
// The cancellation quote is a percentage of Payment.amount and the refund
// cascade splits it across the legs, so one WALLET leg equal to amount is
// what makes a 100% refund return exactly the debit and not a paisa more.
Comment thread
teetangh marked this conversation as resolved.
expect(sum(state.legs)).toBe(state.payment.amount);
expect(tx.paymentLeg.create).not.toHaveBeenCalled();
expect(tx.payment.update).not.toHaveBeenCalled();

expect(tx.overageEvent.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
overageBehavior: "CHARGE_ORG",
chargeStatus: "CHARGED",
paymentId: "pay1",
settledAt: expect.any(Date),
}),
}),
);
});

// A licence is a flat fee settled at contract time, so its leg is ₹0 while
// Payment.amount stays at the full price. Adding an overage leg re-arms the
// leg-sum comparison the licence carve had suppressed, and the booking used
// to die at COMMIT on assert_payment_legs_ok instead of saying why.
it("LICENSE + CHARGE_ORG is refused rather than made additive", async () => {
const { state, tx } = makeTx({
price: 100_000,
cap: 5,
used: 5,
baseSource: "LICENSE",
});

await expect(
recordOverageAtCheckout({
tx: tx as unknown as Tx,
...callArgs(100_000),
}),
).rejects.toMatchObject({ code: "OVERAGE_UNSUPPORTED_FUNDING" });

expect(tx.paymentLeg.create).not.toHaveBeenCalled();
expect(tx.payment.update).not.toHaveBeenCalled();
expect(state.legs).toEqual([{ source: "LICENSE", amountPaise: 0 }]);
});
});

describe("recordOverageAtCheckout — CHARGE_MEMBER parent carve (#785)", () => {
it("carves basePaise off the org parent; member child pays the marginal (no double-collect)", async () => {
const { state, tx } = makeTx({
Expand Down
53 changes: 53 additions & 0 deletions __tests__/enterprise/reachable-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import {
REACHABLE_ORG_FUNDING_PATHS,
isReachableOrgFundingPath,
overageBehaviorUnsupportedReason,
capabilityOf,
} from "@/lib/enterprise/reachable-paths";

Expand Down Expand Up @@ -69,6 +70,58 @@ describe("REACHABLE_ORG_FUNDING_PATHS — v0 lockdown matrix", () => {
});
});

// #1458 — the matrix sanctions SPONSOR + WALLET + CREDIT_POOL, but a wallet
// debit takes the whole booking price at commit, so there is nothing left to
// carve back out for a member charge. Checkout could only fail closed after
// the member had picked a slot; the config is what has to be refused.
describe("overageBehaviorUnsupportedReason", () => {
it("refuses CHARGE_MEMBER on a WALLET-funded account, naming #715", () => {
const reason = overageBehaviorUnsupportedReason(
"WALLET",
"CHARGE_MEMBER",
);
expect(reason).toContain("#715");
});

it("refuses either charging behaviour on a LICENSE-funded account", () => {
// A flat licence moves no money per booking, so nothing carries the
// marginal and the leg-sum guard rejects the extra leg at COMMIT.
expect(
overageBehaviorUnsupportedReason("LICENSE", "CHARGE_ORG"),
).toContain("licence");
expect(
overageBehaviorUnsupportedReason("LICENSE", "CHARGE_MEMBER"),
).toContain("licence");
expect(overageBehaviorUnsupportedReason("LICENSE", "BLOCK")).toBeNull();
});

it("allows CHARGE_ORG and BLOCK on WALLET, and CHARGE_MEMBER on INVOICE", () => {
expect(
overageBehaviorUnsupportedReason("WALLET", "CHARGE_ORG"),
).toBeNull();
expect(overageBehaviorUnsupportedReason("WALLET", "BLOCK")).toBeNull();
expect(
overageBehaviorUnsupportedReason("INVOICE", "CHARGE_MEMBER"),
).toBeNull();
});

// A wallet debit collects the booking price, so the plain over-cap amount
// rides along inside it; a surcharge sits on top of that price and nothing
// collects it. Checkout refuses it either way, so the configuration must.
it("refuses a surcharged CHARGE_ORG on WALLET but not the plain one", () => {
expect(
overageBehaviorUnsupportedReason("WALLET", "CHARGE_ORG", 1000),
).toContain("surcharge");
expect(
overageBehaviorUnsupportedReason("WALLET", "CHARGE_ORG", 0),
).toBeNull();
// The surcharge only matters on the wallet rail — an invoice can carry it.
expect(
overageBehaviorUnsupportedReason("INVOICE", "CHARGE_ORG", 1000),
).toBeNull();
});
});

describe("capabilityOf", () => {
it.each([
[true, false, "SPONSOR"],
Expand Down
63 changes: 63 additions & 0 deletions __tests__/payments/gateway-fence-classification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,67 @@ describe("gateway fence classification", () => {
expect(toast.title).toBe("Domain Verification Required");
expect(toast.description).toBeTruthy();
});

// #1458 — the overage settlement throws PROGRAM_CAP_EXHAUSTED as a 402 with
// copy the buyer can act on, but the checkout catch rewrote it to "Failed to
// record payment information" and the classifier answered 500 UNKNOWN_ERROR.
it("classifies PROGRAM_CAP_EXHAUSTED as a 402 with its own toast", () => {
const classified = classifyError(
Object.assign(new Error("cycle ceiling reached"), {
httpStatus: 402,
code: "PROGRAM_CAP_EXHAUSTED",
}),
);

expect(classified.errorType).toBe(ErrorTypes.PROGRAM_CAP_EXHAUSTED);
expect(classified.isBusinessError).toBe(true);
expect(classified.httpStatus).toBe(402);

const toast = getErrorToast(classified.errorType);
expect(toast.title).not.toBe("Something Went Wrong");
expect(toast.description).toContain("programme budget");
});

it("classifies the other checkout-transaction refusals off their codes", () => {
expect(
classifyError(
Object.assign(new Error("session cap"), {
code: "PROGRAM_SESSION_CAP_REACHED",
}),
).httpStatus,
).toBe(402);
expect(
classifyError(
Object.assign(new Error("member overage"), {
code: "OVERAGE_CHARGE_MEMBER_UNSUPPORTED",
}),
).httpStatus,
).toBe(409);
expect(
classifyError(
Object.assign(new Error("funding"), {
code: "OVERAGE_UNSUPPORTED_FUNDING",
}),
).httpStatus,
).toBe(409);
});

// #1467 — a lapsed contract and a dunning suspension are org entitlement
// states the member's admin can clear. Both threw bare Errors, so the
// message-only fallback answered 500 UNKNOWN_ERROR on a routine refusal.
it.each([
["PROGRAM_ASSIGNMENT_INACTIVE", 409],
["BILLING_SUSPENDED_DUNNING", 402],
])("classifies %s as a business rejection with status %i", (code, status) => {
const classified = classifyError(
Object.assign(new Error("refused"), { code }),
);

expect(classified.isBusinessError).toBe(true);
expect(classified.httpStatus).toBe(status);

const toast = getErrorToast(classified.errorType);
expect(toast.title).not.toBe("Something Went Wrong");
expect(toast.description).toContain("admin");
});
});
95 changes: 95 additions & 0 deletions __tests__/payments/multi-party-booking-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ jest.mock("../../lib/prisma", () => {
},
ledgerAccountBalance: { upsert: jest.fn().mockResolvedValue({}) },
paymentLeg: { findMany: jest.fn().mockResolvedValue([]) },
// #1458 — only read when an OVERAGE_INVOICE_ACCRUAL leg funded the payment.
overageEvent: { findFirst: jest.fn().mockResolvedValue(null) },
consultantEarnings: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest
Expand Down Expand Up @@ -158,6 +160,8 @@ const mockedTx = (
membership: { findFirst: jest.Mock };
consultantEarnings: { findFirst: jest.Mock; create: jest.Mock };
ledgerTransaction: { findUnique: jest.Mock; create: jest.Mock };
paymentLeg: { findMany: jest.Mock };
overageEvent: { findFirst: jest.Mock };
};
}
).__mockTx;
Expand Down Expand Up @@ -247,6 +251,8 @@ beforeEach(() => {
capturedOrgEarnings = [];
mockedTx.consultantEarnings.findFirst.mockResolvedValue(null);
mockedTx.ledgerTransaction.findUnique.mockResolvedValue(null);
mockedTx.paymentLeg.findMany.mockResolvedValue([]);
mockedTx.overageEvent.findFirst.mockResolvedValue(null);
setStandardRateCard();
});

Expand Down Expand Up @@ -459,3 +465,92 @@ describe("#773 multi-party booking journal", () => {
expect(mockedTx.ledgerTransaction.create).not.toHaveBeenCalled();
});
});

/**
* #1458 / Sentry FAMILIARISE_WEB-28 — the BOOKING posting's credits are all
* derived from `payment.originalAmount` (+ tax), while its debits are the
* funding legs plus a DISCOUNT plug clamped at >= 0. The posting therefore
* balances only while Σ(funding legs) <= originalAmount + tax; anything that
* pushes a funding leg above the nominal price throws LedgerImbalanceError and
* the booking commits with no journal entry at all.
*/
describe("#1458 org-overage rails keep the booking journal balanced", () => {
it("WALLET + CHARGE_ORG: the wallet leg alone funds the price and the posting balances", async () => {
// The exact #1458 payment: a 258,326-paise wallet debit on a 218,920 +
// 39,406 booking. Before the fix an OVERAGE_INVOICE_ACCRUAL leg of 248,326
// was added and Payment.amount became 506,652, so debits overshot the
// credits by the marginal and the journal was dropped.
setMembershipMap({ [PRIMARY_PROFILE]: null });
mockedCalculateSplit.mockResolvedValue([]);
mockedTx.paymentLeg.findMany.mockResolvedValue([
{ source: "WALLET", amountPaise: 258_326 },
]);

await createEarningsFromPayment({
payment: makePayment({
amount: 258_326,
originalAmount: 218_920,
taxAmount: 39_406,
}),
appointmentType: "CONSULTATION",
});

const txn = capturedLedgerTxns[0];
const legs = legsOf(txn);
const debit = legs
.filter((l) => l.direction === "DEBIT")
.reduce((s, l) => s + l.paise, 0);
const credit = legs
.filter((l) => l.direction === "CREDIT")
.reduce((s, l) => s + l.paise, 0);
expect(debit).toBe(258_326);
expect(credit).toBe(258_326);
// No leg was added and no amount bumped, so the wallet debit is the whole
// funding side and the platform absorbs nothing as DISCOUNT.
expect(legAmount(txn, "DEBIT", "WALLET|_|_|INR")).toBe(258_326);
expect(legAmount(txn, "DEBIT", "DISCOUNT|_|_|INR")).toBe(0);
expect(mockedTx.overageEvent.findFirst).not.toHaveBeenCalled();
});

it("INVOICE + CHARGE_ORG with a surcharge: the surcharge is credited to PLATFORM_FEE", async () => {
// #785's carve leaves INVOICE_ACCRUAL at 0 and OVERAGE_INVOICE_ACCRUAL at
// base + surcharge, and bumps Payment.amount by the surcharge — which is
// real funding that sits OUTSIDE originalAmount. The surcharge is platform
// revenue for exceeding the cap, so it credits PLATFORM_FEE.
setMembershipMap({ [PRIMARY_PROFILE]: null });
mockedCalculateSplit.mockResolvedValue([]);
mockedTx.paymentLeg.findMany.mockResolvedValue([
{ source: "INVOICE_ACCRUAL", amountPaise: 0 },
{ source: "OVERAGE_INVOICE_ACCRUAL", amountPaise: 125_000 },
]);
mockedTx.overageEvent.findFirst.mockResolvedValue({
surchargePaise: BigInt(25_000),
});

await createEarningsFromPayment({
payment: makePayment({
amount: 125_000,
originalAmount: 100_000,
taxAmount: 0,
}),
appointmentType: "CONSULTATION",
});

const txn = capturedLedgerTxns[0];
const legs = legsOf(txn);
const debit = legs
.filter((l) => l.direction === "DEBIT")
.reduce((s, l) => s + l.paise, 0);
const credit = legs
.filter((l) => l.direction === "CREDIT")
.reduce((s, l) => s + l.paise, 0);
expect(debit).toBe(125_000);
expect(credit).toBe(125_000);
// 20% of the nominal 100_000 plus the whole 25_000 surcharge; the
// consultant pool stays on the nominal price alone.
expect(legAmount(txn, "CREDIT", "PLATFORM_FEE|_|_|INR")).toBe(45_000);
expect(
legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${PRIMARY_PROFILE}|INR`),
).toBe(80_000);
});
Comment thread
teetangh marked this conversation as resolved.
});
31 changes: 31 additions & 0 deletions __tests__/payments/reconcile-reservation-match.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,35 @@ describe("reconcilePendingRefunds real-id PENDING polling", () => {
expect(result.failedCount).toBe(0);
expect(refundTable.update).not.toHaveBeenCalled();
});

// #1458 — with STRIPE_ENABLED unset, the Stripe client is never built, so
// getRefund threw for every Stripe row, the error list filled up and the whole
// run reported success:false — the cleanup route answered 500 for what is
// deliberate configuration.
test("a fenced STRIPE refund is skipped and counted, not failed", async () => {
const previous = process.env.STRIPE_ENABLED;
delete process.env.STRIPE_ENABLED;
try {
refundTable.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: "row_stripe",
refundId: "re_real",
status: "PENDING",
amountPaise: 10_000,
createdAt: new Date(Date.now() - 3 * HOUR),
payment: { paymentGateway: "STRIPE" },
},
]);

const result = await reconcilePendingRefunds();

expect(mockGet).not.toHaveBeenCalled();
expect(result.skippedFenced).toBe(1);
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
} finally {
if (previous === undefined) delete process.env.STRIPE_ENABLED;
else process.env.STRIPE_ENABLED = previous;
}
});
});
Loading
Loading