diff --git a/App/app/(portal)/payments/actions.ts b/App/app/(portal)/payments/actions.ts index 4dc4219..2e9f547 100644 --- a/App/app/(portal)/payments/actions.ts +++ b/App/app/(portal)/payments/actions.ts @@ -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 { 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(), }, }, }, diff --git a/App/app/(portal)/payments/history/page.tsx b/App/app/(portal)/payments/history/page.tsx index 28e564b..870f703 100644 --- a/App/app/(portal)/payments/history/page.tsx +++ b/App/app/(portal)/payments/history/page.tsx @@ -130,7 +130,7 @@ export default async function PaymentHistoryPage({ searchParams }: Props) { {formatCurrency(Number(po.totalAmount), po.currency)} - {po.paidAt ? formatDate(po.paidAt) : "—"} + {po.paymentDate ? formatDate(po.paymentDate) : po.paidAt ? formatDate(po.paidAt) : "—"} ))} diff --git a/App/app/(portal)/payments/payment-actions.tsx b/App/app/(portal)/payments/payment-actions.tsx index 03125e7..489be81 100644 --- a/App/app/(portal)/payments/payment-actions.tsx +++ b/App/app/(portal)/payments/payment-actions.tsx @@ -12,14 +12,23 @@ interface Props { paidAmount?: number; } +// Today's date as a local yyyy-mm-dd string (for 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(""); + const [paymentDate, setPaymentDate] = useState(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" /> + 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" + />
Requisition No.
{po.requisitionNo}
} {po.requisitionDate &&
Requisition Date
{formatDate(po.requisitionDate)}
} {po.paymentRef &&
Payment Ref
{po.paymentRef}
} + {(po.paymentDate || po.paidAt) &&
Payment Date
{formatDate((po.paymentDate ?? po.paidAt)!)}
} {po.placeOfDelivery && (
diff --git a/App/lib/validations/po.ts b/App/lib/validations/po.ts index acc3936..3623cb0 100644 --- a/App/lib/validations/po.ts +++ b/App/lib/validations/po.ts @@ -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({ diff --git a/App/prisma/migrations/20260531000002_po_payment_date/migration.sql b/App/prisma/migrations/20260531000002_po_payment_date/migration.sql new file mode 100644 index 0000000..892014d --- /dev/null +++ b/App/prisma/migrations/20260531000002_po_payment_date/migration.sql @@ -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; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 9a9b9e1..bc23465 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -250,6 +250,7 @@ model PurchaseOrder { projectCode String? managerNote String? paymentRef String? + paymentDate DateTime? paidAmount Decimal? @db.Decimal(12, 2) piQuotationNo String? piQuotationDate DateTime? diff --git a/App/tests/integration/payment-actions.test.ts b/App/tests/integration/payment-actions.test.ts index b94ead1..a6ace55 100644 --- a/App/tests/integration/payment-actions.test.ts +++ b/App/tests/integration/payment-actions.test.ts @@ -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"); }); });