Previously the action was logged unconditionally for every line item on every payment confirmation, even when the price was identical to what was already on the product. Now we compare unitPrice to the stored lastPrice before deciding to record the change. New products being registered for the first time are also excluded since that is not a price update. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
228 lines
7.3 KiB
TypeScript
228 lines
7.3 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 { notify } from "@/lib/notifier";
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
type ActionResult = { ok: true } | { error: string };
|
|
|
|
function nameToCode(name: string): string {
|
|
const slug = name.toUpperCase()
|
|
.replace(/[^A-Z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "")
|
|
.substring(0, 20);
|
|
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
|
|
}
|
|
|
|
// Sync product catalog after payment is confirmed:
|
|
// - Auto-create products for unlinked line items (matched by name or brand new)
|
|
// - Upsert per-vendor prices for all items
|
|
async function syncProductCatalog(
|
|
poId: string,
|
|
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
|
|
vendorId: string | null,
|
|
actorId: string
|
|
) {
|
|
const updatedProductIds: string[] = [];
|
|
|
|
for (const li of lineItems) {
|
|
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
|
|
let productId = li.productId;
|
|
let priceChanged = false;
|
|
|
|
if (!productId) {
|
|
// Try to find an existing product by name (case-insensitive)
|
|
const existing = await db.product.findFirst({
|
|
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
|
|
select: { id: true, lastPrice: true },
|
|
});
|
|
|
|
if (existing) {
|
|
productId = existing.id;
|
|
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
|
|
} else {
|
|
// Create a new product — first-time registration, not a price update
|
|
const code = nameToCode(li.name);
|
|
try {
|
|
const created = await db.product.create({
|
|
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
|
|
});
|
|
productId = created.id;
|
|
} catch {
|
|
// Code collision (extremely unlikely) — add extra entropy
|
|
const created = await db.product.create({
|
|
data: {
|
|
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
|
|
name: li.name,
|
|
lastPrice: unitPrice,
|
|
lastVendorId: vendorId,
|
|
},
|
|
});
|
|
productId = created.id;
|
|
}
|
|
}
|
|
|
|
// Link the line item to the product for future reference
|
|
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
|
|
} else {
|
|
const current = await db.product.findUnique({
|
|
where: { id: productId },
|
|
select: { lastPrice: true },
|
|
});
|
|
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
|
|
}
|
|
|
|
// Always update lastPrice / lastVendorId on the product
|
|
await db.product.update({
|
|
where: { id: productId },
|
|
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
|
|
});
|
|
|
|
// Upsert per-vendor price if PO has a vendor
|
|
if (vendorId) {
|
|
await db.productVendorPrice.upsert({
|
|
where: { productId_vendorId: { productId, vendorId } },
|
|
update: { price: unitPrice },
|
|
create: { productId, vendorId, price: unitPrice },
|
|
});
|
|
}
|
|
|
|
if (priceChanged) updatedProductIds.push(productId);
|
|
}
|
|
|
|
if (updatedProductIds.length > 0) {
|
|
await db.pOAction.create({
|
|
data: {
|
|
actionType: "PRODUCT_PRICE_UPDATED",
|
|
actorId,
|
|
poId,
|
|
metadata: { updatedProductIds },
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
}: {
|
|
poId: string;
|
|
paymentRef: string;
|
|
paymentAmount?: number; // if omitted, treat as full remaining amount
|
|
}): 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 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: new Date(),
|
|
paymentRef: parsed.data.paymentRef,
|
|
paidAmount: newPaidAmount,
|
|
actions: {
|
|
create: {
|
|
actionType: "PAYMENT_SENT",
|
|
actorId: session.user.id,
|
|
metadata: { paymentRef: parsed.data.paymentRef },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// 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,
|
|
paidAmount: newPaidAmount,
|
|
actions: {
|
|
create: {
|
|
actionType: "PARTIAL_PAYMENT_CONFIRMED",
|
|
actorId: session.user.id,
|
|
metadata: {
|
|
paymentRef: parsed.data.paymentRef,
|
|
paymentAmount: paying,
|
|
totalPaid: newPaidAmount,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
revalidatePath("/payments");
|
|
revalidatePath(`/po/${poId}`);
|
|
return { ok: true };
|
|
}
|