feat(payments): compulsory payment date when Accounts records payment

- New PurchaseOrder.paymentDate field (migration 20260531000002)
- Backfill: existing POs use paidAt, else the earliest payment action date
- Accounts must enter a payment date with the payment reference
- Date input pre-selected to today, max=today (no future dates)
- Validated server-side (required + not in future) in processPaymentSchema
- paymentDate stored on both full and partial payments; paidAt set from it
- Shown on PO detail (Payment Date) and payment history (prefers paymentDate)
- Integration tests updated; added future-date rejection test

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-10 08:59:25 +05:30
parent eb402e03ef
commit add0f3c19c
8 changed files with 84 additions and 11 deletions

View file

@ -140,16 +140,20 @@ export async function markPaid({
poId,
paymentRef,
paymentAmount,
paymentDate,
}: {
poId: string;
paymentRef: string;
paymentAmount?: number; // if omitted, treat as full remaining amount
paymentDate: string; // ISO date (yyyy-mm-dd) entered by Accounts
}): Promise<ActionResult> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const parsed = processPaymentSchema.safeParse({ paymentRef, paymentAmount });
if (!parsed.success) return { error: "Payment reference is required." };
const parsed = processPaymentSchema.safeParse({ paymentRef, paymentAmount, paymentDate });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const enteredPaymentDate = parsed.data.paymentDate;
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
@ -175,14 +179,15 @@ export async function markPaid({
where: { id: poId },
data: {
status: "PAID_DELIVERED",
paidAt: new Date(),
paidAt: enteredPaymentDate,
paymentDate: enteredPaymentDate,
paymentRef: parsed.data.paymentRef,
paidAmount: newPaidAmount,
actions: {
create: {
actionType: "PAYMENT_SENT",
actorId: session.user.id,
metadata: { paymentRef: parsed.data.paymentRef },
metadata: { paymentRef: parsed.data.paymentRef, paymentDate: enteredPaymentDate.toISOString() },
},
},
},
@ -206,6 +211,7 @@ export async function markPaid({
data: {
status: "PARTIALLY_PAID",
paymentRef: parsed.data.paymentRef,
paymentDate: enteredPaymentDate,
paidAmount: newPaidAmount,
actions: {
create: {
@ -215,6 +221,7 @@ export async function markPaid({
paymentRef: parsed.data.paymentRef,
paymentAmount: paying,
totalPaid: newPaidAmount,
paymentDate: enteredPaymentDate.toISOString(),
},
},
},

View file

@ -130,7 +130,7 @@ export default async function PaymentHistoryPage({ searchParams }: Props) {
{formatCurrency(Number(po.totalAmount), po.currency)}
</td>
<td className="px-4 py-3 text-neutral-500">
{po.paidAt ? formatDate(po.paidAt) : "—"}
{po.paymentDate ? formatDate(po.paymentDate) : po.paidAt ? formatDate(po.paidAt) : "—"}
</td>
</tr>
))}

View file

@ -12,14 +12,23 @@ interface Props {
paidAmount?: number;
}
// Today's date as a local yyyy-mm-dd string (for <input type="date"> default + max)
function todayLocal(): string {
const d = new Date();
const off = d.getTimezoneOffset();
return new Date(d.getTime() - off * 60_000).toISOString().slice(0, 10);
}
export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0 }: Props) {
const router = useRouter();
const [ref, setRef] = useState("");
const [amount, setAmount] = useState<string>("");
const [paymentDate, setPaymentDate] = useState<string>(todayLocal());
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const remaining = totalAmount - paidAmount;
const today = todayLocal();
async function handleProcessPayment() {
setPending(true);
@ -32,6 +41,8 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
async function handleMarkPaid(e: React.FormEvent, forceFullPayment = false) {
e.preventDefault();
if (!ref.trim()) { setError("Payment reference is required."); return; }
if (!paymentDate) { setError("Payment date is required."); return; }
if (paymentDate > today) { setError("Payment date cannot be in the future."); return; }
const paymentAmount = forceFullPayment ? remaining : (parseFloat(amount) || undefined);
@ -46,7 +57,7 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
setPending(true);
setError("");
const result = await markPaid({ poId, paymentRef: ref, paymentAmount });
const result = await markPaid({ poId, paymentRef: ref, paymentAmount, paymentDate });
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); router.refresh(); }
}
@ -88,6 +99,16 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
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"
/>
<input
type="date"
aria-label="Payment date"
title="Payment date"
value={paymentDate}
max={today}
required
onChange={(e) => setPaymentDate(e.target.value)}
className="w-full sm:w-40 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)})`}

View file

@ -20,6 +20,7 @@ type PoWithRelations = {
dateRequired: Date | null;
managerNote: string | null;
paymentRef: string | null;
paymentDate?: Date | null;
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
piQuotationNo?: string | null;
piQuotationDate?: Date | null;
@ -299,6 +300,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
{po.requisitionNo && <div><dt className="text-neutral-500">Requisition No.</dt><dd className="font-medium text-neutral-900">{po.requisitionNo}</dd></div>}
{po.requisitionDate && <div><dt className="text-neutral-500">Requisition Date</dt><dd className="font-medium text-neutral-900">{formatDate(po.requisitionDate)}</dd></div>}
{po.paymentRef && <div><dt className="text-neutral-500">Payment Ref</dt><dd className="font-mono text-sm text-neutral-900">{po.paymentRef}</dd></div>}
{(po.paymentDate || po.paidAt) && <div><dt className="text-neutral-500">Payment Date</dt><dd className="font-medium text-neutral-900">{formatDate((po.paymentDate ?? po.paidAt)!)}</dd></div>}
</dl>
{po.placeOfDelivery && (
<div className="mt-3 pt-3 border-t border-neutral-100 text-sm">

View file

@ -65,6 +65,14 @@ 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(),
paymentDate: z.coerce
.date({ required_error: "Payment date is required", invalid_type_error: "Payment date is required" })
.refine((d) => {
// Not in the future — compare against end of today (local)
const endOfToday = new Date();
endOfToday.setHours(23, 59, 59, 999);
return d.getTime() <= endOfToday.getTime();
}, "Payment date cannot be in the future"),
});
export const confirmReceiptSchema = z.object({

View file

@ -0,0 +1,22 @@
-- Add user-entered payment date to PurchaseOrder
ALTER TABLE "PurchaseOrder" ADD COLUMN "paymentDate" TIMESTAMP(3);
-- Backfill 1: fully-paid POs already carry paidAt — use it as the payment date
UPDATE "PurchaseOrder"
SET "paymentDate" = "paidAt"
WHERE "paidAt" IS NOT NULL AND "paymentDate" IS NULL;
-- Backfill 2: POs that have a payment reference but no payment date yet
-- (e.g. partially-paid) — use the date the payment reference was first recorded,
-- i.e. the earliest PAYMENT_SENT / PARTIAL_PAYMENT_CONFIRMED action.
UPDATE "PurchaseOrder" po
SET "paymentDate" = sub."firstPaymentActionAt"
FROM (
SELECT "poId", MIN("createdAt") AS "firstPaymentActionAt"
FROM "POAction"
WHERE "actionType" IN ('PAYMENT_SENT', 'PARTIAL_PAYMENT_CONFIRMED')
GROUP BY "poId"
) sub
WHERE po."id" = sub."poId"
AND po."paymentDate" IS NULL
AND po."paymentRef" IS NOT NULL;

View file

@ -250,6 +250,7 @@ model PurchaseOrder {
projectCode String?
managerNote String?
paymentRef String?
paymentDate DateTime?
paidAmount Decimal? @db.Decimal(12, 2)
piQuotationNo String?
piQuotationDate DateTime?

View file

@ -19,6 +19,7 @@ import {
} from "./helpers";
const PREFIX = "INTTEST_PAYMENT_";
const TODAY = new Date().toISOString().slice(0, 10); // yyyy-mm-dd, used for payment date
let techId: string;
let managerId: string;
let accountsId: string;
@ -91,13 +92,14 @@ describe("A-02 — mark PO as paid with reference number", () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234" });
const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234", paymentDate: TODAY });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("PAID_DELIVERED");
expect(po?.paymentRef).toBe("NEFT/2026/001234");
expect(po?.paidAt).not.toBeNull();
expect(po?.paymentDate).not.toBeNull();
});
it("creates a PAYMENT_SENT action in the audit trail", async () => {
@ -105,7 +107,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
await markPaid({ poId, paymentRef: "TXN-9999" });
await markPaid({ poId, paymentRef: "TXN-9999", paymentDate: TODAY });
const action = await db.pOAction.findFirst({ where: { poId, actionType: "PAYMENT_SENT" } });
expect(action).not.toBeNull();
@ -117,7 +119,17 @@ describe("A-02 — mark PO as paid with reference number", () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
const result = await markPaid({ poId, paymentRef: "" });
const result = await markPaid({ poId, paymentRef: "", paymentDate: TODAY });
expect(result).toHaveProperty("error");
});
it("returns error when payment date is in the future", async () => {
const poId = await createApprovedPo(`${PREFIX}PaidFutureDate`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const result = await markPaid({ poId, paymentRef: "FUTURE-REF", paymentDate: future });
expect(result).toHaveProperty("error");
});
@ -128,7 +140,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
vi.mocked(notify).mockClear();
await processPayment({ poId });
await markPaid({ poId, paymentRef: "REF-42" });
await markPaid({ poId, paymentRef: "REF-42", paymentDate: TODAY });
const calls = vi.mocked(notify).mock.calls.map((c) => c[0].event);
expect(calls).toContain("PAYMENT_SENT");
@ -141,7 +153,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
await processPayment({ poId });
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await markPaid({ poId, paymentRef: "MGR-REF" });
const result = await markPaid({ poId, paymentRef: "MGR-REF", paymentDate: TODAY });
expect(result).toHaveProperty("error");
});
});