From 207c16e0e561881dc465e4adf80217ff99fc892d Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 6 May 2026 00:15:14 +0530 Subject: [PATCH] feat(payments): accounts payment queue with two-step payment and product price auto-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 (process): MGR_APPROVED → SENT_FOR_PAYMENT, notifies submitter and managers. Step 2 (mark paid): SENT_FOR_PAYMENT → PAID_DELIVERED, stores paymentRef. On mark paid: auto-updates Product.lastPrice and lastVendorId for any line items linked to a product code; logs PRODUCT_PRICE_UPDATED action. --- .../app/(portal)/payments/actions.ts | 118 ++++++++++++++++++ .../app/(portal)/payments/page.tsx | 93 ++++++++++++++ .../app/(portal)/payments/payment-actions.tsx | 70 +++++++++++ 3 files changed, 281 insertions(+) create mode 100644 App/pelagia-portal/app/(portal)/payments/actions.ts create mode 100644 App/pelagia-portal/app/(portal)/payments/page.tsx create mode 100644 App/pelagia-portal/app/(portal)/payments/payment-actions.tsx diff --git a/App/pelagia-portal/app/(portal)/payments/actions.ts b/App/pelagia-portal/app/(portal)/payments/actions.ts new file mode 100644 index 0000000..d05e09e --- /dev/null +++ b/App/pelagia-portal/app/(portal)/payments/actions.ts @@ -0,0 +1,118 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { canPerformAction } from "@/lib/po-state-machine"; +import { processPaymentSchema } from "@/lib/validations/po"; +import { notify } from "@/lib/notifier"; +import { revalidatePath } from "next/cache"; + +type ActionResult = { ok: true } | { error: string }; + +// Step 1: Accounts picks up the PO — MGR_APPROVED → SENT_FOR_PAYMENT +export async function processPayment({ + poId, +}: { + poId: string; +}): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const po = await db.purchaseOrder.findUnique({ + where: { id: poId }, + include: { submitter: true }, + }); + if (!po) return { error: "PO not found" }; + if (!canPerformAction(po.status, "process_payment", session.user.role)) { + return { error: "You cannot process payment for this PO." }; + } + + await db.purchaseOrder.update({ + where: { id: poId }, + data: { status: "SENT_FOR_PAYMENT" }, + }); + + const [managers, accounts] = await Promise.all([ + db.user.findMany({ where: { role: "MANAGER", isActive: true } }), + db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }), + ]); + await notify({ event: "PAYMENT_PROCESSING", po, recipients: [...managers, ...accounts] }); + + revalidatePath("/payments"); + revalidatePath(`/po/${poId}`); + return { ok: true }; +} + +// Step 2: Accounts confirms payment sent — SENT_FOR_PAYMENT → PAID_DELIVERED +export async function markPaid({ + poId, + paymentRef, +}: { + poId: string; + paymentRef: string; +}): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const parsed = processPaymentSchema.safeParse({ paymentRef }); + if (!parsed.success) return { error: "Payment reference is required." }; + + const po = await db.purchaseOrder.findUnique({ + where: { id: poId }, + include: { + submitter: true, + lineItems: true, + }, + }); + if (!po) return { error: "PO not found" }; + if (!canPerformAction(po.status, "mark_paid", session.user.role)) { + return { error: "You cannot confirm payment for this PO." }; + } + + await db.purchaseOrder.update({ + where: { id: poId }, + data: { + status: "PAID_DELIVERED", + paidAt: new Date(), + paymentRef: parsed.data.paymentRef, + actions: { + create: { + actionType: "PAYMENT_SENT", + actorId: session.user.id, + metadata: { paymentRef: parsed.data.paymentRef }, + }, + }, + }, + }); + + // Auto-update product catalogue: set lastPrice + lastVendorId for each linked product + const linkedItems = po.lineItems.filter((li) => li.productId !== null); + if (linkedItems.length > 0) { + await Promise.all( + linkedItems.map((li) => + db.product.update({ + where: { id: li.productId! }, + data: { + lastPrice: li.unitPrice, + lastVendorId: po.vendorId ?? undefined, + }, + }) + ) + ); + await db.pOAction.create({ + data: { + actionType: "PRODUCT_PRICE_UPDATED", + actorId: session.user.id, + poId, + metadata: { updatedProductIds: linkedItems.map((li) => li.productId) }, + }, + }); + } + + const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } }); + await notify({ event: "PAYMENT_SENT", po, recipients: [po.submitter, ...managers] }); + + revalidatePath("/payments"); + revalidatePath(`/po/${poId}`); + return { ok: true }; +} diff --git a/App/pelagia-portal/app/(portal)/payments/page.tsx b/App/pelagia-portal/app/(portal)/payments/page.tsx new file mode 100644 index 0000000..45d03fe --- /dev/null +++ b/App/pelagia-portal/app/(portal)/payments/page.tsx @@ -0,0 +1,93 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { formatCurrency, formatDate } from "@/lib/utils"; +import { PaymentActions } from "./payment-actions"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Payments" }; + +export default async function PaymentsPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + + if (!hasPermission(session.user.role, "process_payment")) redirect("/dashboard"); + + const queue = await db.purchaseOrder.findMany({ + where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT"] } }, + include: { submitter: true, vessel: true, account: true, vendor: true }, + orderBy: { approvedAt: "asc" }, + }); + + return ( +
+
+

Payment Queue

+

+ {queue.length} order{queue.length !== 1 ? "s" : ""} in the payment pipeline +

+
+ + {queue.length === 0 ? ( +
+

No orders in the payment queue.

+
+ ) : ( +
+ {queue.map((po) => ( +
+
+
+
+ {po.poNumber} +
+

{po.title}

+
+ {po.vessel.name} + · + {po.submitter.name} + {po.vendor && ( + <> + · + {po.vendor.name} + + )} + {po.approvedAt && ( + <> + · + Approved {formatDate(po.approvedAt)} + + )} +
+
+
+
+ {formatCurrency(Number(po.totalAmount), po.currency)} +
+ + View PO → + +
+
+
+ + {po.status === "SENT_FOR_PAYMENT" ? "Processing — awaiting confirmation" : "Ready for payment"} + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/payments/payment-actions.tsx b/App/pelagia-portal/app/(portal)/payments/payment-actions.tsx new file mode 100644 index 0000000..1ceaf8a --- /dev/null +++ b/App/pelagia-portal/app/(portal)/payments/payment-actions.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { processPayment, markPaid } from "./actions"; +import type { POStatus } from "@prisma/client"; + +export function PaymentActions({ poId, poStatus }: { poId: string; poStatus: POStatus }) { + const router = useRouter(); + const [ref, setRef] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + async function handleProcessPayment() { + setPending(true); + setError(""); + const result = await processPayment({ poId }); + if ("error" in result) { setError(result.error); setPending(false); } + else { router.refresh(); } + } + + async function handleMarkPaid(e: React.FormEvent) { + e.preventDefault(); + if (!ref.trim()) { setError("Payment reference is required."); return; } + setPending(true); + setError(""); + const result = await markPaid({ poId, paymentRef: ref }); + if ("error" in result) { setError(result.error); setPending(false); } + else { router.refresh(); } + } + + if (poStatus === "MGR_APPROVED") { + return ( +
+ {error && {error}} + +
+ ); + } + + if (poStatus === "SENT_FOR_PAYMENT") { + return ( +
+ 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" + /> + {error && {error}} + +
+ ); + } + + return null; +}