(todayLocal());
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
- const remaining = totalAmount - paidAmount;
const today = todayLocal();
async function handleProcessPayment() {
@@ -120,6 +141,11 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
className="w-full sm:w-36 rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
+ {advancePrefill && (
+
+ Manager set an advance of {Number(suggestedAdvancePayment).toFixed(2)} — prefilled below; adjust if needed.
+
+ )}
{error && {error}}
{isPartialPayment && (
diff --git a/App/app/(portal)/po/[id]/page.tsx b/App/app/(portal)/po/[id]/page.tsx
index e61d47b..cb5cabd 100644
--- a/App/app/(portal)/po/[id]/page.tsx
+++ b/App/app/(portal)/po/[id]/page.tsx
@@ -2,6 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { notFound, redirect } from "next/navigation";
import { PoDetail } from "@/components/po/po-detail";
+import { canViewAllPos } from "@/lib/permissions";
import { VendorIdForm } from "./vendor-id-form";
import type { Metadata } from "next";
@@ -39,11 +40,11 @@ export default async function PoDetailPage({ params }: Props) {
if (!po) notFound();
- // Submitters can only view their own POs (unless they have view_all_pos)
- const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(
- session.user.role
- );
- if (!canViewAll && po.submitterId !== session.user.id) redirect("/dashboard");
+ // Submitters can only view their own POs — unless they hold view_all_pos, or the
+ // submitter-view-all feature flag grants them read access to every PO.
+ if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) {
+ redirect("/dashboard");
+ }
const canProvideVendorId =
po.status === "VENDOR_ID_PENDING" &&
diff --git a/App/app/api/po/[id]/export/route.ts b/App/app/api/po/[id]/export/route.ts
index 054a9e4..41e895e 100644
--- a/App/app/api/po/[id]/export/route.ts
+++ b/App/app/api/po/[id]/export/route.ts
@@ -7,6 +7,7 @@ import { downloadBuffer } from "@/lib/storage";
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
import { getImageSize, scaleToBox } from "@/lib/image-size";
import { signatoryLayout } from "@/lib/po-export-layout";
+import { canViewAllPos } from "@/lib/permissions";
// ── Company fallback constants (used when no company is linked to a PO) ──────
@@ -66,8 +67,9 @@ export async function GET(request: NextRequest, { params }: Props) {
});
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
- const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(session.user.role);
- if (!canViewAll && po.submitterId !== session.user.id) {
+ // view_all_pos holders, or submitters when the view-all feature flag is on, may export
+ // any PO; everyone else only their own.
+ if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
diff --git a/App/app/api/reports/export/route.ts b/App/app/api/reports/export/route.ts
index 19bdf4b..ee82eae 100644
--- a/App/app/api/reports/export/route.ts
+++ b/App/app/api/reports/export/route.ts
@@ -1,6 +1,6 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
-import { hasPermission } from "@/lib/permissions";
+import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
import type { POStatus } from "@prisma/client";
@@ -16,7 +16,10 @@ export async function GET(request: NextRequest) {
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- if (!hasPermission(session.user.role, "export_reports")) {
+ if (
+ !hasPermission(session.user.role, "export_reports") &&
+ !submitterCanViewAll(session.user.role)
+ ) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx
index 1499c0e..0bc5965 100644
--- a/App/components/layout/sidebar.tsx
+++ b/App/components/layout/sidebar.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
-import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
+import { INVENTORY_ENABLED, SUBMITTER_VIEW_ALL_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
@@ -46,6 +46,13 @@ interface NavItem {
roles?: Role[];
}
+// History is open to all-PO viewers; when the submitter-view-all flag is on, submitters
+// (TECHNICAL / MANNING) get read+export access to it too.
+const HISTORY_ROLES: Role[] = [
+ "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN",
+ ...(SUBMITTER_VIEW_ALL_ENABLED ? (["TECHNICAL", "MANNING"] as Role[]) : []),
+];
+
const NAV_ITEMS: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
@@ -54,7 +61,7 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
- { href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] },
+ { href: "/history", label: "History", icon: History, roles: HISTORY_ROLES },
{ href: "/profile", label: "My Profile", icon: UserCircle },
];
diff --git a/App/components/po/po-detail.tsx b/App/components/po/po-detail.tsx
index 8af02fc..1b6ea8c 100644
--- a/App/components/po/po-detail.tsx
+++ b/App/components/po/po-detail.tsx
@@ -25,6 +25,7 @@ type PoWithRelations = {
paymentRef: string | null;
paymentDate?: Date | null;
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
+ suggestedAdvancePayment?: import("@prisma/client").Prisma.Decimal | null;
piQuotationNo?: string | null;
piQuotationDate?: Date | null;
requisitionNo?: string | null;
@@ -290,6 +291,21 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
)}
+ {/* Manager's advance-payment decision (issue #92) — a partial advance set
+ at approval. Shown to Accounts/Manager from approval through payment. */}
+ {po.suggestedAdvancePayment != null &&
+ Number(po.suggestedAdvancePayment) < Number(po.totalAmount) &&
+ ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID"].includes(po.status) && (
+
+
Advance payment requested
+
+ Pay {formatCurrency(Number(po.suggestedAdvancePayment), po.currency)} first (of{" "}
+ {formatCurrency(Number(po.totalAmount), po.currency)}). The balance follows the usual
+ part-payment flow.
+
+
+ )}
+
{/* Submitter changes banner — shown to managers when PO is resubmitted after edits */}
{resubmitSnapshot &&
po.status === "MGR_REVIEW" &&
diff --git a/App/lib/feature-flags.ts b/App/lib/feature-flags.ts
index 3923a3c..be88910 100644
--- a/App/lib/feature-flags.ts
+++ b/App/lib/feature-flags.ts
@@ -5,6 +5,12 @@
* NEXT_PUBLIC_INVENTORY_ENABLED=false → hides inventory tracking (site qty/consumption)
* Vendor list, product catalogue, and cart remain available for PO creation regardless.
*
+ * NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true → lets submitters (TECHNICAL / MANNING)
+ * read every PO (not just their own), open the History page, and use the export buttons.
+ * Opt-in (off unless explicitly "true") because it widens read access. Submitters stay
+ * read-only — it grants no approval, payment, or edit rights. See lib/permissions.ts
+ * (canViewAllPos / submitterCanViewAll).
+ *
* NEXT_PUBLIC_CREWING_ENABLED=true → exposes the Crewing module (crew/ranks/requisitions
* etc.). Opt-in (off unless explicitly "true") because the feature is built incrementally;
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
@@ -14,5 +20,8 @@
export const INVENTORY_ENABLED =
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";
+export const SUBMITTER_VIEW_ALL_ENABLED =
+ process.env.NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED === "true";
+
export const CREWING_ENABLED =
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";
diff --git a/App/lib/permissions.ts b/App/lib/permissions.ts
index 648942e..37e2718 100644
--- a/App/lib/permissions.ts
+++ b/App/lib/permissions.ts
@@ -1,4 +1,5 @@
import type { Role } from "@prisma/client";
+import { SUBMITTER_VIEW_ALL_ENABLED } from "./feature-flags";
export type Permission =
| "create_po"
@@ -241,3 +242,31 @@ export function requirePermission(role: Role, permission: Permission): void {
export function getPermissions(role: Role): Permission[] {
return ROLE_PERMISSIONS[role] ?? [];
}
+
+// ── Submitter roles & feature-flagged view-all ────────────────────────────────
+// Submitters raise and track their own POs. The two "submitter" roles below hold
+// `view_own_pos` but not `view_all_pos`.
+
+export const SUBMITTER_ROLES: Role[] = ["TECHNICAL", "MANNING"];
+
+export function isSubmitterRole(role: Role): boolean {
+ return SUBMITTER_ROLES.includes(role);
+}
+
+/**
+ * Feature-flagged: when NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true, submitters may
+ * read & export every PO (not just their own) and reach the History page. This is a
+ * read-only widening — it does not grant approval, payment, or edit rights.
+ */
+export function submitterCanViewAll(role: Role): boolean {
+ return SUBMITTER_VIEW_ALL_ENABLED && isSubmitterRole(role);
+}
+
+/**
+ * Whether a role may view/export any PO, not just the ones they submitted.
+ * True for `view_all_pos` holders (ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN) and,
+ * when the feature flag is on, for submitters too.
+ */
+export function canViewAllPos(role: Role): boolean {
+ return hasPermission(role, "view_all_pos") || submitterCanViewAll(role);
+}
diff --git a/App/lib/validations/po.ts b/App/lib/validations/po.ts
index bd57712..7be1042 100644
--- a/App/lib/validations/po.ts
+++ b/App/lib/validations/po.ts
@@ -53,6 +53,13 @@ export const createPoSchema = z.object({
export const approvePoSchema = z.object({
note: z.string().optional(),
+ // Absolute advance amount the Manager wants paid first (issue #92). The UI
+ // slider works in whole percent of totalAmount; the resolved amount is what we
+ // persist. Validated against the PO total in the action. Omitted ⇒ full payment.
+ suggestedAdvancePayment: z.coerce
+ .number()
+ .nonnegative("Advance payment cannot be negative")
+ .optional(),
});
export const rejectPoSchema = z.object({
diff --git a/App/prisma/migrations/20260624120000_po_suggested_advance_payment/migration.sql b/App/prisma/migrations/20260624120000_po_suggested_advance_payment/migration.sql
new file mode 100644
index 0000000..4675582
--- /dev/null
+++ b/App/prisma/migrations/20260624120000_po_suggested_advance_payment/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "PurchaseOrder" ADD COLUMN "suggestedAdvancePayment" DECIMAL(12,2);
diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma
index c857a3a..5d71570 100644
--- a/App/prisma/schema.prisma
+++ b/App/prisma/schema.prisma
@@ -530,6 +530,17 @@ model PurchaseOrder {
paymentRef String?
paymentDate DateTime?
paidAmount Decimal? @db.Decimal(12, 2)
+ // Advance the approving Manager wants paid first (absolute amount, not %).
+ // The approval slider (0–100% of totalAmount) is convenience only — the
+ // resolved amount is stored here. Null on legacy/pre-feature POs ⇒ no explicit
+ // advance, so Accounts defaults to the full remaining balance. Set once at
+ // approval and not edited afterwards (issue #92).
+ //
+ // NOTE (issue #91): this IS the "exact sum due for payment" for an ADVANCE/PART
+ // request. When the structured payment-request lane (payment-term enum +
+ // separate approval) is built, reuse this column for the requested amount
+ // rather than adding a parallel "exact sum" field.
+ suggestedAdvancePayment Decimal? @db.Decimal(12, 2)
piQuotationNo String?
piQuotationDate DateTime?
requisitionNo String?
diff --git a/App/tests/integration/approval-actions.test.ts b/App/tests/integration/approval-actions.test.ts
index fd040d8..5006743 100644
--- a/App/tests/integration/approval-actions.test.ts
+++ b/App/tests/integration/approval-actions.test.ts
@@ -119,6 +119,46 @@ describe("M-02 — approve PO", () => {
});
});
+// ── #92: Advance payment decided at approval ─────────────────────────────────
+
+describe("issue #92 — advance payment on approval", () => {
+ it("persists the manager's advance amount and records it on the audit row", async () => {
+ const poId = await createSubmittedPo(`${PREFIX}Advance`);
+ vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(managerId, "MANAGER"));
+ const before = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
+ const half = Math.round(Number(before.totalAmount) / 2);
+
+ const result = await approvePo({ poId, suggestedAdvancePayment: half });
+ expect(result).toEqual({ ok: true });
+
+ const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
+ expect(po.status).toBe("MGR_APPROVED");
+ expect(Number(po.suggestedAdvancePayment)).toBe(half);
+
+ const action = await db.pOAction.findFirst({ where: { poId, actionType: "APPROVED" } });
+ expect((action?.metadata as { suggestedAdvancePayment?: number } | null)?.suggestedAdvancePayment).toBe(half);
+ });
+
+ it("defaults to null (full payment) when no advance is provided", async () => {
+ const poId = await createSubmittedPo(`${PREFIX}AdvanceNone`);
+ vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(managerId, "MANAGER"));
+ await approvePo({ poId });
+ const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
+ expect(po.suggestedAdvancePayment).toBeNull();
+ });
+
+ it("clamps an advance above the PO total down to the total", async () => {
+ const poId = await createSubmittedPo(`${PREFIX}AdvanceClamp`);
+ vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(managerId, "MANAGER"));
+ const before = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
+ const total = Number(before.totalAmount);
+
+ await approvePo({ poId, suggestedAdvancePayment: total + 5000 });
+ const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
+ expect(Number(po.suggestedAdvancePayment)).toBe(total);
+ });
+});
+
// ── M-03: Reject ──────────────────────────────────────────────────────────────
describe("M-03 — reject PO", () => {
diff --git a/App/tests/integration/candidates.test.ts b/App/tests/integration/candidates.test.ts
index 915ad8b..969ca33 100644
--- a/App/tests/integration/candidates.test.ts
+++ b/App/tests/integration/candidates.test.ts
@@ -33,6 +33,21 @@ const SS_EMAIL = "sitestaff@itcand.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role));
+// Ex-hand is an office/admin designation (set on the admin crew record, not the
+// candidate form) — seed such rows directly for the recognition tests.
+const seedExHand = (data: { name: string; email?: string; experienceMonths?: number }) =>
+ db.crewMember.create({
+ data: {
+ name: data.name,
+ type: "EX_HAND",
+ status: "EX_HAND",
+ source: "CAREERS",
+ email: data.email ?? null,
+ experienceMonths: data.experienceMonths ?? 0,
+ actions: { create: { actionType: "CANDIDATE_ADDED", actorId: managerId } },
+ },
+ });
+
beforeAll(async () => {
managerId = (await getSeedUser("manager@pelagia.local")).id;
const ss = await db.user.upsert({
@@ -71,12 +86,14 @@ describe("addCandidate", () => {
expect(c.actions[0].actorId).toBe(managerId);
});
- it("an EX_HAND source yields type EX_HAND and status EX_HAND", async () => {
+ it("candidate intake always creates a NEW candidate — ex-hand is admin-only", async () => {
as(managerId, "MANAGER");
- await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
+ // Even if an ex-hand hint is smuggled into the form data, intake stays
+ // NEW/CANDIDATE; ex-hand is set only on the admin crew record.
+ await addCandidate(fd({ name: "Returning Ravi", source: "CAREERS", isExHand: "true" }));
const c = await db.crewMember.findFirstOrThrow();
- expect(c.type).toBe("EX_HAND");
- expect(c.status).toBe("EX_HAND");
+ expect(c.type).toBe("NEW");
+ expect(c.status).toBe("CANDIDATE");
});
it("requires a name", async () => {
@@ -98,8 +115,7 @@ describe("addCandidate", () => {
describe("ex-hand recognition + ordering (B3)", () => {
it("recognizes a returning hand by email and reuses the same row (AC1)", async () => {
as(managerId, "MANAGER");
- await addCandidate(fd({ name: "Ravi Old", source: "EX_HAND", email: "ravi@ex.com", experienceMonths: "120" }));
- const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
+ const exhand = await seedExHand({ name: "Ravi Old", email: "ravi@ex.com", experienceMonths: 120 });
// Re-applies as a fresh careers candidate with the same email → recognized.
const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId }));
@@ -115,16 +131,15 @@ describe("ex-hand recognition + ordering (B3)", () => {
it("recognizes a returning hand by exact name when no email is given (AC1)", async () => {
as(managerId, "MANAGER");
- await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
+ const exhand = await seedExHand({ name: "Returning Ravi" });
const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive
- const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
expect("ok" in res && res.id).toBe(exhand.id);
expect(await db.crewMember.count()).toBe(1);
});
it("does not match a different person → creates a new candidate", async () => {
as(managerId, "MANAGER");
- await addCandidate(fd({ name: "Ex One", source: "EX_HAND", email: "one@ex.com" }));
+ await seedExHand({ name: "Ex One", email: "one@ex.com" });
await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" }));
expect(await db.crewMember.count()).toBe(2);
});
@@ -132,7 +147,7 @@ describe("ex-hand recognition + ordering (B3)", () => {
it("lists ex-hands above new candidates by default (AC2)", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "New First", source: "CAREERS" }));
- await addCandidate(fd({ name: "Ex Second", source: "EX_HAND" }));
+ await seedExHand({ name: "Ex Second" });
const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } };
expect(el.props.candidates[0].status).toBe("EX_HAND");
expect(el.props.candidates[0].name).toBe("Ex Second");
diff --git a/App/tests/unit/permissions.test.ts b/App/tests/unit/permissions.test.ts
index ef514bd..2afdf4d 100644
--- a/App/tests/unit/permissions.test.ts
+++ b/App/tests/unit/permissions.test.ts
@@ -1,5 +1,11 @@
-import { describe, it, expect } from "vitest";
-import { hasPermission, requirePermission } from "@/lib/permissions";
+import { describe, it, expect, vi, afterEach } from "vitest";
+import {
+ hasPermission,
+ requirePermission,
+ isSubmitterRole,
+ submitterCanViewAll,
+ canViewAllPos,
+} from "@/lib/permissions";
describe("Permissions", () => {
describe("hasPermission", () => {
@@ -99,6 +105,64 @@ describe("Permissions", () => {
});
});
+ // ── Submitter view-all (feature-flagged) ──────────────────────────────────
+ describe("isSubmitterRole", () => {
+ it("is true for the two submitter roles", () => {
+ expect(isSubmitterRole("TECHNICAL")).toBe(true);
+ expect(isSubmitterRole("MANNING")).toBe(true);
+ });
+
+ it("is false for every other role", () => {
+ for (const role of ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] as const) {
+ expect(isSubmitterRole(role)).toBe(false);
+ }
+ });
+ });
+
+ describe("canViewAllPos / submitterCanViewAll — flag OFF (default)", () => {
+ it("submitters cannot view all POs", () => {
+ expect(canViewAllPos("TECHNICAL")).toBe(false);
+ expect(canViewAllPos("MANNING")).toBe(false);
+ expect(submitterCanViewAll("TECHNICAL")).toBe(false);
+ });
+
+ it("view_all_pos holders can still view all POs", () => {
+ for (const role of ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] as const) {
+ expect(canViewAllPos(role)).toBe(true);
+ }
+ });
+ });
+
+ describe("canViewAllPos / submitterCanViewAll — flag ON", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ });
+
+ it("submitters gain view-all when NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true", async () => {
+ vi.resetModules();
+ vi.stubEnv("NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED", "true");
+ const perms = await import("@/lib/permissions");
+
+ expect(perms.submitterCanViewAll("TECHNICAL")).toBe(true);
+ expect(perms.submitterCanViewAll("MANNING")).toBe(true);
+ expect(perms.canViewAllPos("TECHNICAL")).toBe(true);
+ expect(perms.canViewAllPos("MANNING")).toBe(true);
+ });
+
+ it("does not widen non-submitter roles, and is read-only (no approve/edit)", async () => {
+ vi.resetModules();
+ vi.stubEnv("NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED", "true");
+ const perms = await import("@/lib/permissions");
+
+ expect(perms.submitterCanViewAll("MANAGER")).toBe(false);
+ expect(perms.canViewAllPos("ACCOUNTS")).toBe(true); // unchanged
+ // The flag grants read access only — no approval or edit rights.
+ expect(perms.hasPermission("TECHNICAL", "approve_po")).toBe(false);
+ expect(perms.hasPermission("TECHNICAL", "view_all_pos")).toBe(false);
+ });
+ });
+
describe("requirePermission", () => {
it("does not throw when permission is granted", () => {
expect(() => requirePermission("MANAGER", "approve_po")).not.toThrow();