pelagia-portal/App/app/(portal)/payments/actions.ts
Hardik 70f3230c36
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 31s
feat(po): register line items in the product catalogue on approval
Previously a PO's free-text line items only became reusable catalogue products
(/inventory/items) on full payment (markPaid → syncProductCatalog). An approved-
but-unpaid PO's items weren't selectable for further POs yet.

- extract syncProductCatalog into lib/product-catalog.ts (shared).
- call it from approvePo so approved items are immediately catalogued (create
  product by name if unknown, link the line item, upsert last/per-vendor price);
  payment still re-syncs to refresh prices. Idempotent.
- test: approving a PO with a free-text line creates + links the product and
  records the per-vendor price.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 04:59:47 +05:30

140 lines
4.6 KiB
TypeScript

"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 { syncProductCatalog } from "@/lib/product-catalog";
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<ActionResult> {
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 / PARTIALLY_PAID → PAID_DELIVERED or PARTIALLY_PAID
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, 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 },
include: { submitter: true, lineItems: true },
});
if (!po) return { error: "PO not found" };
const canFullPay = canPerformAction(po.status, "mark_paid", session.user.role);
const canPartialPay = canPerformAction(po.status, "mark_partial_payment", session.user.role);
if (!canFullPay && !canPartialPay) {
return { error: "You cannot confirm payment for this PO." };
}
const alreadyPaid = Number(po.paidAmount ?? 0);
const total = Number(po.totalAmount);
const remaining = total - alreadyPaid;
const paying = parsed.data.paymentAmount ?? remaining;
const newPaidAmount = alreadyPaid + paying;
const isFullyPaid = newPaidAmount >= total;
if (isFullyPaid) {
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "PAID_DELIVERED",
paidAt: enteredPaymentDate,
paymentDate: enteredPaymentDate,
paymentRef: parsed.data.paymentRef,
paidAmount: newPaidAmount,
actions: {
create: {
actionType: "PAYMENT_SENT",
actorId: session.user.id,
metadata: { paymentRef: parsed.data.paymentRef, paymentDate: enteredPaymentDate.toISOString() },
},
},
},
});
// Sync product catalog: auto-create new items, upsert per-vendor prices
await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id);
// Auto-verify the vendor on first successful payment
if (po.vendorId) {
await db.vendor.update({
where: { id: po.vendorId },
data: { isVerified: true },
});
}
await notify({ event: "PAYMENT_SENT", po, recipients: [po.submitter] });
} else {
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "PARTIALLY_PAID",
paymentRef: parsed.data.paymentRef,
paymentDate: enteredPaymentDate,
paidAmount: newPaidAmount,
actions: {
create: {
actionType: "PARTIAL_PAYMENT_CONFIRMED",
actorId: session.user.id,
metadata: {
paymentRef: parsed.data.paymentRef,
paymentAmount: paying,
totalPaid: newPaidAmount,
paymentDate: enteredPaymentDate.toISOString(),
},
},
},
},
});
}
revalidatePath("/payments");
revalidatePath(`/po/${poId}`);
return { ok: true };
}