pelagia-portal/App/app/(portal)/payments/actions.ts
Hardik 5ad85417d9 fix(payments): only log PRODUCT_PRICE_UPDATED when price actually changed
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>
2026-05-27 04:28:40 +05:30

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 };
}