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:
parent
7169d52885
commit
cf9ff40262
14 changed files with 285 additions and 74 deletions
|
|
@ -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 } } },
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue