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,
|
poId,
|
||||||
paymentRef,
|
paymentRef,
|
||||||
paymentAmount,
|
paymentAmount,
|
||||||
|
paymentDate,
|
||||||
}: {
|
}: {
|
||||||
poId: string;
|
poId: string;
|
||||||
paymentRef: string;
|
paymentRef: string;
|
||||||
paymentAmount?: number; // if omitted, treat as full remaining amount
|
paymentAmount?: number; // if omitted, treat as full remaining amount
|
||||||
|
paymentDate: string; // ISO date (yyyy-mm-dd) entered by Accounts
|
||||||
}): 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, paymentAmount });
|
const parsed = processPaymentSchema.safeParse({ paymentRef, paymentAmount, paymentDate });
|
||||||
if (!parsed.success) return { error: "Payment reference is required." };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
|
||||||
|
const enteredPaymentDate = parsed.data.paymentDate;
|
||||||
|
|
||||||
const po = await db.purchaseOrder.findUnique({
|
const po = await db.purchaseOrder.findUnique({
|
||||||
where: { id: poId },
|
where: { id: poId },
|
||||||
|
|
@ -175,14 +179,15 @@ export async function markPaid({
|
||||||
where: { id: poId },
|
where: { id: poId },
|
||||||
data: {
|
data: {
|
||||||
status: "PAID_DELIVERED",
|
status: "PAID_DELIVERED",
|
||||||
paidAt: new Date(),
|
paidAt: enteredPaymentDate,
|
||||||
|
paymentDate: enteredPaymentDate,
|
||||||
paymentRef: parsed.data.paymentRef,
|
paymentRef: parsed.data.paymentRef,
|
||||||
paidAmount: newPaidAmount,
|
paidAmount: newPaidAmount,
|
||||||
actions: {
|
actions: {
|
||||||
create: {
|
create: {
|
||||||
actionType: "PAYMENT_SENT",
|
actionType: "PAYMENT_SENT",
|
||||||
actorId: session.user.id,
|
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: {
|
data: {
|
||||||
status: "PARTIALLY_PAID",
|
status: "PARTIALLY_PAID",
|
||||||
paymentRef: parsed.data.paymentRef,
|
paymentRef: parsed.data.paymentRef,
|
||||||
|
paymentDate: enteredPaymentDate,
|
||||||
paidAmount: newPaidAmount,
|
paidAmount: newPaidAmount,
|
||||||
actions: {
|
actions: {
|
||||||
create: {
|
create: {
|
||||||
|
|
@ -215,6 +221,7 @@ export async function markPaid({
|
||||||
paymentRef: parsed.data.paymentRef,
|
paymentRef: parsed.data.paymentRef,
|
||||||
paymentAmount: paying,
|
paymentAmount: paying,
|
||||||
totalPaid: newPaidAmount,
|
totalPaid: newPaidAmount,
|
||||||
|
paymentDate: enteredPaymentDate.toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ export default async function PaymentHistoryPage({ searchParams }: Props) {
|
||||||
{formatCurrency(Number(po.totalAmount), po.currency)}
|
{formatCurrency(Number(po.totalAmount), po.currency)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-neutral-500">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,23 @@ interface Props {
|
||||||
paidAmount?: number;
|
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) {
|
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 [amount, setAmount] = useState<string>("");
|
||||||
|
const [paymentDate, setPaymentDate] = useState<string>(todayLocal());
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const remaining = totalAmount - paidAmount;
|
const remaining = totalAmount - paidAmount;
|
||||||
|
const today = todayLocal();
|
||||||
|
|
||||||
async function handleProcessPayment() {
|
async function handleProcessPayment() {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
|
|
@ -32,6 +41,8 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
|
||||||
async function handleMarkPaid(e: React.FormEvent, forceFullPayment = false) {
|
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; }
|
||||||
|
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);
|
const paymentAmount = forceFullPayment ? remaining : (parseFloat(amount) || undefined);
|
||||||
|
|
||||||
|
|
@ -46,7 +57,7 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
|
||||||
|
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setError("");
|
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); }
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
else { setPending(false); router.refresh(); }
|
else { setPending(false); router.refresh(); }
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +99,16 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
|
||||||
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="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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={`Amount (max ${remaining.toFixed(2)})`}
|
placeholder={`Amount (max ${remaining.toFixed(2)})`}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ type PoWithRelations = {
|
||||||
dateRequired: Date | null;
|
dateRequired: Date | null;
|
||||||
managerNote: string | null;
|
managerNote: string | null;
|
||||||
paymentRef: string | null;
|
paymentRef: string | null;
|
||||||
|
paymentDate?: Date | null;
|
||||||
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
|
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
|
||||||
piQuotationNo?: string | null;
|
piQuotationNo?: string | null;
|
||||||
piQuotationDate?: Date | 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.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.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.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>
|
</dl>
|
||||||
{po.placeOfDelivery && (
|
{po.placeOfDelivery && (
|
||||||
<div className="mt-3 pt-3 border-t border-neutral-100 text-sm">
|
<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({
|
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(),
|
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({
|
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?
|
projectCode String?
|
||||||
managerNote String?
|
managerNote String?
|
||||||
paymentRef String?
|
paymentRef String?
|
||||||
|
paymentDate DateTime?
|
||||||
paidAmount Decimal? @db.Decimal(12, 2)
|
paidAmount Decimal? @db.Decimal(12, 2)
|
||||||
piQuotationNo String?
|
piQuotationNo String?
|
||||||
piQuotationDate DateTime?
|
piQuotationDate DateTime?
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
const PREFIX = "INTTEST_PAYMENT_";
|
const PREFIX = "INTTEST_PAYMENT_";
|
||||||
|
const TODAY = new Date().toISOString().slice(0, 10); // yyyy-mm-dd, used for payment date
|
||||||
let techId: string;
|
let techId: string;
|
||||||
let managerId: string;
|
let managerId: string;
|
||||||
let accountsId: 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"));
|
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
await processPayment({ poId });
|
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 });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
||||||
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||||
expect(po?.status).toBe("PAID_DELIVERED");
|
expect(po?.status).toBe("PAID_DELIVERED");
|
||||||
expect(po?.paymentRef).toBe("NEFT/2026/001234");
|
expect(po?.paymentRef).toBe("NEFT/2026/001234");
|
||||||
expect(po?.paidAt).not.toBeNull();
|
expect(po?.paidAt).not.toBeNull();
|
||||||
|
expect(po?.paymentDate).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a PAYMENT_SENT action in the audit trail", async () => {
|
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"));
|
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
await processPayment({ poId });
|
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" } });
|
const action = await db.pOAction.findFirst({ where: { poId, actionType: "PAYMENT_SENT" } });
|
||||||
expect(action).not.toBeNull();
|
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"));
|
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
await processPayment({ poId });
|
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");
|
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(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
vi.mocked(notify).mockClear();
|
vi.mocked(notify).mockClear();
|
||||||
await processPayment({ poId });
|
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);
|
const calls = vi.mocked(notify).mock.calls.map((c) => c[0].event);
|
||||||
expect(calls).toContain("PAYMENT_SENT");
|
expect(calls).toContain("PAYMENT_SENT");
|
||||||
|
|
@ -141,7 +153,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
||||||
await processPayment({ poId });
|
await processPayment({ poId });
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
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");
|
expect(result).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue