pelagia-portal/App/app/(portal)/approvals/[id]/manager-line-edit-actions.ts
Hardik 78afcb610b
Some checks failed
PR checks / checks (pull_request) Failing after 35s
PR checks / integration (pull_request) Successful in 33s
feat(po): TCS & Discount below GST (#133)
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>
2026-06-29 14:50:34 +05:30

91 lines
2.7 KiB
TypeScript

"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { poNetPayable } from "@/lib/po-money";
import { revalidatePath } from "next/cache";
import { z } from "zod";
type ActionResult = { ok: true } | { error: string };
const lineItemSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
quantity: z.coerce.number().positive(),
unit: z.string().min(1),
size: z.string().optional(),
unitPrice: z.coerce.number().nonnegative(),
gstRate: z.coerce.number().min(0).max(1).default(0.18),
});
export async function managerEditLineItems({
poId,
lineItems,
}: {
poId: string;
lineItems: Array<{ name: string; description?: string; quantity: number; unit: string; size?: string; unitPrice: number }>;
}): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "approve_po")) {
return { error: "Forbidden" };
}
const parsed = z.array(lineItemSchema).min(1).safeParse(lineItems);
if (!parsed.success) return { error: parsed.error.errors[0].message };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { lineItems: { orderBy: { sortOrder: "asc" } } },
});
if (!po) return { error: "PO not found" };
if (po.status !== "MGR_REVIEW") return { error: "Line items can only be edited while the PO is under review." };
const originalSnapshot = po.lineItems.map((li) => ({
name: (li as typeof li & { name: string }).name,
description: li.description ?? undefined,
quantity: Number(li.quantity),
unit: li.unit,
size: li.size ?? undefined,
unitPrice: Number(li.unitPrice),
}));
// Recompute the total from the edited line items, preserving the PO-level
// TCS / Discount charges (#133) so a manager line edit doesn't drop them.
const newTotal = poNetPayable(
parsed.data,
Number(po.tcsAmount ?? 0),
Number(po.discountAmount ?? 0)
);
await db.purchaseOrder.update({
where: { id: poId },
data: {
totalAmount: newTotal,
lineItems: {
deleteMany: {},
create: parsed.data.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,
})),
},
actions: {
create: {
actionType: "MANAGER_LINE_EDIT",
actorId: session.user.id,
metadata: { original: originalSnapshot },
},
},
},
});
revalidatePath(`/approvals/${poId}`);
return { ok: true };
}