diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 0109aa9..bb0e27e 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -106,6 +106,8 @@ Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25 When Accounts records a payment, a **compulsory payment date** is captured (`PurchaseOrder.paymentDate`) — the input defaults to today and rejects future dates (validated in `processPaymentSchema` and `markPaid`). There is also an editable **`poDate`** field; the exported PO "Date" shows `poDate ?? approvedAt ?? createdAt` (i.e. the approval date once approved, not creation). +**Advance payment (issue #92):** the approving Manager sets how much of the PO is paid first via a 0–100% slider on the approval card (`approval-actions.tsx`, default 100%). The slider is convenience only — the resolved **absolute amount** is stored on `PurchaseOrder.suggestedAdvancePayment` (`Decimal(12,2)`, nullable; null = no explicit advance ⇒ full payment). `approvePo()` clamps it to `[0, totalAmount]` and records it on the `APPROVED` audit row; it is **set once at approval and never edited after**. Accounts sees it on the payment queue + PO detail, and it **prefills the first payment's amount** (`payment-actions.tsx`, only when nothing is paid yet and the advance is a true partial); the balance then runs through the normal `PARTIALLY_PAID` loop. It does **not** appear on the exported PO/invoice. + ### Vendors `Vendor` carries `isVerified`, `gstin`, `pincode` + `latitude`/`longitude` (geocoded for vendor-distance sorting from a Site), and a `VendorContact[]` list. **Submitters can create vendors** (permission `create_vendor`) but they are created **unverified**; a vendor becomes verified when a PO is closed/paid with it, on import, or when a Manager/Accounts/Admin runs `verifyVendor`. Only `manage_vendors` holders may assign a `vendorId` (the formal verified code). diff --git a/App/app/(portal)/approvals/[id]/actions.ts b/App/app/(portal)/approvals/[id]/actions.ts index 6257eff..4c04098 100644 --- a/App/app/(portal)/approvals/[id]/actions.ts +++ b/App/app/(portal)/approvals/[id]/actions.ts @@ -3,6 +3,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { canPerformAction } from "@/lib/po-state-machine"; +import { approvePoSchema } from "@/lib/validations/po"; import { notify } from "@/lib/notifier"; import { revalidatePath } from "next/cache"; @@ -12,14 +13,21 @@ export async function approvePo({ poId, note, withNote = false, + suggestedAdvancePayment, }: { poId: string; note?: string; withNote?: boolean; + // Absolute advance the Manager wants paid first (issue #92). Whole amount, + // resolved from the approval slider client-side. Omitted ⇒ full payment. + suggestedAdvancePayment?: number; }): Promise { const session = await auth(); if (!session?.user) return { error: "Unauthorized" }; + const parsed = approvePoSchema.safeParse({ note, suggestedAdvancePayment }); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + const po = await db.purchaseOrder.findUnique({ where: { id: poId }, include: { submitter: true, lineItems: true }, @@ -35,17 +43,28 @@ export async function approvePo({ return { error: "A vendor must be assigned before approving this PO." }; } + // Resolve the advance: clamp to [0, total]. Undefined ⇒ no explicit advance + // (full payment, current default behaviour). The slider always sends a value, + // but a malformed/over-total amount is clamped rather than rejected. + const total = Number(po.totalAmount); + const advance = + parsed.data.suggestedAdvancePayment === undefined + ? null + : Math.min(Math.max(parsed.data.suggestedAdvancePayment, 0), total); + await db.purchaseOrder.update({ where: { id: poId }, data: { status: "MGR_APPROVED", approvedAt: new Date(), managerNote: note ?? null, + suggestedAdvancePayment: advance, actions: { create: { actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED", note: note ?? null, actorId: session.user.id, + metadata: advance !== null ? { suggestedAdvancePayment: advance } : undefined, }, }, }, diff --git a/App/app/(portal)/approvals/[id]/approval-actions.tsx b/App/app/(portal)/approvals/[id]/approval-actions.tsx index ac83965..88e5026 100644 --- a/App/app/(portal)/approvals/[id]/approval-actions.tsx +++ b/App/app/(portal)/approvals/[id]/approval-actions.tsx @@ -3,21 +3,38 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { approvePo, rejectPo, requestEdits, requestVendorId } from "./actions"; +import { formatCurrency } from "@/lib/utils"; import type { POStatus } from "@prisma/client"; +// Resolve the slider percent (whole number) into an absolute advance amount. +// 100% is the exact total (no rounding loss on paise); partial advances are +// rounded to whole rupees — the slider is convenience, the amount is the record. +function advanceAmount(total: number, percent: number): number { + if (percent >= 100) return total; + if (percent <= 0) return 0; + return Math.round((total * percent) / 100); +} + export function ApprovalActions({ poId, poStatus, + totalAmount = 0, + currency = "INR", }: { poId: string; poStatus: POStatus; + totalAmount?: number; + currency?: string; }) { const router = useRouter(); const [note, setNote] = useState(""); + const [advancePercent, setAdvancePercent] = useState(100); const [activeAction, setActiveAction] = useState(null); const [pending, setPending] = useState(null); const [error, setError] = useState(""); + const advance = advanceAmount(totalAmount, advancePercent); + async function dispatch(action: string, requireNote = false) { if (requireNote && !note.trim()) { setError("A note is required for this action."); @@ -26,8 +43,10 @@ export function ApprovalActions({ setPending(action); setError(""); let result: { ok: true } | { error: string } | undefined; - if (action === "approve") result = await approvePo({ poId, note }); - else if (action === "approve_note") result = await approvePo({ poId, note, withNote: true }); + // Approvals carry the Manager's advance decision (resolved amount, not %). + if (action === "approve") result = await approvePo({ poId, note, suggestedAdvancePayment: advance }); + else if (action === "approve_note") + result = await approvePo({ poId, note, withNote: true, suggestedAdvancePayment: advance }); else if (action === "reject") result = await rejectPo({ poId, note }); else if (action === "request_edits") result = await requestEdits({ poId, note }); else if (action === "request_vendor_id") result = await requestVendorId({ poId }); @@ -45,6 +64,37 @@ export function ApprovalActions({

Decision

+ {/* Advance payment (issue #92) — Manager decides how much Accounts pays + first. 100% = full payment; lower values seed the first part-payment. */} +
+
+ + + {advancePercent}% · {formatCurrency(advance, currency)} + +
+ setAdvancePercent(Number(e.target.value))} + className="w-full accent-primary-600" + /> +

