import { test, expect, createOrderedPurchaseOrder, invoiceIdFromUrl, seedSupplierWithDefaultExpenseAccount, } from "../fixtures/vendor-invoice-processing"; import { InvoiceCreateFormPage } from "../pages/invoice-create-form"; import { InvoiceDetailPage } from "../pages/invoice-detail"; import { InvoiceListPage } from "../pages/invoice-list"; /** * E2E coverage for the `vendor-invoice-processing` business flow, driven end to * end through the live frontend by the purchase agent (the machine user acting * as the AP operator / Purchaser). * * Each test walks the flow diagram's main path as a single journey: capture a * vendor invoice as a DRAFT Account Payable document (either from an ORDERED * purchase order or as a standalone "Other" invoice), register it to run 3-way * matching, and — when registration flags a variance — review and release the * resulting hold. Posting to the general ledger is out of scope for the * template, so the flow ends at REGISTERED / holds-cleared. */ // Friendly label of the hold raised when the invoiced quantity exceeds the // posted received quantity — a fresh PO with no receipt always trips this at // registration, which gives the release-hold story a deterministic trigger. const RECEIVED_QTY_HOLD_LABEL = "Invoiced quantity exceeds the received quantity"; test.describe("vendor-invoice-processing", () => { // PO path main flow: place an ORDERED PO, capture the vendor invoice from it // (lines pre-filled), register it (3-way matching raises a SYSTEM hold because // nothing was received), then review and release the hold. test("purchaser captures a PO-based invoice, registers it, and releases the matching hold", async ({ page, }) => { const externalRef = `E2E-INV-PO-${Date.now()}`; const poDocNumber = await createOrderedPurchaseOrder(page, externalRef); // Capture the vendor invoice from the ORDERED purchase order. In PO mode the // lines are derived from the PO and the account defaults are pre-selected, // so the happy path is: pick the type, pick the PO, submit. Select the PO // this test just raised by its docNumber — the ORDERED-PO list is shared // across runs, so "first" could match another PO and skew the 3-way match. const createForm = new InvoiceCreateFormPage(page); await createForm.goto(); await expect(createForm.invoiceTypeSelect).toBeVisible(); await createForm.selectInvoiceType(/purchase order invoice/i); await createForm.selectPurchaseOrderByDocNumber(poDocNumber); // The read-only preview confirms the invoice picked up the PO's lines. await expect(createForm.previewLinesHeading).toBeVisible(); await createForm.submit(); // Redirects straight to the new invoice's detail page in DRAFT status. await page.waitForURL(/\/procurement\/invoice\/[^/]+$/); const detailPage = new InvoiceDetailPage(page); await expect(detailPage.heading).toBeVisible(); await expect(detailPage.statusBadge).toHaveText(/draft/i); // Register — DRAFT -> REGISTERED. Registration succeeds even when matching // places holds, and the Register action is only offered while DRAFT, so it // disappears once the invoice leaves DRAFT. await expect(detailPage.registerButton).toBeVisible(); await detailPage.registerButton.click(); await expect(detailPage.statusBadge).toHaveText(/registered/i); await expect(detailPage.registerButton).toHaveCount(0); // 3-way matching flagged the un-received quantity, so an Active Holds card // surfaces with a per-hold Release action. Review and release the hold. await expect(detailPage.activeHoldsCardTitle).toBeVisible(); const releaseButton = detailPage.releaseHoldButton(RECEIVED_QTY_HOLD_LABEL); await expect(releaseButton).toBeVisible(); await releaseButton.click(); // The released hold drops out of Active Holds and its Release action is gone; // releasing does not change the invoice status (still REGISTERED). await expect(releaseButton).toHaveCount(0); await expect(detailPage.activeHoldsCardTitle).toHaveCount(0); await expect(detailPage.statusBadge).toHaveText(/registered/i); }); // "Other" (no-PO) path: capture a standalone freight invoice against a // supplier with amount-only lines, then register it. Amount-only lines carry // no source, so there is nothing to 3-way match and no holds are raised. test("purchaser captures an Other invoice with manual lines and registers it without holds", async ({ page, }) => { const supplierInvoiceNumber = `E2E-OTHER-${Date.now()}`; const supplier = await seedSupplierWithDefaultExpenseAccount(); const createForm = new InvoiceCreateFormPage(page); await createForm.goto(); await expect(createForm.invoiceTypeSelect).toBeVisible(); await createForm.selectInvoiceType(/other invoice/i); await expect(createForm.supplierCombobox).toBeVisible(); await expect(createForm.distributionAccountSelect).not.toContainText( supplier.defaultExpenseAccount.code, ); await createForm.selectSupplierByName(supplier.name); await expect(createForm.distributionAccountSelect).toContainText( supplier.defaultExpenseAccount.code, ); await createForm.fillSupplierInvoiceNumber(supplierInvoiceNumber); await createForm.fillManualLine(0, "Freight", "125.00"); await createForm.submit(); // Redirects to the new invoice's detail page in DRAFT status. await page.waitForURL(/\/procurement\/invoice\/[^/]+$/); const detailPage = new InvoiceDetailPage(page); await expect(detailPage.heading).toBeVisible(); await expect(detailPage.statusBadge).toHaveText(/draft/i); // Register — nothing to match, so it goes straight to REGISTERED with no // Active Holds card. await expect(detailPage.registerButton).toBeVisible(); await detailPage.registerButton.click(); await expect(detailPage.statusBadge).toHaveText(/registered/i); await expect(detailPage.registerButton).toHaveCount(0); await expect(detailPage.activeHoldsCardTitle).toHaveCount(0); }); // List triage: a captured invoice is surfaced on the invoice list and links // back to its detail page by document number. test("purchaser finds a captured invoice in the list and opens it", async ({ page }) => { const supplierInvoiceNumber = `E2E-LIST-${Date.now()}`; const supplier = await seedSupplierWithDefaultExpenseAccount(); const createForm = new InvoiceCreateFormPage(page); await createForm.goto(); await expect(createForm.invoiceTypeSelect).toBeVisible(); await createForm.selectInvoiceType(/other invoice/i); await expect(createForm.supplierCombobox).toBeVisible(); await createForm.selectSupplierByName(supplier.name); await expect(createForm.distributionAccountSelect).toContainText( supplier.defaultExpenseAccount.code, ); await createForm.fillSupplierInvoiceNumber(supplierInvoiceNumber); await createForm.fillManualLine(0, "Consulting fee", "500.00"); await createForm.submit(); await page.waitForURL(/\/procurement\/invoice\/[^/]+$/); const detailPage = new InvoiceDetailPage(page); await expect(detailPage.heading).toBeVisible(); const invoiceId = invoiceIdFromUrl(page); // View Invoice List — the new invoice is listed for triage and links back // to the detail page by supplier invoice number. const listPage = new InvoiceListPage(page); await listPage.goto(); await expect(listPage.heading).toBeVisible(); await expect(listPage.getRow(supplierInvoiceNumber).first()).toBeVisible(); await listPage.getViewLink(supplierInvoiceNumber).first().click(); await page.waitForURL(new RegExp(`/procurement/invoice/${invoiceId}$`)); await expect(detailPage.heading).toBeVisible(); }); });