From cf9ff40262f906d78c63b6da7099de6323696062 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 27 May 2026 04:17:19 +0530 Subject: [PATCH] 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 --- App/app/(portal)/dashboard/page.tsx | 6 +- App/app/(portal)/my-orders/page.tsx | 2 +- App/app/(portal)/payments/actions.ts | 85 ++++++++++++----- App/app/(portal)/payments/page.tsx | 42 ++++++-- App/app/(portal)/payments/payment-actions.tsx | 95 +++++++++++++++---- App/app/(portal)/po/[id]/receipt/actions.ts | 27 +++++- App/app/(portal)/po/[id]/receipt/page.tsx | 18 +++- .../(portal)/po/[id]/receipt/receipt-form.tsx | 18 +++- App/components/po/po-detail.tsx | 24 +++-- App/lib/po-state-machine.ts | 33 +++++++ App/lib/utils.ts | 2 + App/lib/validations/po.ts | 1 + .../migration.sql | 3 + App/prisma/schema.prisma | 3 + 14 files changed, 285 insertions(+), 74 deletions(-) create mode 100644 App/prisma/migrations/20260527000000_partial_payment/migration.sql diff --git a/App/app/(portal)/dashboard/page.tsx b/App/app/(portal)/dashboard/page.tsx index 2c4f4ac..8c54ae5 100644 --- a/App/app/(portal)/dashboard/page.tsx +++ b/App/app/(portal)/dashboard/page.tsx @@ -31,7 +31,7 @@ export default async function DashboardPage() { async function SubmitterDashboard({ userId }: { userId: string }) { const [openCount, pendingCount, closedCount, recentPos] = await Promise.all([ 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({ where: { submitterId: userId, status: "MGR_REVIEW" }, @@ -110,7 +110,7 @@ async function ManagerDashboard() { const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 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([ db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }), @@ -120,7 +120,7 @@ async function ManagerDashboard() { where: { status: { in: [...approvedStatuses] } }, }), 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" }, take: 8, select: { id: true, poNumber: true, title: true, status: true, totalAmount: true, approvedAt: true, vessel: { select: { name: true } } }, diff --git a/App/app/(portal)/my-orders/page.tsx b/App/app/(portal)/my-orders/page.tsx index bc827ea..a0acc52 100644 --- a/App/app/(portal)/my-orders/page.tsx +++ b/App/app/(portal)/my-orders/page.tsx @@ -25,7 +25,7 @@ export default async function MyOrdersPage() { }); 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) => ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED", "REJECTED"].includes(p.status) diff --git a/App/app/(portal)/payments/actions.ts b/App/app/(portal)/payments/actions.ts index 5d6ee37..7b3d513 100644 --- a/App/app/(portal)/payments/actions.ts +++ b/App/app/(portal)/payments/actions.ts @@ -127,18 +127,20 @@ export async function processPayment({ poId }: { poId: string }): Promise { const session = await auth(); 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." }; const po = await db.purchaseOrder.findUnique({ @@ -146,39 +148,72 @@ export async function markPaid({ include: { submitter: true, lineItems: true }, }); 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." }; } - await db.purchaseOrder.update({ - where: { id: poId }, - data: { - status: "PAID_DELIVERED", - paidAt: new Date(), - paymentRef: parsed.data.paymentRef, - actions: { - create: { - actionType: "PAYMENT_SENT", - actorId: session.user.id, - metadata: { paymentRef: parsed.data.paymentRef }, + 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({ + where: { id: poId }, + data: { + status: "PAID_DELIVERED", + paidAt: new Date(), + paymentRef: parsed.data.paymentRef, + paidAmount: newPaidAmount, + actions: { + create: { + actionType: "PAYMENT_SENT", + actorId: session.user.id, + metadata: { paymentRef: parsed.data.paymentRef }, + }, }, }, - }, - }); + }); - // Sync product catalog: auto-create new items, upsert per-vendor prices - await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id); + // Sync product catalog: auto-create new items, upsert per-vendor prices + await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id); - // Auto-verify the vendor on first successful payment - if (po.vendorId) { - await db.vendor.update({ - where: { id: po.vendorId }, - data: { isVerified: true }, + // Auto-verify the vendor on first successful payment + if (po.vendorId) { + await db.vendor.update({ + where: { id: po.vendorId }, + data: { isVerified: true }, + }); + } + + 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, + }, + }, + }, + }, }); } - await notify({ event: "PAYMENT_SENT", po, recipients: [po.submitter] }); - revalidatePath("/payments"); revalidatePath(`/po/${poId}`); return { ok: true }; diff --git a/App/app/(portal)/payments/page.tsx b/App/app/(portal)/payments/page.tsx index 95123fb..2664e9e 100644 --- a/App/app/(portal)/payments/page.tsx +++ b/App/app/(portal)/payments/page.tsx @@ -3,7 +3,7 @@ import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { redirect } from "next/navigation"; 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 type { Metadata } from "next"; @@ -16,7 +16,7 @@ export default async function PaymentsPage() { if (!hasPermission(session.user.role, "process_payment")) redirect("/dashboard"); 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 }, orderBy: { approvedAt: "asc" }, }); @@ -75,14 +75,36 @@ export default async function PaymentsPage() {
- - {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"} + + {(po.status === "PARTIALLY_PAID" || po.status === "PARTIALLY_CLOSED") && po.paidAmount != null && ( + + Paid {formatCurrency(Number(po.paidAmount), po.currency)} of {formatCurrency(Number(po.totalAmount), po.currency)} + + )} +
+
))} diff --git a/App/app/(portal)/payments/payment-actions.tsx b/App/app/(portal)/payments/payment-actions.tsx index ce744af..92068d3 100644 --- a/App/app/(portal)/payments/payment-actions.tsx +++ b/App/app/(portal)/payments/payment-actions.tsx @@ -5,12 +5,22 @@ import { useRouter } from "next/navigation"; import { processPayment, markPaid } from "./actions"; 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 [ref, setRef] = useState(""); + const [amount, setAmount] = useState(""); const [pending, setPending] = useState(false); const [error, setError] = useState(""); + const remaining = totalAmount - paidAmount; + async function handleProcessPayment() { setPending(true); setError(""); @@ -19,12 +29,24 @@ export function PaymentActions({ poId, poStatus }: { poId: string; poStatus: POS else { router.refresh(); } } - async function handleMarkPaid(e: React.FormEvent) { + async function handleMarkPaid(e: React.FormEvent, forceFullPayment = false) { e.preventDefault(); 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); 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); } else { router.refresh(); } } @@ -44,24 +66,59 @@ 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 ( -
- 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" - /> + handleMarkPaid(e)} + className="flex flex-col gap-2 w-full sm:w-auto" + > +
+ 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" + /> + 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" + /> +
{error && {error}} - +
+ {isPartialPayment && ( + + )} + +
); } diff --git a/App/app/(portal)/po/[id]/receipt/actions.ts b/App/app/(portal)/po/[id]/receipt/actions.ts index 1f04b4a..ce0fa77 100644 --- a/App/app/(portal)/po/[id]/receipt/actions.ts +++ b/App/app/(portal)/po/[id]/receipt/actions.ts @@ -34,7 +34,9 @@ export async function confirmReceipt({ if (!po) return { error: "PO not found" }; const isAllowedStatus = - po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED"; + po.status === "PAID_DELIVERED" || + po.status === "PARTIALLY_CLOSED" || + po.status === "PARTIALLY_PAID"; if (!isAllowedStatus) { 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 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 await Promise.all( @@ -90,7 +107,7 @@ export async function confirmReceipt({ where: { id: poId }, data: { status: newStatus, - closedAt: allDelivered ? new Date() : undefined, + closedAt: newStatus === "CLOSED" ? new Date() : undefined, receipt: notes ? { create: { storageKey: "", fileName: "no-file", notes } } : undefined, @@ -133,7 +150,7 @@ export async function confirmReceipt({ } 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 } }); await notify({ event: "RECEIPT_CONFIRMED", po, recipients: [...managers, ...accounts] }); } else { diff --git a/App/app/(portal)/po/[id]/receipt/page.tsx b/App/app/(portal)/po/[id]/receipt/page.tsx index 77247d4..d657d66 100644 --- a/App/app/(portal)/po/[id]/receipt/page.tsx +++ b/App/app/(portal)/po/[id]/receipt/page.tsx @@ -24,6 +24,8 @@ export default async function ReceiptPage({ params }: Props) { title: true, status: true, submitterId: true, + paidAmount: true, + totalAmount: true, lineItems: { orderBy: { sortOrder: "asc" }, select: { @@ -38,7 +40,11 @@ export default async function ReceiptPage({ params }: Props) { }); 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}`); } 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 isPartiallyPaid = po.status === "PARTIALLY_PAID"; return (
@@ -70,7 +77,14 @@ export default async function ReceiptPage({ params }: Props) {

)}
- + ); } diff --git a/App/app/(portal)/po/[id]/receipt/receipt-form.tsx b/App/app/(portal)/po/[id]/receipt/receipt-form.tsx index f6f879d..c9927bc 100644 --- a/App/app/(portal)/po/[id]/receipt/receipt-form.tsx +++ b/App/app/(portal)/po/[id]/receipt/receipt-form.tsx @@ -18,9 +18,12 @@ interface Props { poId: string; lineItems: LineItem[]; 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 [notes, setNotes] = useState(""); const [files, setFiles] = useState([]); @@ -81,6 +84,19 @@ export function ReceiptForm({ poId, lineItems, isPartiallyReceived }: Props) { return (
+ {/* Partial payment warning banner */} + {isPartiallyPaid && ( +
+

Payment not yet complete

+

+ 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. + )} +

+
+ )} + {/* Line items delivery tracker */}
diff --git a/App/components/po/po-detail.tsx b/App/components/po/po-detail.tsx index 436af1b..2e2ab49 100644 --- a/App/components/po/po-detail.tsx +++ b/App/components/po/po-detail.tsx @@ -20,6 +20,7 @@ type PoWithRelations = { dateRequired: Date | null; managerNote: string | null; paymentRef: string | null; + paidAmount?: import("@prisma/client").Prisma.Decimal | null; piQuotationNo?: string | null; piQuotationDate?: Date | null; requisitionNo?: string | null; @@ -82,6 +83,7 @@ const ACTION_LABELS: Record = { VENDOR_ID_REQUESTED: "Vendor ID requested", VENDOR_ID_PROVIDED: "Vendor ID provided", PAYMENT_SENT: "Payment confirmed", + PARTIAL_PAYMENT_CONFIRMED: "Partial payment confirmed", RECEIPT_CONFIRMED: "Receipt confirmed", PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed", CLOSED: "Closed", @@ -142,7 +144,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals ); 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") && !readOnly; @@ -184,7 +186,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals )} {/* 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) && (<>
-

- {po.status === "PARTIALLY_CLOSED" ? "Partially received" : "Payment confirmed"} +

+ {po.status === "PARTIALLY_CLOSED" + ? "Partially received" + : po.status === "PARTIALLY_PAID" + ? "Advance payment received" + : "Payment confirmed"}

-

+

{po.status === "PARTIALLY_CLOSED" ? "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."}

{po.status === "PARTIALLY_CLOSED" ? "Confirm Remaining" : "Confirm Receipt"} diff --git a/App/lib/po-state-machine.ts b/App/lib/po-state-machine.ts index fd56e64..ed241a3 100644 --- a/App/lib/po-state-machine.ts +++ b/App/lib/po-state-machine.ts @@ -10,6 +10,7 @@ export type POAction = | "provide_vendor_id" | "process_payment" | "mark_paid" + | "mark_partial_payment" | "confirm_receipt" | "confirm_partial_receipt"; @@ -103,6 +104,38 @@ const TRANSITIONS: Partial> = { requiresNote: false, 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: { confirm_receipt: { diff --git a/App/lib/utils.ts b/App/lib/utils.ts index a02d791..f74d65a 100644 --- a/App/lib/utils.ts +++ b/App/lib/utils.ts @@ -47,6 +47,7 @@ export const PO_STATUS_LABELS: Record = { REJECTED: "Rejected", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment", + PARTIALLY_PAID: "Partially Paid", PAID_DELIVERED: "Paid", PARTIALLY_CLOSED: "Partially Received", CLOSED: "Closed", @@ -69,6 +70,7 @@ export const PO_STATUS_VARIANTS: Record = { REJECTED: "danger", MGR_APPROVED: "success", SENT_FOR_PAYMENT: "default", + PARTIALLY_PAID: "warning", PAID_DELIVERED: "success", PARTIALLY_CLOSED: "warning", CLOSED: "secondary", diff --git a/App/lib/validations/po.ts b/App/lib/validations/po.ts index bfb302d..ba829d4 100644 --- a/App/lib/validations/po.ts +++ b/App/lib/validations/po.ts @@ -63,6 +63,7 @@ export const requestEditsSchema = z.object({ export const processPaymentSchema = z.object({ 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({ diff --git a/App/prisma/migrations/20260527000000_partial_payment/migration.sql b/App/prisma/migrations/20260527000000_partial_payment/migration.sql new file mode 100644 index 0000000..452074d --- /dev/null +++ b/App/prisma/migrations/20260527000000_partial_payment/migration.sql @@ -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); diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 310e78a..e832d97 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -26,6 +26,7 @@ enum POStatus { REJECTED MGR_APPROVED SENT_FOR_PAYMENT + PARTIALLY_PAID PAID_DELIVERED PARTIALLY_CLOSED CLOSED @@ -41,6 +42,7 @@ enum ActionType { VENDOR_ID_REQUESTED VENDOR_ID_PROVIDED PAYMENT_SENT + PARTIAL_PAYMENT_CONFIRMED RECEIPT_CONFIRMED PARTIAL_RECEIPT_CONFIRMED CLOSED @@ -229,6 +231,7 @@ model PurchaseOrder { projectCode String? managerNote String? paymentRef String? + paidAmount Decimal? @db.Decimal(12, 2) piQuotationNo String? piQuotationDate DateTime? requisitionNo String?