Adds two PO-level charges shown below GST, per issue #133 ask 2. - Stored as ABSOLUTE rupee amounts on PurchaseOrder.tcsAmount / discountAmount (Decimal?, default 0; null/0 on historical & imported POs). Migration added. - Discount is applied post-GST. totalAmount folds the charges in (net payable = subtotal + GST + TCS − Discount), so payments / reports / advance all use the true amount due. lib/po-money.ts is the single source of truth. - Forms (create + edit) render a shared TcsDiscountFields with a % control bidirectionally linked to the rupee value (percentage is convenience only, taken against the GST-inclusive total; only the absolute amount is persisted). - createPo / updatePo store & compute; both manager-edit actions PRESERVE the PO's TCS/Discount when recomputing the total; import leaves them at 0. - PO detail shows TCS / Discount / Net payable below GST; PDF + XLSX export show the same breakdown and a corrected grand total. Tests: lib/po-money unit tests; po-tcs-discount integration test (create / edit / manager-line-edit preservation). Docs: CLAUDE.md GST section + wiki Purchase Orders (TCS/Discount + a full "what import sets vs. not" field-mapping table). Full unit (360) + integration (305) suites green; tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
6.2 KiB
TypeScript
169 lines
6.2 KiB
TypeScript
"use server";
|
||
|
||
import { auth } from "@/auth";
|
||
import { db } from "@/lib/db";
|
||
import { requirePermission } from "@/lib/permissions";
|
||
import { createPoSchema } from "@/lib/validations/po";
|
||
import { parsePoTerms } from "@/lib/terms";
|
||
import { generatePoNumber } from "@/lib/po-number";
|
||
import { poNetPayable } from "@/lib/po-money";
|
||
import { notify } from "@/lib/notifier";
|
||
import { revalidatePath } from "next/cache";
|
||
|
||
export async function createPo(
|
||
formData: FormData
|
||
): Promise<{ id: string } | { error: string }> {
|
||
const session = await auth();
|
||
if (!session?.user) return { error: "Unauthorized" };
|
||
|
||
try {
|
||
requirePermission(session.user.role, "create_po");
|
||
} catch {
|
||
return { error: "You do not have permission to create purchase orders." };
|
||
}
|
||
|
||
const intent = formData.get("intent") as "draft" | "submit";
|
||
|
||
const lineItems: Array<{
|
||
name: string;
|
||
description?: string;
|
||
quantity: number;
|
||
unit: string;
|
||
size?: string;
|
||
unitPrice: number;
|
||
gstRate: number;
|
||
productId?: string;
|
||
accountId?: string;
|
||
}> = [];
|
||
let i = 0;
|
||
while (formData.has(`lineItems[${i}].name`)) {
|
||
lineItems.push({
|
||
name: formData.get(`lineItems[${i}].name`) as string,
|
||
description: (formData.get(`lineItems[${i}].description`) as string) || undefined,
|
||
quantity: Number(formData.get(`lineItems[${i}].quantity`)),
|
||
unit: formData.get(`lineItems[${i}].unit`) as string,
|
||
size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
|
||
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
|
||
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
|
||
productId: (formData.get(`lineItems[${i}].productId`) as string) || undefined,
|
||
accountId: (formData.get(`lineItems[${i}].accountId`) as string) || undefined,
|
||
});
|
||
i++;
|
||
}
|
||
|
||
const parsed = createPoSchema.safeParse({
|
||
title: formData.get("title"),
|
||
vesselId: formData.get("vesselId"),
|
||
accountId: formData.get("accountId"),
|
||
companyId: (formData.get("companyId") as string) || undefined,
|
||
poDate: formData.get("poDate") || undefined,
|
||
projectCode: formData.get("projectCode") || undefined,
|
||
dateRequired: formData.get("dateRequired") || undefined,
|
||
vendorId: formData.get("vendorId") || undefined,
|
||
piQuotationNo: formData.get("piQuotationNo") || undefined,
|
||
piQuotationDate: formData.get("piQuotationDate") || undefined,
|
||
requisitionNo: formData.get("requisitionNo") || undefined,
|
||
requisitionDate: formData.get("requisitionDate") || undefined,
|
||
placeOfDelivery: formData.get("placeOfDelivery") || undefined,
|
||
tcDelivery: formData.get("tcDelivery") || undefined,
|
||
tcDispatch: formData.get("tcDispatch") || undefined,
|
||
tcInspection: formData.get("tcInspection") || undefined,
|
||
tcTransitInsurance: formData.get("tcTransitInsurance") || undefined,
|
||
tcPaymentTerms: formData.get("tcPaymentTerms") || undefined,
|
||
tcOthers: formData.get("tcOthers") || undefined,
|
||
tcsAmount: formData.get("tcsAmount") || undefined,
|
||
discountAmount: formData.get("discountAmount") || undefined,
|
||
lineItems,
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||
}
|
||
|
||
const data = parsed.data;
|
||
// Dynamic T&C rows (issue #11) — a JSON snapshot superseding the tc* columns.
|
||
let termsRaw: unknown = [];
|
||
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
|
||
const terms = parsePoTerms(termsRaw);
|
||
|
||
// totalAmount = subtotal + GST + TCS − Discount (PO-level charges below GST, #133)
|
||
const total = poNetPayable(data.lineItems, data.tcsAmount, data.discountAmount);
|
||
|
||
const po = await db.purchaseOrder.create({
|
||
data: {
|
||
poNumber: await generatePoNumber(data.vesselId, data.companyId),
|
||
title: data.title,
|
||
status: intent === "submit" ? "SUBMITTED" : "DRAFT",
|
||
totalAmount: total,
|
||
tcsAmount: data.tcsAmount,
|
||
discountAmount: data.discountAmount,
|
||
currency: data.currency,
|
||
vesselId: data.vesselId,
|
||
accountId: data.accountId,
|
||
companyId: data.companyId ?? null,
|
||
vendorId: data.vendorId ?? null,
|
||
poDate: data.poDate ? new Date(data.poDate) : null,
|
||
projectCode: data.projectCode ?? null,
|
||
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,
|
||
piQuotationNo: data.piQuotationNo ?? null,
|
||
piQuotationDate: data.piQuotationDate ? new Date(data.piQuotationDate) : null,
|
||
requisitionNo: data.requisitionNo ?? null,
|
||
requisitionDate: data.requisitionDate ? new Date(data.requisitionDate) : null,
|
||
placeOfDelivery: data.placeOfDelivery ?? null,
|
||
tcDelivery: data.tcDelivery ?? null,
|
||
tcDispatch: data.tcDispatch ?? null,
|
||
tcInspection: data.tcInspection ?? null,
|
||
tcTransitInsurance: data.tcTransitInsurance ?? null,
|
||
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
||
tcOthers: data.tcOthers ?? null,
|
||
terms,
|
||
submitterId: session.user.id,
|
||
submittedAt: intent === "submit" ? new Date() : null,
|
||
lineItems: {
|
||
create: data.lineItems.map((item, idx) => ({
|
||
name: item.name,
|
||
description: item.description ?? null,
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
size: item.size ?? null,
|
||
unitPrice: item.unitPrice,
|
||
totalPrice: item.quantity * item.unitPrice,
|
||
gstRate: item.gstRate,
|
||
sortOrder: idx,
|
||
productId: item.productId ?? null,
|
||
accountId: item.accountId ?? null,
|
||
})),
|
||
},
|
||
actions: {
|
||
create: {
|
||
actionType: "CREATED",
|
||
actorId: session.user.id,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
if (intent === "submit") {
|
||
await db.purchaseOrder.update({
|
||
where: { id: po.id },
|
||
data: {
|
||
status: "MGR_REVIEW",
|
||
actions: {
|
||
create: { actionType: "SUBMITTED", actorId: session.user.id },
|
||
},
|
||
},
|
||
});
|
||
|
||
const [fullPo, managers] = await Promise.all([
|
||
db.purchaseOrder.findUnique({ where: { id: po.id }, include: { submitter: true } }),
|
||
db.user.findMany({ where: { role: "MANAGER", isActive: true } }),
|
||
]);
|
||
if (fullPo) {
|
||
await notify({ event: "PO_SUBMITTED", po: fullPo, recipients: managers });
|
||
}
|
||
}
|
||
|
||
revalidatePath("/dashboard");
|
||
revalidatePath("/approvals");
|
||
return { id: po.id };
|
||
}
|