feat(payments): partial/advance payment support

Allow accounts to record partial/advance payments against a PO before
full delivery. A new PARTIALLY_PAID status tracks in-progress payment;
paidAmount accumulates across multiple markPaid calls. PO only closes
when both paidAmount >= totalAmount AND all line items are delivered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-27 04:17:19 +05:30
parent 7169d52885
commit cf9ff40262
14 changed files with 285 additions and 74 deletions

View file

@ -31,7 +31,7 @@ export default async function DashboardPage() {
async function SubmitterDashboard({ userId }: { userId: string }) { async function SubmitterDashboard({ userId }: { userId: string }) {
const [openCount, pendingCount, closedCount, recentPos] = await Promise.all([ const [openCount, pendingCount, closedCount, recentPos] = await Promise.all([
db.purchaseOrder.count({ db.purchaseOrder.count({
where: { submitterId: userId, status: { in: ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED"] } }, where: { submitterId: userId, status: { in: ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED", "PARTIALLY_PAID"] } },
}), }),
db.purchaseOrder.count({ db.purchaseOrder.count({
where: { submitterId: userId, status: "MGR_REVIEW" }, where: { submitterId: userId, status: "MGR_REVIEW" },
@ -110,7 +110,7 @@ async function ManagerDashboard() {
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1); const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1);
const approvedStatuses = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED"] as const; const approvedStatuses = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "CLOSED"] as const;
const [awaitingCount, approvedThisMonth, totalSpendResult, recentApproved, vesselBreakdown, monthlyPos] = await Promise.all([ const [awaitingCount, approvedThisMonth, totalSpendResult, recentApproved, vesselBreakdown, monthlyPos] = await Promise.all([
db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }), db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }),
@ -120,7 +120,7 @@ async function ManagerDashboard() {
where: { status: { in: [...approvedStatuses] } }, where: { status: { in: [...approvedStatuses] } },
}), }),
db.purchaseOrder.findMany({ db.purchaseOrder.findMany({
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED"] } }, where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED"] } },
orderBy: { approvedAt: "desc" }, orderBy: { approvedAt: "desc" },
take: 8, take: 8,
select: { id: true, poNumber: true, title: true, status: true, totalAmount: true, approvedAt: true, vessel: { select: { name: true } } }, select: { id: true, poNumber: true, title: true, status: true, totalAmount: true, approvedAt: true, vessel: { select: { name: true } } },

View file

@ -25,7 +25,7 @@ export default async function MyOrdersPage() {
}); });
const open = pos.filter((p) => const open = pos.filter((p) =>
["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED"].includes(p.status) ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED", "PARTIALLY_PAID"].includes(p.status)
); );
const closed = pos.filter((p) => const closed = pos.filter((p) =>
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED", "REJECTED"].includes(p.status) ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED", "REJECTED"].includes(p.status)

View file

@ -127,18 +127,20 @@ export async function processPayment({ poId }: { poId: string }): Promise<Action
return { ok: true }; return { ok: true };
} }
// Step 2: Accounts confirms payment sent — SENT_FOR_PAYMENT → PAID_DELIVERED // Step 2: Accounts confirms payment sent — SENT_FOR_PAYMENT / PARTIALLY_PAID → PAID_DELIVERED or PARTIALLY_PAID
export async function markPaid({ export async function markPaid({
poId, poId,
paymentRef, paymentRef,
paymentAmount,
}: { }: {
poId: string; poId: string;
paymentRef: string; paymentRef: string;
paymentAmount?: number; // if omitted, treat as full remaining amount
}): Promise<ActionResult> { }): Promise<ActionResult> {
const session = await auth(); const session = await auth();
if (!session?.user) return { error: "Unauthorized" }; if (!session?.user) return { error: "Unauthorized" };
const parsed = processPaymentSchema.safeParse({ paymentRef }); const parsed = processPaymentSchema.safeParse({ paymentRef, paymentAmount });
if (!parsed.success) return { error: "Payment reference is required." }; if (!parsed.success) return { error: "Payment reference is required." };
const po = await db.purchaseOrder.findUnique({ const po = await db.purchaseOrder.findUnique({
@ -146,16 +148,28 @@ export async function markPaid({
include: { submitter: true, lineItems: true }, include: { submitter: true, lineItems: true },
}); });
if (!po) return { error: "PO not found" }; if (!po) return { error: "PO not found" };
if (!canPerformAction(po.status, "mark_paid", session.user.role)) {
const canFullPay = canPerformAction(po.status, "mark_paid", session.user.role);
const canPartialPay = canPerformAction(po.status, "mark_partial_payment", session.user.role);
if (!canFullPay && !canPartialPay) {
return { error: "You cannot confirm payment for this PO." }; return { error: "You cannot confirm payment for this PO." };
} }
const alreadyPaid = Number(po.paidAmount ?? 0);
const total = Number(po.totalAmount);
const remaining = total - alreadyPaid;
const paying = parsed.data.paymentAmount ?? remaining;
const newPaidAmount = alreadyPaid + paying;
const isFullyPaid = newPaidAmount >= total;
if (isFullyPaid) {
await db.purchaseOrder.update({ await db.purchaseOrder.update({
where: { id: poId }, where: { id: poId },
data: { data: {
status: "PAID_DELIVERED", status: "PAID_DELIVERED",
paidAt: new Date(), paidAt: new Date(),
paymentRef: parsed.data.paymentRef, paymentRef: parsed.data.paymentRef,
paidAmount: newPaidAmount,
actions: { actions: {
create: { create: {
actionType: "PAYMENT_SENT", actionType: "PAYMENT_SENT",
@ -178,6 +192,27 @@ export async function markPaid({
} }
await notify({ event: "PAYMENT_SENT", po, recipients: [po.submitter] }); await notify({ event: "PAYMENT_SENT", po, recipients: [po.submitter] });
} else {
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "PARTIALLY_PAID",
paymentRef: parsed.data.paymentRef,
paidAmount: newPaidAmount,
actions: {
create: {
actionType: "PARTIAL_PAYMENT_CONFIRMED",
actorId: session.user.id,
metadata: {
paymentRef: parsed.data.paymentRef,
paymentAmount: paying,
totalPaid: newPaidAmount,
},
},
},
},
});
}
revalidatePath("/payments"); revalidatePath("/payments");
revalidatePath(`/po/${poId}`); revalidatePath(`/po/${poId}`);

View file

@ -3,7 +3,7 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils"; import { formatCurrency, formatDate, PO_STATUS_LABELS } from "@/lib/utils";
import { PaymentActions } from "./payment-actions"; import { PaymentActions } from "./payment-actions";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -16,7 +16,7 @@ export default async function PaymentsPage() {
if (!hasPermission(session.user.role, "process_payment")) redirect("/dashboard"); if (!hasPermission(session.user.role, "process_payment")) redirect("/dashboard");
const queue = await db.purchaseOrder.findMany({ const queue = await db.purchaseOrder.findMany({
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT"] } }, where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PARTIALLY_CLOSED"] } },
include: { submitter: true, vessel: true, account: true, vendor: true }, include: { submitter: true, vessel: true, account: true, vendor: true },
orderBy: { approvedAt: "asc" }, orderBy: { approvedAt: "asc" },
}); });
@ -75,14 +75,36 @@ export default async function PaymentsPage() {
</div> </div>
</div> </div>
<div className="mt-4 border-t border-neutral-100 pt-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> <div className="mt-4 border-t border-neutral-100 pt-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<span className={`text-xs font-medium rounded-full px-2.5 py-0.5 ${ <div className="flex flex-col gap-1">
<span className={`text-xs font-medium rounded-full px-2.5 py-0.5 self-start ${
po.status === "SENT_FOR_PAYMENT" po.status === "SENT_FOR_PAYMENT"
? "bg-primary-50 text-primary-700" ? "bg-primary-50 text-primary-700"
: po.status === "PARTIALLY_PAID"
? "bg-warning-50 text-warning-700"
: po.status === "PARTIALLY_CLOSED"
? "bg-warning-50 text-warning-700"
: "bg-warning-50 text-warning-700" : "bg-warning-50 text-warning-700"
}`}> }`}>
{po.status === "SENT_FOR_PAYMENT" ? "Processing — awaiting confirmation" : "Ready for payment"} {po.status === "SENT_FOR_PAYMENT"
? "Processing — awaiting confirmation"
: po.status === "PARTIALLY_PAID"
? "Partially paid — additional payment needed"
: po.status === "PARTIALLY_CLOSED"
? "Partially received — awaiting more payments"
: "Ready for payment"}
</span> </span>
<PaymentActions poId={po.id} poStatus={po.status} /> {(po.status === "PARTIALLY_PAID" || po.status === "PARTIALLY_CLOSED") && po.paidAmount != null && (
<span className="text-xs text-neutral-500">
Paid {formatCurrency(Number(po.paidAmount), po.currency)} of {formatCurrency(Number(po.totalAmount), po.currency)}
</span>
)}
</div>
<PaymentActions
poId={po.id}
poStatus={po.status}
totalAmount={Number(po.totalAmount)}
paidAmount={po.paidAmount != null ? Number(po.paidAmount) : 0}
/>
</div> </div>
</div> </div>
))} ))}

View file

@ -5,12 +5,22 @@ import { useRouter } from "next/navigation";
import { processPayment, markPaid } from "./actions"; import { processPayment, markPaid } from "./actions";
import type { POStatus } from "@prisma/client"; import type { POStatus } from "@prisma/client";
export function PaymentActions({ poId, poStatus }: { poId: string; poStatus: POStatus }) { interface Props {
poId: string;
poStatus: POStatus;
totalAmount?: number;
paidAmount?: number;
}
export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0 }: Props) {
const router = useRouter(); const router = useRouter();
const [ref, setRef] = useState(""); const [ref, setRef] = useState("");
const [amount, setAmount] = useState<string>("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const remaining = totalAmount - paidAmount;
async function handleProcessPayment() { async function handleProcessPayment() {
setPending(true); setPending(true);
setError(""); setError("");
@ -19,12 +29,24 @@ export function PaymentActions({ poId, poStatus }: { poId: string; poStatus: POS
else { router.refresh(); } else { router.refresh(); }
} }
async function handleMarkPaid(e: React.FormEvent) { async function handleMarkPaid(e: React.FormEvent, forceFullPayment = false) {
e.preventDefault(); e.preventDefault();
if (!ref.trim()) { setError("Payment reference is required."); return; } if (!ref.trim()) { setError("Payment reference is required."); return; }
const paymentAmount = forceFullPayment ? remaining : (parseFloat(amount) || undefined);
if (paymentAmount !== undefined && paymentAmount <= 0) {
setError("Payment amount must be greater than 0.");
return;
}
if (paymentAmount !== undefined && paymentAmount > remaining) {
setError(`Payment amount cannot exceed the remaining balance of ${remaining.toFixed(2)}.`);
return;
}
setPending(true); setPending(true);
setError(""); setError("");
const result = await markPaid({ poId, paymentRef: ref }); const result = await markPaid({ poId, paymentRef: ref, paymentAmount });
if ("error" in result) { setError(result.error); setPending(false); } if ("error" in result) { setError(result.error); setPending(false); }
else { router.refresh(); } else { router.refresh(); }
} }
@ -44,9 +66,21 @@ export function PaymentActions({ poId, poStatus }: { poId: string; poStatus: POS
); );
} }
if (poStatus === "SENT_FOR_PAYMENT") { if (
poStatus === "SENT_FOR_PAYMENT" ||
poStatus === "PARTIALLY_PAID" ||
poStatus === "PARTIALLY_CLOSED"
) {
const parsedAmount = parseFloat(amount);
const isPartialPayment =
!isNaN(parsedAmount) && parsedAmount > 0 && parsedAmount < remaining;
return ( return (
<form onSubmit={handleMarkPaid} className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 w-full"> <form
onSubmit={(e) => handleMarkPaid(e)}
className="flex flex-col gap-2 w-full sm:w-auto"
>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<input <input
type="text" type="text"
placeholder="Payment reference / transaction ID" placeholder="Payment reference / transaction ID"
@ -54,14 +88,37 @@ export function PaymentActions({ poId, poStatus }: { poId: string; poStatus: POS
onChange={(e) => setRef(e.target.value)} onChange={(e) => setRef(e.target.value)}
className="flex-1 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" className="flex-1 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"
/> />
<input
type="number"
placeholder={`Amount (max ${remaining.toFixed(2)})`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
min={0.01}
max={remaining}
step="0.01"
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"
/>
</div>
{error && <span className="text-xs text-danger-700">{error}</span>} {error && <span className="text-xs text-danger-700">{error}</span>}
<div className="flex gap-2 justify-end">
{isPartialPayment && (
<button <button
type="submit" type="submit"
disabled={pending} disabled={pending}
className="w-full sm:w-auto rounded-lg bg-success px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity" className="rounded-lg bg-warning-600 px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
> >
{pending ? "Confirming…" : "Confirm Payment Sent"} {pending ? "Confirming…" : "Confirm Partial Payment"}
</button> </button>
)}
<button
type="button"
disabled={pending}
onClick={(e) => handleMarkPaid(e as unknown as React.FormEvent, true)}
className="rounded-lg bg-success px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
>
{pending ? "Confirming…" : "Confirm Full Payment"}
</button>
</div>
</form> </form>
); );
} }

View file

@ -34,7 +34,9 @@ export async function confirmReceipt({
if (!po) return { error: "PO not found" }; if (!po) return { error: "PO not found" };
const isAllowedStatus = const isAllowedStatus =
po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED"; po.status === "PAID_DELIVERED" ||
po.status === "PARTIALLY_CLOSED" ||
po.status === "PARTIALLY_PAID";
if (!isAllowedStatus) { if (!isAllowedStatus) {
return { error: "You cannot confirm receipt on this PO in its current state." }; return { error: "You cannot confirm receipt on this PO in its current state." };
} }
@ -72,8 +74,23 @@ export async function confirmReceipt({
// Determine if all items are now fully delivered // Determine if all items are now fully delivered
const allDelivered = lineUpdates.every((u) => u.deliveredQuantity >= u.quantity); const allDelivered = lineUpdates.every((u) => u.deliveredQuantity >= u.quantity);
const newStatus = allDelivered ? "CLOSED" : "PARTIALLY_CLOSED";
const isPartial = !allDelivered; // Re-fetch paidAmount for accurate check
const updatedPo = await db.purchaseOrder.findUnique({
where: { id: poId },
select: { paidAmount: true, totalAmount: true },
});
const fullyPaid =
Number(updatedPo?.paidAmount ?? 0) >= Number(updatedPo?.totalAmount ?? 0);
const newStatus: "CLOSED" | "PARTIALLY_CLOSED" | "PARTIALLY_PAID" =
allDelivered && fullyPaid
? "CLOSED"
: !allDelivered && fullyPaid
? "PARTIALLY_CLOSED"
: "PARTIALLY_PAID";
const isPartial = newStatus !== "CLOSED";
// Persist delivery quantities // Persist delivery quantities
await Promise.all( await Promise.all(
@ -90,7 +107,7 @@ export async function confirmReceipt({
where: { id: poId }, where: { id: poId },
data: { data: {
status: newStatus, status: newStatus,
closedAt: allDelivered ? new Date() : undefined, closedAt: newStatus === "CLOSED" ? new Date() : undefined,
receipt: notes receipt: notes
? { create: { storageKey: "", fileName: "no-file", notes } } ? { create: { storageKey: "", fileName: "no-file", notes } }
: undefined, : undefined,
@ -133,7 +150,7 @@ export async function confirmReceipt({
} }
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } }); const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
if (allDelivered) { if (newStatus === "CLOSED") {
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }); const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
await notify({ event: "RECEIPT_CONFIRMED", po, recipients: [...managers, ...accounts] }); await notify({ event: "RECEIPT_CONFIRMED", po, recipients: [...managers, ...accounts] });
} else { } else {

View file

@ -24,6 +24,8 @@ export default async function ReceiptPage({ params }: Props) {
title: true, title: true,
status: true, status: true,
submitterId: true, submitterId: true,
paidAmount: true,
totalAmount: true,
lineItems: { lineItems: {
orderBy: { sortOrder: "asc" }, orderBy: { sortOrder: "asc" },
select: { select: {
@ -38,7 +40,11 @@ export default async function ReceiptPage({ params }: Props) {
}); });
if (!po) notFound(); if (!po) notFound();
if (po.status !== "PAID_DELIVERED" && po.status !== "PARTIALLY_CLOSED") { if (
po.status !== "PAID_DELIVERED" &&
po.status !== "PARTIALLY_CLOSED" &&
po.status !== "PARTIALLY_PAID"
) {
redirect(`/po/${id}`); redirect(`/po/${id}`);
} }
if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") { if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") {
@ -54,6 +60,7 @@ export default async function ReceiptPage({ params }: Props) {
})); }));
const isPartiallyReceived = po.status === "PARTIALLY_CLOSED"; const isPartiallyReceived = po.status === "PARTIALLY_CLOSED";
const isPartiallyPaid = po.status === "PARTIALLY_PAID";
return ( return (
<div className="max-w-2xl"> <div className="max-w-2xl">
@ -70,7 +77,14 @@ export default async function ReceiptPage({ params }: Props) {
</p> </p>
)} )}
</div> </div>
<ReceiptForm poId={po.id} lineItems={lineItems} isPartiallyReceived={isPartiallyReceived} /> <ReceiptForm
poId={po.id}
lineItems={lineItems}
isPartiallyReceived={isPartiallyReceived}
isPartiallyPaid={isPartiallyPaid}
paidAmount={po.paidAmount != null ? Number(po.paidAmount) : undefined}
totalAmount={Number(po.totalAmount)}
/>
</div> </div>
); );
} }

View file

@ -18,9 +18,12 @@ interface Props {
poId: string; poId: string;
lineItems: LineItem[]; lineItems: LineItem[];
isPartiallyReceived: boolean; isPartiallyReceived: boolean;
isPartiallyPaid?: boolean;
paidAmount?: number;
totalAmount?: number;
} }
export function ReceiptForm({ poId, lineItems, isPartiallyReceived }: Props) { export function ReceiptForm({ poId, lineItems, isPartiallyReceived, isPartiallyPaid, paidAmount, totalAmount }: Props) {
const router = useRouter(); const router = useRouter();
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
@ -81,6 +84,19 @@ export function ReceiptForm({ poId, lineItems, isPartiallyReceived }: Props) {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Partial payment warning banner */}
{isPartiallyPaid && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3">
<p className="text-sm font-medium text-amber-800 mb-0.5">Payment not yet complete</p>
<p className="text-sm text-amber-700">
You can confirm received items now, but the PO will not be closed until full payment is made.
{paidAmount != null && totalAmount != null && (
<> Currently {paidAmount.toLocaleString("en-IN", { style: "currency", currency: "INR" })} of {totalAmount.toLocaleString("en-IN", { style: "currency", currency: "INR" })} has been paid.</>
)}
</p>
</div>
)}
{/* Line items delivery tracker */} {/* Line items delivery tracker */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> <div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-100 flex items-center justify-between"> <div className="px-4 py-3 border-b border-neutral-100 flex items-center justify-between">

View file

@ -20,6 +20,7 @@ type PoWithRelations = {
dateRequired: Date | null; dateRequired: Date | null;
managerNote: string | null; managerNote: string | null;
paymentRef: string | null; paymentRef: string | null;
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
piQuotationNo?: string | null; piQuotationNo?: string | null;
piQuotationDate?: Date | null; piQuotationDate?: Date | null;
requisitionNo?: string | null; requisitionNo?: string | null;
@ -82,6 +83,7 @@ const ACTION_LABELS: Record<string, string> = {
VENDOR_ID_REQUESTED: "Vendor ID requested", VENDOR_ID_REQUESTED: "Vendor ID requested",
VENDOR_ID_PROVIDED: "Vendor ID provided", VENDOR_ID_PROVIDED: "Vendor ID provided",
PAYMENT_SENT: "Payment confirmed", PAYMENT_SENT: "Payment confirmed",
PARTIAL_PAYMENT_CONFIRMED: "Partial payment confirmed",
RECEIPT_CONFIRMED: "Receipt confirmed", RECEIPT_CONFIRMED: "Receipt confirmed",
PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed", PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed",
CLOSED: "Closed", CLOSED: "Closed",
@ -142,7 +144,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
); );
const canConfirmReceipt = const canConfirmReceipt =
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED") && (po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") &&
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") && (po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
!readOnly; !readOnly;
@ -184,7 +186,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
<DiscardDraftButton poId={po.id} /> <DiscardDraftButton poId={po.id} />
)} )}
{/* Export buttons — only available once the PO has been approved by a manager */} {/* Export buttons — only available once the PO has been approved by a manager */}
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (<> {["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (<>
<a <a
href={`/api/po/${po.id}/export?format=pdf`} href={`/api/po/${po.id}/export?format=pdf`}
target="_blank" target="_blank"
@ -398,25 +400,31 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
{/* Confirm receipt CTA */} {/* Confirm receipt CTA */}
{canConfirmReceipt && ( {canConfirmReceipt && (
<div className={`rounded-lg border p-5 flex items-center justify-between ${ <div className={`rounded-lg border p-5 flex items-center justify-between flex-wrap gap-3 ${
po.status === "PARTIALLY_CLOSED" po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID"
? "border-warning-100 bg-warning-50" ? "border-warning-100 bg-warning-50"
: "border-success-100 bg-success-50" : "border-success-100 bg-success-50"
}`}> }`}>
<div> <div>
<p className={`font-medium ${po.status === "PARTIALLY_CLOSED" ? "text-warning-700" : "text-success-700"}`}> <p className={`font-medium ${po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID" ? "text-warning-700" : "text-success-700"}`}>
{po.status === "PARTIALLY_CLOSED" ? "Partially received" : "Payment confirmed"} {po.status === "PARTIALLY_CLOSED"
? "Partially received"
: po.status === "PARTIALLY_PAID"
? "Advance payment received"
: "Payment confirmed"}
</p> </p>
<p className={`text-sm mt-0.5 ${po.status === "PARTIALLY_CLOSED" ? "text-warning-700" : "text-success-700"}`}> <p className={`text-sm mt-0.5 ${po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID" ? "text-warning-700" : "text-success-700"}`}>
{po.status === "PARTIALLY_CLOSED" {po.status === "PARTIALLY_CLOSED"
? "Some items are still outstanding. Confirm remaining deliveries." ? "Some items are still outstanding. Confirm remaining deliveries."
: po.status === "PARTIALLY_PAID"
? `Advance payment received (${formatCurrency(Number(po.paidAmount ?? 0), po.currency)} of ${formatCurrency(Number(po.totalAmount), po.currency)}). Items can be received now — PO closes when fully paid and delivered.`
: "Please confirm that you have received all items."} : "Please confirm that you have received all items."}
</p> </p>
</div> </div>
<Link <Link
href={`/po/${po.id}/receipt`} href={`/po/${po.id}/receipt`}
className={`rounded-lg px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 ${ className={`rounded-lg px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 ${
po.status === "PARTIALLY_CLOSED" ? "bg-warning-600" : "bg-success" po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID" ? "bg-warning-600" : "bg-success"
}`} }`}
> >
{po.status === "PARTIALLY_CLOSED" ? "Confirm Remaining" : "Confirm Receipt"} {po.status === "PARTIALLY_CLOSED" ? "Confirm Remaining" : "Confirm Receipt"}

View file

@ -10,6 +10,7 @@ export type POAction =
| "provide_vendor_id" | "provide_vendor_id"
| "process_payment" | "process_payment"
| "mark_paid" | "mark_paid"
| "mark_partial_payment"
| "confirm_receipt" | "confirm_receipt"
| "confirm_partial_receipt"; | "confirm_partial_receipt";
@ -103,6 +104,38 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
requiresNote: false, requiresNote: false,
sideEffects: ["EMAIL_SUBMITTER", "EMAIL_MANAGER"], sideEffects: ["EMAIL_SUBMITTER", "EMAIL_MANAGER"],
}, },
mark_partial_payment: {
to: "PARTIALLY_PAID",
allowedRoles: ["ACCOUNTS", "SUPERUSER"],
requiresNote: false,
sideEffects: [],
},
},
PARTIALLY_PAID: {
mark_paid: {
to: "PAID_DELIVERED",
allowedRoles: ["ACCOUNTS", "SUPERUSER"],
requiresNote: false,
sideEffects: [],
},
mark_partial_payment: {
to: "PARTIALLY_PAID",
allowedRoles: ["ACCOUNTS", "SUPERUSER"],
requiresNote: false,
sideEffects: [],
},
confirm_receipt: {
to: "CLOSED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false,
sideEffects: [],
},
confirm_partial_receipt: {
to: "PARTIALLY_PAID",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false,
sideEffects: [],
},
}, },
PAID_DELIVERED: { PAID_DELIVERED: {
confirm_receipt: { confirm_receipt: {

View file

@ -47,6 +47,7 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
REJECTED: "Rejected", REJECTED: "Rejected",
MGR_APPROVED: "Approved", MGR_APPROVED: "Approved",
SENT_FOR_PAYMENT: "Sent for Payment", SENT_FOR_PAYMENT: "Sent for Payment",
PARTIALLY_PAID: "Partially Paid",
PAID_DELIVERED: "Paid", PAID_DELIVERED: "Paid",
PARTIALLY_CLOSED: "Partially Received", PARTIALLY_CLOSED: "Partially Received",
CLOSED: "Closed", CLOSED: "Closed",
@ -69,6 +70,7 @@ export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
REJECTED: "danger", REJECTED: "danger",
MGR_APPROVED: "success", MGR_APPROVED: "success",
SENT_FOR_PAYMENT: "default", SENT_FOR_PAYMENT: "default",
PARTIALLY_PAID: "warning",
PAID_DELIVERED: "success", PAID_DELIVERED: "success",
PARTIALLY_CLOSED: "warning", PARTIALLY_CLOSED: "warning",
CLOSED: "secondary", CLOSED: "secondary",

View file

@ -63,6 +63,7 @@ export const requestEditsSchema = z.object({
export const processPaymentSchema = z.object({ export const processPaymentSchema = z.object({
paymentRef: z.string().min(1, "Payment reference is required"), paymentRef: z.string().min(1, "Payment reference is required"),
paymentAmount: z.number().positive("Payment amount must be greater than 0").optional(),
}); });
export const confirmReceiptSchema = z.object({ export const confirmReceiptSchema = z.object({

View file

@ -0,0 +1,3 @@
ALTER TYPE "POStatus" ADD VALUE 'PARTIALLY_PAID';
ALTER TYPE "ActionType" ADD VALUE 'PARTIAL_PAYMENT_CONFIRMED';
ALTER TABLE "PurchaseOrder" ADD COLUMN "paidAmount" DECIMAL(12,2);

View file

@ -26,6 +26,7 @@ enum POStatus {
REJECTED REJECTED
MGR_APPROVED MGR_APPROVED
SENT_FOR_PAYMENT SENT_FOR_PAYMENT
PARTIALLY_PAID
PAID_DELIVERED PAID_DELIVERED
PARTIALLY_CLOSED PARTIALLY_CLOSED
CLOSED CLOSED
@ -41,6 +42,7 @@ enum ActionType {
VENDOR_ID_REQUESTED VENDOR_ID_REQUESTED
VENDOR_ID_PROVIDED VENDOR_ID_PROVIDED
PAYMENT_SENT PAYMENT_SENT
PARTIAL_PAYMENT_CONFIRMED
RECEIPT_CONFIRMED RECEIPT_CONFIRMED
PARTIAL_RECEIPT_CONFIRMED PARTIAL_RECEIPT_CONFIRMED
CLOSED CLOSED
@ -229,6 +231,7 @@ model PurchaseOrder {
projectCode String? projectCode String?
managerNote String? managerNote String?
paymentRef String? paymentRef String?
paidAmount Decimal? @db.Decimal(12, 2)
piQuotationNo String? piQuotationNo String?
piQuotationDate DateTime? piQuotationDate DateTime?
requisitionNo String? requisitionNo String?