/** * User stories covered: Feature 8 — Partial receipt confirmation * - ACCOUNTS user sees a receipt/delivery section on SENT_FOR_PAYMENT or PAID_DELIVERED POs * - The receipt section shows individual line items with per-item tracking controls * US-8c: PARTIALLY_CLOSED PO appears in submitter's My Orders page under Open Orders * US-8d: Submitter can reach /receipt from a PARTIALLY_CLOSED PO detail ("Confirm Remaining" CTA) * US-8e: Receipt form shows editable inputs only for remaining items; fully-delivered items show "—" * * Limitation: This requires a PO in SENT_FOR_PAYMENT or PAID_DELIVERED status. * The test drives the full flow (create → submit → approve → process payment) if * no such PO is present in seed data. If the seed has a PAID_DELIVERED PO the test * will find it via the payment history page. * * Created: 2026-05-17 * Updated: 2026-05-27 — added US-8c/8d/8e for PARTIALLY_CLOSED revisit flow */ import { test, expect, type BrowserContext } from "@playwright/test"; import { login, USERS, submitPo } from "./helpers/login"; import { fillPoHeader } from "./helpers/login"; /** * Drive: create PO (two line items) → approve → confirm payment → partial receipt * (deliver item 1 only, leave item 2 undelivered) → returns PO URL in PARTIALLY_CLOSED state. * * Line item layout: * Row 0 — "Engine gasket" qty 2 (will be fully delivered in the partial receipt) * Row 1 — "Oil filter" qty 4 (will NOT be delivered — remaining stays outstanding) */ async function createPartiallyClosedPo( page: import("@playwright/test").Page, context: BrowserContext, title: string ): Promise { // ── Step 1: Create + submit PO as TECH with two line items ─────────────── await login(page, USERS.TECH); await page.goto("/po/new"); await fillPoHeader(page, title); // Select a vendor (required for manager approval) const vendorSelectPC = page.locator('select[name="vendorId"]'); await expect(vendorSelectPC).toBeVisible({ timeout: 5_000 }); await vendorSelectPC.selectOption({ index: 1 }); // Fill first line item (pre-existing row) const lineItemsSection = page.locator("section").filter({ has: page.getByRole("heading", { name: /line items/i }), }); const firstRow = lineItemsSection.locator("tbody tr").first(); await firstRow.getByPlaceholder("Item name *").fill("Engine gasket"); await firstRow.locator('input[type="number"]').first().fill("2"); await firstRow.locator('input[placeholder="0.00"]').fill("250"); // Add a second line item await page.getByRole("button", { name: /add (line )?item/i }).click(); const rows = lineItemsSection.locator("tbody tr"); await expect(rows).toHaveCount(2, { timeout: 5_000 }); const secondRow = rows.nth(1); await secondRow.getByPlaceholder("Item name *").fill("Oil filter"); await secondRow.locator('input[type="number"]').first().fill("4"); await secondRow.locator('input[placeholder="0.00"]').fill("80"); await page.getByRole("button", { name: /submit for approval/i }).click(); // Wait for redirect to the PO detail page — must NOT be /po/new await expect(page).toHaveURL(/\/po\/(?!new)[^/]+$/, { timeout: 20_000 }); const poUrl = page.url(); // ── Step 2: Approve as MANAGER via /approvals/ ─────────────────────── // Extract PO id from the URL (/po/) to construct the approvals URL const poId = poUrl.split("/po/")[1].replace(/[?#].*$/, "").replace(/\/$/, ""); await context.clearCookies(); await login(page, USERS.MANAGER); await page.goto(`/approvals/${poId}`); // ApprovalActions renders an "Approve" button (no signature guard in test env seed) const approveBtn = page.getByRole("button", { name: /^approve$/i }); await expect(approveBtn).toBeVisible({ timeout: 15_000 }); await approveBtn.click(); // After approval the router pushes to /approvals — verify the PO is no longer in MGR_REVIEW await expect(page).not.toHaveURL(/\/approvals\/[^/]+$/, { timeout: 15_000 }); // ── Step 3: Start payment processing as ACCOUNTS ──────────────────────── await context.clearCookies(); await login(page, USERS.ACCOUNTS); await page.goto("/payments"); const cardLoc3 = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) }); await expect(cardLoc3).toBeVisible({ timeout: 10_000 }); await cardLoc3.getByRole("button", { name: /start payment processing/i }).click(); // Reload to get a fresh server-rendered page in SENT_FOR_PAYMENT state (avoids React // state race where the card shows "Confirming…" before we can interact with the ref input) await page.reload(); // ── Step 4: Confirm payment as ACCOUNTS (→ PAID_DELIVERED) ─────────────── const cardLoc4 = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) }); await expect(cardLoc4).toBeVisible({ timeout: 10_000 }); const refInputPC = cardLoc4.getByPlaceholder(/payment reference|transaction/i); await expect(refInputPC).toBeVisible({ timeout: 10_000 }); await refInputPC.fill("NEFT-E2E-PAID-PC"); await cardLoc4.getByRole("button", { name: /confirm payment sent/i }).click(); // After successful payment the PO moves to PAID_DELIVERED and the card disappears await page.reload(); await expect( page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) }) ).not.toBeVisible({ timeout: 10_000 }); // ── Step 5: Partial receipt as TECH — deliver item 0 only ─────────────── await context.clearCookies(); await login(page, USERS.TECH); await page.goto(`${poUrl}/receipt`); await expect(page).toHaveURL(/\/receipt$/, { timeout: 10_000 }); // The receipt form defaults all "Receiving Now" inputs to the full remaining quantity. // Set the second item (Oil filter) to 0 so only item 1 is received. const receivingInputs = page.locator('input[type="number"][min="0"]'); await expect(receivingInputs).toHaveCount(2, { timeout: 10_000 }); // Clear the second item's delivery quantity to 0 await receivingInputs.nth(1).fill("0"); // Submit partial receipt await page.getByRole("button", { name: /confirm partial receipt/i }).click(); // Should redirect back to the PO detail page await expect(page).toHaveURL(/\/po\/[^/]+$/, { timeout: 15_000 }); // Verify status is now PARTIALLY_CLOSED (status badge text) await expect(page.getByText(/partially/i).first()).toBeVisible({ timeout: 10_000 }); return poUrl; } /** Drive: create PO → approve as manager → process payment as accounts → return PO URL */ async function createSentForPaymentPo( page: import("@playwright/test").Page, context: BrowserContext, title: string ): Promise { // Step 1: Create + submit PO as tech (single line item) await login(page, USERS.TECH); await page.goto("/po/new"); await fillPoHeader(page, title); // Select a vendor (required for manager approval) const vendorSelectSFP = page.locator('select[name="vendorId"]'); await expect(vendorSelectSFP).toBeVisible({ timeout: 5_000 }); await vendorSelectSFP.selectOption({ index: 1 }); const lineItemsSection = page.locator("section").filter({ has: page.getByRole("heading", { name: /line items/i }), }); const firstRow = lineItemsSection.locator("tbody tr").first(); await firstRow.getByPlaceholder("Item name *").fill("Engine gasket"); await firstRow.locator('input[type="number"]').first().fill("2"); await firstRow.locator('input[placeholder="0.00"]').fill("250"); await page.getByRole("button", { name: /submit for approval/i }).click(); // Wait for redirect to a real PO detail page (not /po/new) await expect(page).toHaveURL(/\/po\/(?!new)[^/]+$/, { timeout: 20_000 }); const poUrl = page.url(); const poId = poUrl.split("/po/")[1].replace(/[?#].*$/, "").replace(/\/$/, ""); // Step 2: Approve as manager via /approvals/ await context.clearCookies(); await login(page, USERS.MANAGER); await page.goto(`/approvals/${poId}`); const approveBtn = page.getByRole("button", { name: /^approve$/i }); await expect(approveBtn).toBeVisible({ timeout: 15_000 }); await approveBtn.click(); await expect(page).not.toHaveURL(/\/approvals\/[^/]+$/, { timeout: 15_000 }); // Step 3: Start payment processing as accounts via /payments queue await context.clearCookies(); await login(page, USERS.ACCOUNTS); await page.goto("/payments"); const cardSFP = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) }); await expect(cardSFP).toBeVisible({ timeout: 10_000 }); await cardSFP.getByRole("button", { name: /start payment processing/i }).click(); // Reload to get a fresh server-rendered SENT_FOR_PAYMENT state await page.reload(); // Verify the ref input is now visible (card is in SENT_FOR_PAYMENT state) const cardSFP2 = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title }) }); await expect(cardSFP2.getByPlaceholder(/payment reference|transaction/i)).toBeVisible({ timeout: 10_000 }); return poUrl; } test.describe("Feature 8 — Partial receipt confirmation", () => { // Each test drives the full PO lifecycle (create → approve → pay → receipt). // This takes well over 30 s per test on a local dev server under load. test.setTimeout(180_000); test("US-8a: ACCOUNTS user sees delivery/receipt section on SENT_FOR_PAYMENT PO", async ({ page, context, }: { page: import("@playwright/test").Page; context: BrowserContext; }) => { const title8a = `E2E_RECEIPT_SEC_${Date.now()}`; await createSentForPaymentPo(page, context, title8a); // Stay on /payments — the PO should show "Confirm Payment Sent" form now // (createSentForPaymentPo ends with the PO in SENT_FOR_PAYMENT on the /payments page) await page.goto("/payments"); const poCard8a = page.locator("div.rounded-lg").filter({ hasText: title8a }); await expect(poCard8a).toBeVisible({ timeout: 10_000 }); await expect( poCard8a.getByPlaceholder(/payment reference|transaction/i) ).toBeVisible({ timeout: 10_000 }); await expect( poCard8a.getByRole("button", { name: /confirm payment sent/i }) ).toBeVisible({ timeout: 10_000 }); console.log("✓ Payment reference input and Confirm Payment Sent button visible on SENT_FOR_PAYMENT PO"); }); test("US-8b: TECHNICAL submitter sees receipt section with line items on PAID_DELIVERED PO", async ({ page, context, }: { page: import("@playwright/test").Page; context: BrowserContext; }) => { // Drive the full flow to SENT_FOR_PAYMENT const title8b = `E2E_RECEIPT_ITEMS_${Date.now()}`; const poUrl = await createSentForPaymentPo(page, context, title8b); // Mark as paid via /payments — createSentForPaymentPo leaves the PO in SENT_FOR_PAYMENT await page.goto("/payments"); const card8b = page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title8b }) }); await expect(card8b).toBeVisible({ timeout: 10_000 }); const refInput8b = card8b.getByPlaceholder(/payment reference|transaction/i); await expect(refInput8b).toBeVisible({ timeout: 10_000 }); await refInput8b.fill("NEFT-E2E-RECEIPT-8B"); await card8b.getByRole("button", { name: /confirm payment sent/i }).click(); // Reload to get fresh state; card should now be gone (PO moved to PAID_DELIVERED) await page.reload(); await expect( page.locator("div.rounded-lg").filter({ has: page.getByRole("heading", { name: title8b }) }) ).not.toBeVisible({ timeout: 10_000 }); // Switch to tech submitter to view the receipt section await context.clearCookies(); await login(page, USERS.TECH); await page.goto(poUrl); // The PoDetail component shows a "Confirm Receipt" link for PAID_DELIVERED + submitter const confirmReceiptLink = page.getByRole("link", { name: /confirm receipt/i }); await expect(confirmReceiptLink).toBeVisible({ timeout: 10_000 }); console.log("✓ Confirm Receipt link visible to TECHNICAL submitter on PAID_DELIVERED PO"); }); test("US-8a: receipt page at /po/[id]/receipt is accessible for PAID_DELIVERED PO", async ({ page, context, }: { page: import("@playwright/test").Page; context: BrowserContext; }) => { // Try to find an existing PAID_DELIVERED PO from payment history // Fall back to driving the full flow if none found await login(page, USERS.ACCOUNTS); await page.goto("/payments/history"); const firstPoLink = page.locator("table tbody tr a").first(); if (await firstPoLink.isVisible()) { await firstPoLink.click(); await expect(page).toHaveURL(/\/po\//); console.log("✓ Navigated to a paid PO detail page via Payment History"); } else { // Skip with note when no paid POs exist test.skip(true, "No PAID_DELIVERED POs in seed data — drive full flow in US-8b"); } }); // ── PARTIALLY_CLOSED revisit flow (US-8c / 8d / 8e) ────────────────────── test("US-8c: PARTIALLY_CLOSED PO appears in submitter's My Orders page under Open Orders", async ({ page, context, }: { page: import("@playwright/test").Page; context: BrowserContext; }) => { const title = `E2E_PC_MYORDERS_${Date.now()}`; await createPartiallyClosedPo(page, context, title); // Still logged in as TECH — navigate to My Orders await page.goto("/my-orders"); await expect(page).toHaveURL(/\/my-orders/, { timeout: 10_000 }); // The page has an "Open Orders" heading followed by a table const openOrdersSection = page.locator("div", { has: page.getByRole("heading", { name: /open orders/i }) }); await expect(openOrdersSection).toBeVisible({ timeout: 10_000 }); // The PO title should appear inside that section const poRow = openOrdersSection.getByText(title); await expect(poRow).toBeVisible({ timeout: 10_000 }); console.log(`✓ PARTIALLY_CLOSED PO "${title}" is visible in the Open Orders section of My Orders`); }); test("US-8d: submitter can reach the receipt page from a PARTIALLY_CLOSED PO detail via 'Confirm Remaining'", async ({ page, context, }: { page: import("@playwright/test").Page; context: BrowserContext; }) => { const title = `E2E_PC_CTA_${Date.now()}`; const poUrl = await createPartiallyClosedPo(page, context, title); // Still logged in as TECH — go back to the PO detail page await page.goto(poUrl); // Expect the amber "Partially received" CTA banner await expect(page.getByText(/partially received/i)).toBeVisible({ timeout: 10_000 }); await expect( page.getByText(/some items are still outstanding/i) ).toBeVisible({ timeout: 10_000 }); // The "Confirm Remaining" link must be present const confirmRemainingLink = page.getByRole("link", { name: /confirm remaining/i }); await expect(confirmRemainingLink).toBeVisible({ timeout: 10_000 }); // Click the link and confirm navigation to the /receipt page await confirmRemainingLink.click(); await expect(page).toHaveURL(/\/po\/[^/]+\/receipt$/, { timeout: 10_000 }); // The receipt page heading should say "Confirm Remaining Receipt" await expect( page.getByRole("heading", { name: /confirm remaining receipt/i }) ).toBeVisible({ timeout: 10_000 }); // The amber "partially received" notice on the receipt page must also be present await expect( page.getByText(/this po has been partially received/i) ).toBeVisible({ timeout: 10_000 }); console.log("✓ 'Confirm Remaining' CTA visible on PARTIALLY_CLOSED PO; navigates to /receipt with correct heading"); }); test("US-8e: receipt form shows editable inputs only for items with remaining quantity; fully-delivered items show '—'", async ({ page, context, }: { page: import("@playwright/test").Page; context: BrowserContext; }) => { const title = `E2E_PC_FORM_${Date.now()}`; const poUrl = await createPartiallyClosedPo(page, context, title); // Navigate directly to the /receipt page as TECH (still logged in) await page.goto(`${poUrl}/receipt`); await expect(page).toHaveURL(/\/receipt$/, { timeout: 10_000 }); // The table has 2 rows — one fully delivered (Engine gasket) and one remaining (Oil filter) const tableRows = page.locator("table tbody tr"); await expect(tableRows).toHaveCount(2, { timeout: 10_000 }); // ── Row 0: Engine gasket — fully received → "Receiving Now" cell must show "—" (no input) ── const firstRow = tableRows.first(); // The fully-received row should show the "✓ fully received" label await expect(firstRow.getByText(/fully received/i)).toBeVisible({ timeout: 5_000 }); // No number input should be present in this row const inputInFirstRow = firstRow.locator('input[type="number"]'); await expect(inputInFirstRow).toHaveCount(0, { timeout: 5_000 }); // The "—" dash placeholder should be visible in the Receiving Now column const receivingNowCell = firstRow.locator("td").last(); await expect(receivingNowCell.getByText("—")).toBeVisible({ timeout: 5_000 }); // ── Row 1: Oil filter — remaining → "Receiving Now" cell must show an editable input ── const secondRow = tableRows.nth(1); await expect(secondRow.locator('input[type="number"]')).toHaveCount(1, { timeout: 5_000 }); // The remaining quantity column for row 1 should not be "—" (items are still outstanding) const remainingCell = secondRow.locator("td").nth(3); await expect(remainingCell.getByText("—")).toHaveCount(0, { timeout: 5_000 }); console.log("✓ Fully-delivered item shows '—' with no input; remaining item has editable Receiving Now input"); }); });