pelagia-portal/App/tests/integration/po-tcs-discount.test.ts
Hardik 78afcb610b
Some checks failed
PR checks / checks (pull_request) Failing after 35s
PR checks / integration (pull_request) Successful in 33s
feat(po): TCS & Discount below GST (#133)
Adds two PO-level charges shown below GST, per issue #133 ask 2.

- Stored as ABSOLUTE rupee amounts on PurchaseOrder.tcsAmount / discountAmount
  (Decimal?, default 0; null/0 on historical & imported POs). Migration added.
- Discount is applied post-GST. totalAmount folds the charges in (net payable =
  subtotal + GST + TCS − Discount), so payments / reports / advance all use the
  true amount due. lib/po-money.ts is the single source of truth.
- Forms (create + edit) render a shared TcsDiscountFields with a % control
  bidirectionally linked to the rupee value (percentage is convenience only,
  taken against the GST-inclusive total; only the absolute amount is persisted).
- createPo / updatePo store & compute; both manager-edit actions PRESERVE the
  PO's TCS/Discount when recomputing the total; import leaves them at 0.
- PO detail shows TCS / Discount / Net payable below GST; PDF + XLSX export show
  the same breakdown and a corrected grand total.

Tests: lib/po-money unit tests; po-tcs-discount integration test (create / edit /
manager-line-edit preservation). Docs: CLAUDE.md GST section + wiki Purchase
Orders (TCS/Discount + a full "what import sets vs. not" field-mapping table).

Full unit (360) + integration (305) suites green; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:50:34 +05:30

114 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Integration test for PO-level TCS & Discount (issue #133).
* Verifies totalAmount folds in the charges (subtotal + GST + TCS Discount),
* the absolute amounts are persisted, edits update them, and a manager line edit
* preserves them.
*/
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import type { Role } from "@prisma/client";
import { createPo } from "@/app/(portal)/po/new/actions";
import { updatePo } from "@/app/(portal)/po/[id]/edit/actions";
import { managerEditLineItems } from "@/app/(portal)/approvals/[id]/manager-line-edit-actions";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, makePoForm, deletePosByTitle } from "./helpers";
const PREFIX = "INTTEST_TCSDISC_";
let techId: string;
let managerId: string;
let vesselId: string;
let accountId: string;
beforeAll(async () => {
const [tech, manager, vessel, account] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("manager@pelagia.local"),
getSeedVessel("MV Pelagia Star"),
getSeedAccount("700201"),
]);
techId = tech.id;
managerId = manager.id;
vesselId = vessel.id;
accountId = account.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
vi.clearAllMocks();
});
function as(userId: string, role: Role) {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
}
// One line item: 10 × ₹100 @ 18% GST ⇒ taxable 1000, GST 180, incl-GST 1180.
function form(title: string, intent: "draft" | "submit", tcs: number, discount: number) {
const f = makePoForm({
title, vesselId, accountId, intent,
lineItems: [{ description: "Item", quantity: 10, unit: "pc", unitPrice: 100, gstRate: 0.18 }],
});
f.set("tcsAmount", String(tcs));
f.set("discountAmount", String(discount));
return f;
}
describe("PO TCS & Discount", () => {
it("folds TCS and Discount into totalAmount and stores the absolute amounts", async () => {
as(techId, "TECHNICAL");
const result = await createPo(form(`${PREFIX}Create`, "draft", 118, 100));
expect(result).not.toHaveProperty("error");
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: (result as { id: string }).id } });
expect(Number(po.tcsAmount)).toBeCloseTo(118, 2);
expect(Number(po.discountAmount)).toBeCloseTo(100, 2);
expect(Number(po.totalAmount)).toBeCloseTo(1180 + 118 - 100, 2); // 1198
});
it("defaults to zero charges ⇒ totalAmount is just incl-GST", async () => {
as(techId, "TECHNICAL");
const result = await createPo(form(`${PREFIX}Zero`, "draft", 0, 0));
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: (result as { id: string }).id } });
expect(Number(po.totalAmount)).toBeCloseTo(1180, 2);
expect(Number(po.tcsAmount)).toBe(0);
expect(Number(po.discountAmount)).toBe(0);
});
it("edit updates the charges and recomputes the total", async () => {
as(techId, "TECHNICAL");
const created = await createPo(form(`${PREFIX}Edit`, "draft", 0, 0));
const poId = (created as { id: string }).id;
as(techId, "TECHNICAL");
const edited = await updatePo(poId, form(`${PREFIX}Edit`, "save", 50, 30));
expect(edited).not.toHaveProperty("error");
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
expect(Number(po.tcsAmount)).toBeCloseTo(50, 2);
expect(Number(po.discountAmount)).toBeCloseTo(30, 2);
expect(Number(po.totalAmount)).toBeCloseTo(1180 + 50 - 30, 2); // 1200
});
it("a manager line edit preserves the PO's TCS & Discount", async () => {
as(techId, "TECHNICAL");
const created = await createPo(form(`${PREFIX}MgrEdit`, "submit", 118, 100)); // ⇒ MGR_REVIEW
const poId = (created as { id: string }).id;
as(managerId, "MANAGER");
const res = await managerEditLineItems({
poId,
// Double the quantity: incl-GST becomes 20 × 100 × 1.18 = 2360.
lineItems: [{ name: "Item", quantity: 20, unit: "pc", unitPrice: 100 }],
});
expect(res).not.toHaveProperty("error");
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
expect(Number(po.tcsAmount)).toBeCloseTo(118, 2);
expect(Number(po.discountAmount)).toBeCloseTo(100, 2);
expect(Number(po.totalAmount)).toBeCloseTo(2360 + 118 - 100, 2); // 2378
});
});