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:
parent
eb402e03ef
commit
add0f3c19c
8 changed files with 84 additions and 11 deletions
|
|
@ -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(),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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)})`}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -250,6 +250,7 @@ model PurchaseOrder {
|
|||
projectCode String?
|
||||
managerNote String?
|
||||
paymentRef String?
|
||||
paymentDate DateTime?
|
||||
paidAmount Decimal? @db.Decimal(12, 2)
|
||||
piQuotationNo String?
|
||||
piQuotationDate DateTime?
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue