fix(receipt): upsert Receipt record on repeat confirmations with notes

Partial-receipt flows call confirmReceipt multiple times. The nested
`create` on the Receipt relation threw a unique-constraint error on the
second call when both confirmations supplied notes, preventing any
delivery from completing and blocking attachment uploads.

Changed to `upsert` so subsequent confirmations update the existing
Receipt row's notes instead of failing.

Adds integration tests covering full receipt, partial receipt, the
upsert scenario (two confirmations each with notes), and permission guards.

Fixes #9
This commit is contained in:
Claude (auto-fix) 2026-06-19 04:01:26 +05:30
parent 600f637de2
commit 9adc93e54a
2 changed files with 203 additions and 1 deletions

View file

@ -109,7 +109,12 @@ export async function confirmReceipt({
status: newStatus,
closedAt: newStatus === "CLOSED" ? new Date() : undefined,
receipt: notes
? { create: { storageKey: "", fileName: "no-file", notes } }
? {
upsert: {
create: { storageKey: "", fileName: "no-file", notes },
update: { notes },
},
}
: undefined,
actions: {
create: {

View file

@ -0,0 +1,197 @@
/**
* Integration tests for the confirmReceipt server action.
* Covers: full receipt, partial receipt, upsert notes on repeated confirmation,
* and permission guards.
*/
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 { createPo } from "@/app/(portal)/po/new/actions";
import { approvePo } from "@/app/(portal)/approvals/[id]/actions";
import { processPayment, markPaid } from "@/app/(portal)/payments/actions";
import { confirmReceipt } from "@/app/(portal)/po/[id]/receipt/actions";
import {
makeSession,
getSeedUser,
getSeedVessel,
getSeedAccount,
makePoForm,
deletePosByTitle,
} from "./helpers";
const PREFIX = "INTTEST_RECEIPT_";
const TODAY = new Date().toISOString().slice(0, 10);
let techId: string;
let managerId: string;
let accountsId: string;
let vesselId: string;
let accountId: string;
beforeAll(async () => {
const [tech, mgr, acct, vessel, account] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("manager@pelagia.local"),
getSeedUser("accounts@pelagia.local"),
getSeedVessel("MV Sea Breeze"),
getSeedAccount("700202"),
]);
techId = tech.id;
managerId = mgr.id;
accountsId = acct.id;
vesselId = vessel.id;
accountId = account.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
});
/** Create a PO and drive it to PAID_DELIVERED (fully paid). */
async function createPaidPo(title: string): Promise<string> {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
const { id: poId } = (await createPo(form)) as { id: string };
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId });
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
await markPaid({ poId, paymentRef: "NEFT-TEST-RECEIPT", paymentDate: TODAY });
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
return poId;
}
describe("confirmReceipt — full delivery", () => {
it("transitions PAID_DELIVERED to CLOSED when all items delivered", async () => {
const poId = await createPaidPo(`${PREFIX}Full`);
const result = await confirmReceipt({ poId });
expect(result).toEqual({ ok: true, partial: false });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("CLOSED");
expect(po?.closedAt).not.toBeNull();
});
it("records RECEIPT_CONFIRMED in audit log", async () => {
const poId = await createPaidPo(`${PREFIX}Audit`);
await confirmReceipt({ poId });
const action = await db.pOAction.findFirst({
where: { poId, actionType: "RECEIPT_CONFIRMED" },
});
expect(action).not.toBeNull();
});
it("saves delivery notes on the Receipt record", async () => {
const poId = await createPaidPo(`${PREFIX}Notes`);
await confirmReceipt({ poId, notes: "All items received in good condition." });
const receipt = await db.receipt.findUnique({ where: { poId } });
expect(receipt?.notes).toBe("All items received in good condition.");
});
});
describe("confirmReceipt — partial delivery", () => {
it("transitions PAID_DELIVERED to PARTIALLY_CLOSED when some items remain", async () => {
const poId = await createPaidPo(`${PREFIX}Partial`);
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
const deliveries: Record<string, number> = {};
for (const li of lineItems) deliveries[li.id] = 0; // deliver nothing
const result = await confirmReceipt({ poId, deliveries });
// delivering 0 of everything → nothingDelivered guard is in the UI, not the action
// action still proceeds and computes PARTIALLY_CLOSED (paid but 0 delivered)
expect(result).toEqual({ ok: true, partial: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
// fully paid but nothing delivered → PARTIALLY_CLOSED
expect(po?.status).toBe("PARTIALLY_CLOSED");
});
it("returns partial:true for a partial delivery", async () => {
const poId = await createPaidPo(`${PREFIX}PartialQty`);
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
const half = Math.floor(Number(lineItems[0].quantity) / 2);
const deliveries = { [lineItems[0].id]: half };
const result = await confirmReceipt({ poId, deliveries });
expect(result).toEqual({ ok: true, partial: true });
});
});
describe("confirmReceipt — repeated notes upsert (regression for partial → full flow)", () => {
it("succeeds on second call with notes after first partial confirmation also had notes", async () => {
const poId = await createPaidPo(`${PREFIX}Upsert`);
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
const half = Math.floor(Number(lineItems[0].quantity) / 2);
const remaining = Number(lineItems[0].quantity) - half;
// First confirmation: partial delivery with notes — creates Receipt row
const first = await confirmReceipt({
poId,
notes: "First batch received.",
deliveries: { [lineItems[0].id]: half },
});
expect(first).toEqual({ ok: true, partial: true });
// Second confirmation: deliver the rest, also with notes — must not throw
// (previously crashed due to unique constraint on Receipt.poId when using `create`)
const second = await confirmReceipt({
poId,
notes: "Remaining items received.",
deliveries: { [lineItems[0].id]: remaining },
});
expect(second).toEqual({ ok: true, partial: false });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("CLOSED");
// Notes should reflect the latest confirmation
const receipt = await db.receipt.findUnique({ where: { poId } });
expect(receipt?.notes).toBe("Remaining items received.");
});
});
describe("confirmReceipt — permission guards", () => {
it("rejects non-submitter who is not SUPERUSER", async () => {
const poId = await createPaidPo(`${PREFIX}PermFail`);
const otherTech = await getSeedUser("tech@pelagia.local");
// Use a different user id to simulate a different submitter
const fakeSession = makeSession(managerId, "TECHNICAL");
vi.mocked(auth).mockResolvedValue(fakeSession);
const result = await confirmReceipt({ poId });
expect(result).toHaveProperty("error");
void otherTech; // suppress unused warning
});
it("rejects confirmation on a PO in wrong status", async () => {
// Create a PO that is still DRAFT (no payment yet)
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}WrongStatus`, vesselId, accountId, intent: "draft" });
const { id: poId } = (await createPo(form)) as { id: string };
const result = await confirmReceipt({ poId });
expect(result).toHaveProperty("error");
});
it("returns error when PO does not exist", async () => {
const result = await confirmReceipt({ poId: "nonexistent-po-id" });
expect(result).toHaveProperty("error");
});
it("returns error when not authenticated", async () => {
vi.mocked(auth).mockResolvedValue(null);
const result = await confirmReceipt({ poId: "any-id" });
expect(result).toHaveProperty("error");
});
});