PO_SUBMITTED: managers only, submitter no longer copied PAYMENT_SENT: submitter only (it is a receipt prompt, not a manager action) PARTIAL_RECEIPT_CONFIRMED: managers now notified via new event type RECEIPT_CONFIRMED: unchanged (managers + accounts) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
185 lines
5.7 KiB
TypeScript
185 lines
5.7 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;
|
|
|
|
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 },
|
|
});
|
|
|
|
if (existing) {
|
|
productId = existing.id;
|
|
} else {
|
|
// Create a new product
|
|
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 } });
|
|
}
|
|
|
|
// 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 },
|
|
});
|
|
}
|
|
|
|
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 → PAID_DELIVERED
|
|
export async function markPaid({
|
|
poId,
|
|
paymentRef,
|
|
}: {
|
|
poId: string;
|
|
paymentRef: string;
|
|
}): Promise<ActionResult> {
|
|
const session = await auth();
|
|
if (!session?.user) return { error: "Unauthorized" };
|
|
|
|
const parsed = processPaymentSchema.safeParse({ paymentRef });
|
|
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" };
|
|
if (!canPerformAction(po.status, "mark_paid", session.user.role)) {
|
|
return { error: "You cannot confirm payment for this PO." };
|
|
}
|
|
|
|
await db.purchaseOrder.update({
|
|
where: { id: poId },
|
|
data: {
|
|
status: "PAID_DELIVERED",
|
|
paidAt: new Date(),
|
|
paymentRef: parsed.data.paymentRef,
|
|
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] });
|
|
|
|
revalidatePath("/payments");
|
|
revalidatePath(`/po/${poId}`);
|
|
return { ok: true };
|
|
}
|