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>
140 lines
4.6 KiB
TypeScript
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 };
|
|
}
|