+ {advancePercent >= 100 + ? "Full payment — Accounts will be prompted to pay the whole PO value." + : `Accounts will be prompted to pay ${formatCurrency(advance, currency)} first; the balance of ${formatCurrency( + Math.max(totalAmount - advance, 0), + currency + )} follows the usual part-payment flow.`} +

+
+ {(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && (
diff --git a/App/app/(portal)/payments/payment-actions.tsx b/App/app/(portal)/payments/payment-actions.tsx index 489be81..7c4209c 100644 --- a/App/app/(portal)/payments/payment-actions.tsx +++ b/App/app/(portal)/payments/payment-actions.tsx @@ -10,6 +10,9 @@ interface Props { poStatus: POStatus; totalAmount?: number; paidAmount?: number; + // Manager's advance decision (issue #92) — absolute amount. Prefills the FIRST + // payment's amount field; ignored once any payment has been recorded. + suggestedAdvancePayment?: number | null; } // Today's date as a local yyyy-mm-dd string (for default + max) @@ -19,15 +22,33 @@ function todayLocal(): string { return new Date(d.getTime() - off * 60_000).toISOString().slice(0, 10); } -export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0 }: Props) { +export function PaymentActions({ + poId, + poStatus, + totalAmount = 0, + paidAmount = 0, + suggestedAdvancePayment = null, +}: Props) { const router = useRouter(); + const remaining = totalAmount - paidAmount; + + // Prefill the first payment with the Manager's advance, when it's a genuine + // partial of the (untouched) total. Nothing paid yet ⇒ first payment; a full + // (>= total) advance leaves the field blank so "Confirm Full Payment" is used. + const advancePrefill = + paidAmount === 0 && + suggestedAdvancePayment != null && + suggestedAdvancePayment > 0 && + suggestedAdvancePayment < remaining + ? String(suggestedAdvancePayment) + : ""; + const [ref, setRef] = useState(""); - const [amount, setAmount] = useState(""); + const [amount, setAmount] = useState(advancePrefill); const [paymentDate, setPaymentDate] = useState(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/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/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 8016b48..33a680d 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -512,6 +512,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", () => {