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