Compare commits

..

4 commits

Author SHA1 Message Date
98eeb64045 Merge pull request 'feat(po): TCS & Discount below GST (#133)' (#149) from claude/issue-133-tcs-discount into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #149
2026-06-29 09:30:46 +00:00
c67155f5d9 test(po): fix intent type in TCS/Discount test (updatePo uses "save")
All checks were successful
PR checks / checks (pull_request) Successful in 54s
PR checks / integration (pull_request) Successful in 32s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:52:53 +05:30
78afcb610b feat(po): TCS & Discount below GST (#133)
Some checks failed
PR checks / checks (pull_request) Failing after 35s
PR checks / integration (pull_request) Successful in 33s
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
0fdda87381 Merge pull request 'fix: Activity should log partial payment amount' (#141) from claude/issue-140 into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #141
2026-06-28 01:41:18 +00:00
17 changed files with 527 additions and 39 deletions

View file

@ -267,7 +267,17 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
### GST Calculation ### GST Calculation
`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`. Per line item: `lineInclGst = quantity × unitPrice × (1 + gstRate)`. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%).
**TCS & Discount (issue #133):** two **PO-level** charges shown below GST, stored as **absolute** rupee amounts on `PurchaseOrder.tcsAmount` / `discountAmount` (`Decimal?`, default 0; null/0 on historical & imported POs). The PO forms offer a percentage control bidirectionally linked to the rupee value (a convenience — only the absolute amount is persisted); discount is applied **post-GST**.
`totalAmount` **folds the charges in** — it is the net payable:
```
totalAmount = Σ(quantity × unitPrice × (1 + gstRate)) + tcsAmount discountAmount
```
So payments, reports, and the advance-payment slider all operate on the true amount due. The single source of truth for this math is **`lib/po-money.ts`** (`computePoMoney` / `poNetPayable`), used by `createPo`, `updatePo`, import, both manager-edit actions (which **preserve** the PO's existing TCS/Discount when recomputing), the PO detail/forms, and the PDF/XLSX export.
### Environment Variables ### Environment Variables

View file

@ -3,6 +3,7 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { poNetPayable } from "@/lib/po-money";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
@ -49,9 +50,12 @@ export async function managerEditLineItems({
unitPrice: Number(li.unitPrice), unitPrice: Number(li.unitPrice),
})); }));
const newTotal = parsed.data.reduce( // Recompute the total from the edited line items, preserving the PO-level
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), // TCS / Discount charges (#133) so a manager line edit doesn't drop them.
0 const newTotal = poNetPayable(
parsed.data,
Number(po.tcsAmount ?? 0),
Number(po.discountAmount ?? 0)
); );
await db.purchaseOrder.update({ await db.purchaseOrder.update({

View file

@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { createPoSchema } from "@/lib/validations/po"; import { createPoSchema } from "@/lib/validations/po";
import { poNetPayable } from "@/lib/po-money";
import { parsePoTerms } from "@/lib/terms"; import { parsePoTerms } from "@/lib/terms";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@ -73,9 +74,11 @@ export async function managerEditPo(
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ } try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
const terms = parsePoTerms(termsRaw); const terms = parsePoTerms(termsRaw);
const newTotal = data.lineItems.reduce( // Preserve PO-level TCS / Discount charges (#133) when recomputing the total.
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), const newTotal = poNetPayable(
0 data.lineItems,
Number(po.tcsAmount ?? 0),
Number(po.discountAmount ?? 0)
); );
// Snapshot all original values for the audit trail // Snapshot all original values for the audit trail

View file

@ -3,6 +3,7 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { createPoSchema } from "@/lib/validations/po"; import { createPoSchema } from "@/lib/validations/po";
import { poNetPayable } from "@/lib/po-money";
import { parsePoTerms } from "@/lib/terms"; import { parsePoTerms } from "@/lib/terms";
import { notify } from "@/lib/notifier"; import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@ -64,6 +65,8 @@ export async function updatePo(
tcTransitInsurance: formData.get("tcTransitInsurance") || undefined, tcTransitInsurance: formData.get("tcTransitInsurance") || undefined,
tcPaymentTerms: formData.get("tcPaymentTerms") || undefined, tcPaymentTerms: formData.get("tcPaymentTerms") || undefined,
tcOthers: formData.get("tcOthers") || undefined, tcOthers: formData.get("tcOthers") || undefined,
tcsAmount: formData.get("tcsAmount") || undefined,
discountAmount: formData.get("discountAmount") || undefined,
lineItems: parseLineItems(formData), lineItems: parseLineItems(formData),
}); });
@ -77,10 +80,8 @@ export async function updatePo(
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ } try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
const terms = parsePoTerms(termsRaw); const terms = parsePoTerms(termsRaw);
const total = data.lineItems.reduce( // totalAmount = subtotal + GST + TCS Discount (#133)
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), const total = poNetPayable(data.lineItems, data.tcsAmount, data.discountAmount);
0
);
const isSubmit = intent === "submit" && po.status === "DRAFT"; const isSubmit = intent === "submit" && po.status === "DRAFT";
const isResubmit = intent === "resubmit" && po.status === "EDITS_REQUESTED"; const isResubmit = intent === "resubmit" && po.status === "EDITS_REQUESTED";
@ -164,6 +165,8 @@ export async function updatePo(
tcOthers: data.tcOthers ?? null, tcOthers: data.tcOthers ?? null,
terms, terms,
totalAmount: total, totalAmount: total,
tcsAmount: data.tcsAmount,
discountAmount: data.discountAmount,
status: shouldSubmit ? "MGR_REVIEW" : "DRAFT", status: shouldSubmit ? "MGR_REVIEW" : "DRAFT",
submittedAt: shouldSubmit ? new Date() : po.submittedAt, submittedAt: shouldSubmit ? new Date() : po.submittedAt,
lineItems: { lineItems: {

View file

@ -11,7 +11,9 @@ import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { ProjectCodeField } from "@/components/po/project-code-field"; import { ProjectCodeField } from "@/components/po/project-code-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor"; import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { TcsDiscountFields } from "@/components/po/tcs-discount-fields";
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard"; import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
import { computePoMoney } from "@/lib/po-money";
import type { CatalogueCategory, PoTerm } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
@ -34,8 +36,10 @@ type SerializedLineItem = {
accountId: string | null; accountId: string | null;
}; };
type PoWithItems = Omit<PurchaseOrder, "totalAmount"> & { type PoWithItems = Omit<PurchaseOrder, "totalAmount" | "tcsAmount" | "discountAmount"> & {
totalAmount: number; totalAmount: number;
tcsAmount: number;
discountAmount: number;
lineItems: SerializedLineItem[]; lineItems: SerializedLineItem[];
}; };
@ -72,6 +76,8 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts); const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? ""); const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
const [terms, setTerms] = useState<PoTerm[]>(initialTerms); const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
const [tcs, setTcs] = useState(po.tcsAmount ?? 0);
const [discount, setDiscount] = useState(po.discountAmount ?? 0);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const markDirty = () => setDirty(true); const markDirty = () => setDirty(true);
@ -85,6 +91,8 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const data = new FormData(form); const data = new FormData(form);
data.set("intent", intent); data.set("intent", intent);
data.set("termsJson", JSON.stringify(terms)); data.set("termsJson", JSON.stringify(terms));
data.set("tcsAmount", String(tcs));
data.set("discountAmount", String(discount));
lineItems.forEach((item, i) => { lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name); data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? ""); data.set(`lineItems[${i}].description`, item.description ?? "");
@ -259,6 +267,20 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
/> />
</section> </section>
{/* Charges (TCS & Discount, below GST) */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Charges</h2>
<TcsDiscountFields
base={computePoMoney(lineItems).inclGst}
currency={po.currency}
tcs={tcs}
discount={discount}
onTcsChange={(v) => { setTcs(v); markDirty(); }}
onDiscountChange={(v) => { setDiscount(v); markDirty(); }}
disabled={!!submitting}
/>
</section>
{/* Vendor */} {/* Vendor */}
<section className="rounded-lg border border-neutral-200 bg-white p-6"> <section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2> <h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>

View file

@ -62,6 +62,8 @@ export default async function EditPoPage({ params }: Props) {
const serializedPo = { const serializedPo = {
...po, ...po,
totalAmount: po.totalAmount.toNumber(), totalAmount: po.totalAmount.toNumber(),
tcsAmount: po.tcsAmount ? po.tcsAmount.toNumber() : 0,
discountAmount: po.discountAmount ? po.discountAmount.toNumber() : 0,
lineItems: po.lineItems.map((li) => ({ lineItems: po.lineItems.map((li) => ({
...li, ...li,
quantity: li.quantity.toNumber(), quantity: li.quantity.toNumber(),

View file

@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/permissions";
import { createPoSchema } from "@/lib/validations/po"; import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms"; import { parsePoTerms } from "@/lib/terms";
import { generatePoNumber } from "@/lib/po-number"; import { generatePoNumber } from "@/lib/po-number";
import { poNetPayable } from "@/lib/po-money";
import { notify } from "@/lib/notifier"; import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@ -70,6 +71,8 @@ export async function createPo(
tcTransitInsurance: formData.get("tcTransitInsurance") || undefined, tcTransitInsurance: formData.get("tcTransitInsurance") || undefined,
tcPaymentTerms: formData.get("tcPaymentTerms") || undefined, tcPaymentTerms: formData.get("tcPaymentTerms") || undefined,
tcOthers: formData.get("tcOthers") || undefined, tcOthers: formData.get("tcOthers") || undefined,
tcsAmount: formData.get("tcsAmount") || undefined,
discountAmount: formData.get("discountAmount") || undefined,
lineItems, lineItems,
}); });
@ -83,11 +86,8 @@ export async function createPo(
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ } try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
const terms = parsePoTerms(termsRaw); const terms = parsePoTerms(termsRaw);
// totalAmount = grand total including GST // totalAmount = subtotal + GST + TCS Discount (PO-level charges below GST, #133)
const total = data.lineItems.reduce( const total = poNetPayable(data.lineItems, data.tcsAmount, data.discountAmount);
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0
);
const po = await db.purchaseOrder.create({ const po = await db.purchaseOrder.create({
data: { data: {
@ -95,6 +95,8 @@ export async function createPo(
title: data.title, title: data.title,
status: intent === "submit" ? "SUBMITTED" : "DRAFT", status: intent === "submit" ? "SUBMITTED" : "DRAFT",
totalAmount: total, totalAmount: total,
tcsAmount: data.tcsAmount,
discountAmount: data.discountAmount,
currency: data.currency, currency: data.currency,
vesselId: data.vesselId, vesselId: data.vesselId,
accountId: data.accountId, accountId: data.accountId,

View file

@ -11,6 +11,8 @@ import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { ProjectCodeField } from "@/components/po/project-code-field"; import { ProjectCodeField } from "@/components/po/project-code-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor"; import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { TcsDiscountFields } from "@/components/po/tcs-discount-fields";
import { computePoMoney } from "@/lib/po-money";
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard"; import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
import type { CatalogueCategory, PoTerm } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents"; import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
@ -58,6 +60,8 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE] initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
); );
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [tcs, setTcs] = useState(0);
const [discount, setDiscount] = useState(0);
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null); const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [multiAccount, setMultiAccount] = useState(initialMultiAccount ?? false); const [multiAccount, setMultiAccount] = useState(initialMultiAccount ?? false);
@ -75,6 +79,8 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
const data = new FormData(form); const data = new FormData(form);
data.set("intent", intent); data.set("intent", intent);
data.set("termsJson", JSON.stringify(terms)); data.set("termsJson", JSON.stringify(terms));
data.set("tcsAmount", String(tcs));
data.set("discountAmount", String(discount));
lineItems.forEach((item, i) => { lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name); data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? ""); data.set(`lineItems[${i}].description`, item.description ?? "");
@ -247,6 +253,20 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
/> />
</section> </section>
{/* Charges (TCS & Discount, below GST) */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Charges</h2>
<TcsDiscountFields
base={computePoMoney(lineItems).inclGst}
currency="INR"
tcs={tcs}
discount={discount}
onTcsChange={(v) => { setTcs(v); markDirty(); }}
onDiscountChange={(v) => { setDiscount(v); markDirty(); }}
disabled={!!submitting}
/>
</section>
{/* Vendor */} {/* Vendor */}
<section className="rounded-lg border border-neutral-200 bg-white p-6"> <section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2> <h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>

View file

@ -136,7 +136,11 @@ export async function GET(request: NextRequest, { params }: Props) {
const totalTaxable = items.reduce((s, i) => s + i.taxable, 0); const totalTaxable = items.reduce((s, i) => s + i.taxable, 0);
const totalGst = items.reduce((s, i) => s + i.gstAmt, 0); const totalGst = items.reduce((s, i) => s + i.gstAmt, 0);
const grandTotal = totalTaxable + totalGst; const inclGst = totalTaxable + totalGst;
// PO-level charges shown below GST (#133). Net payable = incl-GST + TCS Discount.
const tcsAmount = Number(po.tcsAmount ?? 0);
const discountAmount = Number(po.discountAmount ?? 0);
const grandTotal = inclGst + tcsAmount - discountAmount;
const approvalAction = [...po.actions].reverse() const approvalAction = [...po.actions].reverse()
.find(a => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE"); .find(a => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE");
@ -436,31 +440,30 @@ export async function GET(request: NextRequest, { params }: Props) {
} }
// ══ Totals ════════════════════════════════════════════════════════════ // ══ Totals ════════════════════════════════════════════════════════════
// Rows: taxable, GST, optional TCS / Discount (#133), then GRAND TOTAL.
const TOT_ROW = HDR_ROW + 1 + BODY_ROWS + 1; const TOT_ROW = HDR_ROW + 1 + BODY_ROWS + 1;
ws.getRow(TOT_ROW).height = 14; const totalRows: Array<{ label: string; value: number; grand?: boolean }> = [
ws.getRow(TOT_ROW + 1).height = 14; { label: "Total taxable value", value: totalTaxable },
ws.getRow(TOT_ROW + 2).height = 16; { label: gstLabel, value: totalGst },
];
if (tcsAmount > 0) totalRows.push({ label: "TCS", value: tcsAmount });
if (discountAmount > 0) totalRows.push({ label: "Discount", value: -discountAmount });
totalRows.push({ label: "GRAND TOTAL", value: grandTotal, grand: true });
// "Total taxable value" totalRows.forEach((row, i) => {
sc(TOT_ROW, 6, "Total taxable value", { font: fBold, fill: fillTot, border: bordAll, align: alignR }); const r = TOT_ROW + i;
ws.mergeCells(`F${TOT_ROW}:G${TOT_ROW}`); ws.getRow(r).height = row.grand ? 16 : 14;
sc(TOT_ROW, 8, totalTaxable, { font: fBold, fill: fillTot, border: bordAll, align: alignR, numFmt: NUM_FMT }); const font = row.grand ? { ...fBold, size: 10 } : fBold;
ws.mergeCells(`H${TOT_ROW}:I${TOT_ROW}`); const fill = row.grand ? fillGT : fillTot;
sc(r, 6, row.label, { font, fill, border: bordAll, align: alignR });
// "GST" ws.mergeCells(`F${r}:G${r}`);
sc(TOT_ROW + 1, 6, gstLabel, { font: fBold, fill: fillTot, border: bordAll, align: alignR }); sc(r, 8, row.value, { font, fill, border: bordAll, align: alignR, numFmt: NUM_FMT });
ws.mergeCells(`F${TOT_ROW + 1}:G${TOT_ROW + 1}`); ws.mergeCells(`H${r}:I${r}`);
sc(TOT_ROW + 1, 8, totalGst, { font: fBold, fill: fillTot, border: bordAll, align: alignR, numFmt: NUM_FMT }); });
ws.mergeCells(`H${TOT_ROW + 1}:I${TOT_ROW + 1}`); const GT_ROW = TOT_ROW + totalRows.length - 1;
// "GRAND TOTAL"
sc(TOT_ROW + 2, 6, "GRAND TOTAL", { font: { ...fBold, size: 10 }, fill: fillGT, border: bordAll, align: alignR });
ws.mergeCells(`F${TOT_ROW + 2}:G${TOT_ROW + 2}`);
sc(TOT_ROW + 2, 8, grandTotal, { font: { ...fBold, size: 10 }, fill: fillGT, border: bordAll, align: alignR, numFmt: NUM_FMT });
ws.mergeCells(`H${TOT_ROW + 2}:I${TOT_ROW + 2}`);
// ══ Instructions ═════════════════════════════════════════════════════ // ══ Instructions ═════════════════════════════════════════════════════
const INST_ROW = TOT_ROW + 4; const INST_ROW = GT_ROW + 2;
ws.getRow(INST_ROW).height = 16; ws.getRow(INST_ROW).height = 16;
sc(INST_ROW, 1, "INSTRUCTIONS TO VENDORS", { font: { ...fBold, size: 10 }, fill: fillInst, border: bordAll, align: alignC }); sc(INST_ROW, 1, "INSTRUCTIONS TO VENDORS", { font: { ...fBold, size: 10 }, fill: fillInst, border: bordAll, align: alignC });
ws.mergeCells(`A${INST_ROW}:I${INST_ROW}`); ws.mergeCells(`A${INST_ROW}:I${INST_ROW}`);
@ -868,6 +871,14 @@ ${cleanPdf ? "" : `<div class="no-print" style="margin-bottom:8px">
<td class="tot-lbl">${gstLabel}</td> <td class="tot-lbl">${gstLabel}</td>
<td class="tot-val">${fmtNum(totalGst)}</td> <td class="tot-val">${fmtNum(totalGst)}</td>
</tr> </tr>
${tcsAmount > 0 ? `<tr>
<td class="tot-lbl">TCS</td>
<td class="tot-val">${fmtNum(tcsAmount)}</td>
</tr>` : ""}
${discountAmount > 0 ? `<tr>
<td class="tot-lbl">Discount</td>
<td class="tot-val">-${fmtNum(discountAmount)}</td>
</tr>` : ""}
<tr> <tr>
<td class="gt-lbl">GRAND TOTAL</td> <td class="gt-lbl">GRAND TOTAL</td>
<td class="gt-val">${fmtNum(grandTotal)}</td> <td class="gt-val">${fmtNum(grandTotal)}</td>

View file

@ -22,6 +22,8 @@ type PoWithRelations = {
title: string; title: string;
status: import("@prisma/client").POStatus; status: import("@prisma/client").POStatus;
totalAmount: import("@prisma/client").Prisma.Decimal; totalAmount: import("@prisma/client").Prisma.Decimal;
tcsAmount?: import("@prisma/client").Prisma.Decimal | null;
discountAmount?: import("@prisma/client").Prisma.Decimal | null;
currency: string; currency: string;
poDate: Date | null; poDate: Date | null;
projectCode: string | null; projectCode: string | null;
@ -154,6 +156,15 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
); );
const attachmentGroups = groupAttachments(docsWithUrls); const attachmentGroups = groupAttachments(docsWithUrls);
// PO-level charges shown below GST (#133). totalAmount already folds these in.
const tcsAmount = Number(po.tcsAmount ?? 0);
const discountAmount = Number(po.discountAmount ?? 0);
const hasCharges = tcsAmount > 0 || discountAmount > 0;
const itemsInclGst = po.lineItems.reduce(
(s, li) => s + Number(li.quantity) * Number(li.unitPrice) * (1 + Number(li.gstRate ?? 0.18)),
0
);
// Feature-flagged: the PO's submitter (or Accounts / Manager / SuperUser) may add // Feature-flagged: the PO's submitter (or Accounts / Manager / SuperUser) may add
// attachments after the fact, in any state except rejected/cancelled. Never in // attachments after the fact, in any state except rejected/cancelled. Never in
// readOnly. The server action re-checks this permission. // readOnly. The server action re-checks this permission.
@ -459,6 +470,30 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
originalItems={originalLineItems} originalItems={originalLineItems}
originalItemsLabel={lineItemsDiffLabel} originalItemsLabel={lineItemsDiffLabel}
/> />
{hasCharges && (
<dl className="mt-4 ml-auto w-full max-w-xs space-y-1 text-sm">
<div className="flex justify-between">
<dt className="text-neutral-500">Total (incl. GST)</dt>
<dd className="text-neutral-700">{formatCurrency(itemsInclGst, po.currency)}</dd>
</div>
{tcsAmount > 0 && (
<div className="flex justify-between">
<dt className="text-neutral-500">TCS</dt>
<dd className="text-neutral-700">+ {formatCurrency(tcsAmount, po.currency)}</dd>
</div>
)}
{discountAmount > 0 && (
<div className="flex justify-between">
<dt className="text-neutral-500">Discount</dt>
<dd className="text-neutral-700"> {formatCurrency(discountAmount, po.currency)}</dd>
</div>
)}
<div className="flex justify-between border-t border-neutral-100 pt-1 font-semibold text-neutral-900">
<dt>Net payable</dt>
<dd>{formatCurrency(Number(po.totalAmount), po.currency)}</dd>
</div>
</dl>
)}
</div> </div>
{/* Terms & Conditions (issue #11): dynamic snapshot when present, else legacy tc* + fixed line. */} {/* Terms & Conditions (issue #11): dynamic snapshot when present, else legacy tc* + fixed line. */}

View file

@ -0,0 +1,123 @@
"use client";
import { formatCurrency } from "@/lib/utils";
import { amountToPercent, percentToAmount } from "@/lib/po-money";
/**
* PO-level TCS and Discount charges, shown below GST (issue #133).
*
* Each charge is entered as an absolute rupee amount **or** as a percentage of the
* GST-inclusive line-items total (`base`) the two are bidirectionally linked.
* Only the absolute amount is stored (the parent persists `tcs` / `discount`); the
* percentage is a convenience. Discount is applied post-GST.
*/
interface Props {
base: number; // GST-inclusive line-items total — the % base
currency: string;
tcs: number;
discount: number;
onTcsChange: (amount: number) => void;
onDiscountChange: (amount: number) => void;
disabled?: boolean;
}
const INPUT =
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
function round2(n: number): number {
return Math.round((n + Number.EPSILON) * 100) / 100;
}
function ChargeRow({
label,
hint,
base,
value,
onChange,
disabled,
}: {
label: string;
hint: string;
base: number;
value: number;
onChange: (amount: number) => void;
disabled?: boolean;
}) {
const percent = round2(amountToPercent(value, base));
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto_auto] sm:items-end">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">{label}</label>
<p className="text-xs text-neutral-400">{hint}</p>
</div>
<div className="sm:w-40">
<label className="block text-xs text-neutral-500 mb-1">Amount ()</label>
<input
type="number"
min={0}
step="0.01"
value={value === 0 ? "" : value}
placeholder="0.00"
disabled={disabled}
onChange={(e) => onChange(Math.max(0, Number(e.target.value) || 0))}
className={INPUT}
/>
</div>
<div className="sm:w-32">
<label className="block text-xs text-neutral-500 mb-1">% of total</label>
<input
type="number"
min={0}
step="0.01"
value={percent === 0 ? "" : percent}
placeholder="0"
disabled={disabled || base === 0}
onChange={(e) => onChange(round2(percentToAmount(Number(e.target.value) || 0, base)))}
className={INPUT}
/>
</div>
</div>
);
}
export function TcsDiscountFields({
base,
currency,
tcs,
discount,
onTcsChange,
onDiscountChange,
disabled,
}: Props) {
const netPayable = base + (tcs || 0) - (discount || 0);
return (
<div className="space-y-5">
<p className="text-xs text-neutral-400">
Optional charges applied after GST. Enter a rupee amount or a percentage of the
GST-inclusive total they stay in sync. Stored as an absolute amount.
</p>
<ChargeRow
label="TCS"
hint="Tax Collected at Source, added to the total."
base={base}
value={tcs}
onChange={onTcsChange}
disabled={disabled}
/>
<ChargeRow
label="Discount"
hint="Applied post-GST, subtracted from the total."
base={base}
value={discount}
onChange={onDiscountChange}
disabled={disabled}
/>
<div className="flex items-center justify-between border-t border-neutral-100 pt-3 text-sm">
<span className="font-medium text-neutral-600">Net payable</span>
<span className="font-semibold text-neutral-900">{formatCurrency(netPayable, currency)}</span>
</div>
</div>
);
}

67
App/lib/po-money.ts Normal file
View file

@ -0,0 +1,67 @@
/**
* Single source of truth for PO money math (issue #133).
*
* A PO's value is built up as:
*
* taxable = Σ qty · unitPrice (ex-GST)
* gst = Σ qty · unitPrice · gstRate
* inclGst = taxable + gst (the line-items total)
* netPayable = inclGst + tcs discount (PO-level charges, below GST)
*
* `netPayable` is what is stored in `PurchaseOrder.totalAmount`, so payments,
* reports, and the advance-payment slider all operate on the true amount due.
*
* TCS and Discount are **absolute** rupee amounts (the UI's % control is only a
* convenience that writes back the rupee value). Discount is applied **post-GST**.
* The percentage shown for either is taken against `inclGst` (the GST-inclusive
* line-items total) see `amountToPercent` / `percentToAmount`.
*/
export interface MoneyItem {
quantity: number;
unitPrice: number;
gstRate?: number | null;
}
export const DEFAULT_GST_RATE = 0.18;
export interface PoMoney {
taxable: number;
gst: number;
inclGst: number;
tcs: number;
discount: number;
netPayable: number;
}
export function computePoMoney(
items: MoneyItem[],
tcs = 0,
discount = 0
): PoMoney {
const taxable = items.reduce((s, i) => s + i.quantity * i.unitPrice, 0);
const gst = items.reduce(
(s, i) => s + i.quantity * i.unitPrice * (i.gstRate ?? DEFAULT_GST_RATE),
0
);
const inclGst = taxable + gst;
const t = tcs || 0;
const d = discount || 0;
return { taxable, gst, inclGst, tcs: t, discount: d, netPayable: inclGst + t - d };
}
/** Net payable (PO totalAmount) for a set of line items plus PO-level charges. */
export function poNetPayable(items: MoneyItem[], tcs = 0, discount = 0): number {
return computePoMoney(items, tcs, discount).netPayable;
}
/** Convert an absolute charge to its percentage of a base (0 when base is 0). */
export function amountToPercent(amount: number, base: number): number {
if (!base) return 0;
return (amount / base) * 100;
}
/** Convert a percentage of a base back to an absolute charge. */
export function percentToAmount(percent: number, base: number): number {
return (percent / 100) * base;
}

View file

@ -48,6 +48,9 @@ export const createPoSchema = z.object({
tcTransitInsurance: z.string().optional(), tcTransitInsurance: z.string().optional(),
tcPaymentTerms: z.string().optional(), tcPaymentTerms: z.string().optional(),
tcOthers: z.string().optional(), tcOthers: z.string().optional(),
// PO-level charges, stored absolute (issue #133). Discount is applied post-GST.
tcsAmount: z.coerce.number().nonnegative("TCS cannot be negative").default(0),
discountAmount: z.coerce.number().nonnegative("Discount cannot be negative").default(0),
lineItems: z.array(lineItemSchema).min(1, "At least one line item is required"), lineItems: z.array(lineItemSchema).min(1, "At least one line item is required"),
}); });

View file

@ -0,0 +1,5 @@
-- Issue #133: PO-level TCS and Discount, shown below GST.
-- Absolute rupee amounts (nullable, default 0 so historical/imported POs read as 0).
-- totalAmount already includes these: subtotal + GST + tcsAmount - discountAmount.
ALTER TABLE "PurchaseOrder" ADD COLUMN "tcsAmount" DECIMAL(12,2) DEFAULT 0;
ALTER TABLE "PurchaseOrder" ADD COLUMN "discountAmount" DECIMAL(12,2) DEFAULT 0;

View file

@ -569,6 +569,13 @@ model PurchaseOrder {
status POStatus @default(DRAFT) status POStatus @default(DRAFT)
totalAmount Decimal @db.Decimal(12, 2) totalAmount Decimal @db.Decimal(12, 2)
currency String @default("INR") currency String @default("INR")
// PO-level charges shown below GST (issue #133). Stored as ABSOLUTE rupee
// amounts — the UI offers a % control bidirectionally linked to the rupee value
// for convenience, but only the absolute amount is persisted. Discount is applied
// post-GST. Nullable + default 0 so historical/imported POs read as 0.
// NOTE: totalAmount already folds these in: subtotal + GST + tcsAmount discountAmount.
tcsAmount Decimal? @default(0) @db.Decimal(12, 2)
discountAmount Decimal? @default(0) @db.Decimal(12, 2)
dateRequired DateTime? dateRequired DateTime?
projectCode String? projectCode String?
managerNote String? managerNote String?

View file

@ -0,0 +1,115 @@
/**
* Integration test for PO-level TCS & Discount (issue #133).
* Verifies totalAmount folds in the charges (subtotal + GST + TCS Discount),
* the absolute amounts are persisted, edits update them, and a manager line edit
* preserves them.
*/
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import type { Role } from "@prisma/client";
import { createPo } from "@/app/(portal)/po/new/actions";
import { updatePo } from "@/app/(portal)/po/[id]/edit/actions";
import { managerEditLineItems } from "@/app/(portal)/approvals/[id]/manager-line-edit-actions";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, makePoForm, deletePosByTitle } from "./helpers";
const PREFIX = "INTTEST_TCSDISC_";
let techId: string;
let managerId: string;
let vesselId: string;
let accountId: string;
beforeAll(async () => {
const [tech, manager, vessel, account] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("manager@pelagia.local"),
getSeedVessel("MV Pelagia Star"),
getSeedAccount("700201"),
]);
techId = tech.id;
managerId = manager.id;
vesselId = vessel.id;
accountId = account.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
vi.clearAllMocks();
});
function as(userId: string, role: Role) {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
}
// One line item: 10 × ₹100 @ 18% GST ⇒ taxable 1000, GST 180, incl-GST 1180.
function form(title: string, intent: string, tcs: number, discount: number) {
const f = makePoForm({
title, vesselId, accountId, intent: "draft",
lineItems: [{ description: "Item", quantity: 10, unit: "pc", unitPrice: 100, gstRate: 0.18 }],
});
f.set("intent", intent); // create: draft/submit · edit: save
f.set("tcsAmount", String(tcs));
f.set("discountAmount", String(discount));
return f;
}
describe("PO TCS & Discount", () => {
it("folds TCS and Discount into totalAmount and stores the absolute amounts", async () => {
as(techId, "TECHNICAL");
const result = await createPo(form(`${PREFIX}Create`, "draft", 118, 100));
expect(result).not.toHaveProperty("error");
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: (result as { id: string }).id } });
expect(Number(po.tcsAmount)).toBeCloseTo(118, 2);
expect(Number(po.discountAmount)).toBeCloseTo(100, 2);
expect(Number(po.totalAmount)).toBeCloseTo(1180 + 118 - 100, 2); // 1198
});
it("defaults to zero charges ⇒ totalAmount is just incl-GST", async () => {
as(techId, "TECHNICAL");
const result = await createPo(form(`${PREFIX}Zero`, "draft", 0, 0));
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: (result as { id: string }).id } });
expect(Number(po.totalAmount)).toBeCloseTo(1180, 2);
expect(Number(po.tcsAmount)).toBe(0);
expect(Number(po.discountAmount)).toBe(0);
});
it("edit updates the charges and recomputes the total", async () => {
as(techId, "TECHNICAL");
const created = await createPo(form(`${PREFIX}Edit`, "draft", 0, 0));
const poId = (created as { id: string }).id;
as(techId, "TECHNICAL");
const edited = await updatePo(poId, form(`${PREFIX}Edit`, "save", 50, 30));
expect(edited).not.toHaveProperty("error");
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
expect(Number(po.tcsAmount)).toBeCloseTo(50, 2);
expect(Number(po.discountAmount)).toBeCloseTo(30, 2);
expect(Number(po.totalAmount)).toBeCloseTo(1180 + 50 - 30, 2); // 1200
});
it("a manager line edit preserves the PO's TCS & Discount", async () => {
as(techId, "TECHNICAL");
const created = await createPo(form(`${PREFIX}MgrEdit`, "submit", 118, 100)); // ⇒ MGR_REVIEW
const poId = (created as { id: string }).id;
as(managerId, "MANAGER");
const res = await managerEditLineItems({
poId,
// Double the quantity: incl-GST becomes 20 × 100 × 1.18 = 2360.
lineItems: [{ name: "Item", quantity: 20, unit: "pc", unitPrice: 100 }],
});
expect(res).not.toHaveProperty("error");
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
expect(Number(po.tcsAmount)).toBeCloseTo(118, 2);
expect(Number(po.discountAmount)).toBeCloseTo(100, 2);
expect(Number(po.totalAmount)).toBeCloseTo(2360 + 118 - 100, 2); // 2378
});
});

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
computePoMoney,
poNetPayable,
amountToPercent,
percentToAmount,
} from "@/lib/po-money";
const items = [
{ quantity: 10, unitPrice: 100, gstRate: 0.18 }, // taxable 1000, gst 180
{ quantity: 2, unitPrice: 50, gstRate: 0.05 }, // taxable 100, gst 5
];
describe("computePoMoney", () => {
it("breaks down taxable, GST and incl-GST", () => {
const m = computePoMoney(items);
expect(m.taxable).toBe(1100);
expect(m.gst).toBeCloseTo(185, 5);
expect(m.inclGst).toBeCloseTo(1285, 5);
expect(m.netPayable).toBeCloseTo(1285, 5); // no charges ⇒ equals inclGst
});
it("adds TCS and subtracts Discount post-GST", () => {
const m = computePoMoney(items, 50, 85);
expect(m.tcs).toBe(50);
expect(m.discount).toBe(85);
expect(m.netPayable).toBeCloseTo(1285 + 50 - 85, 5);
});
it("defaults a missing gstRate to 18%", () => {
expect(computePoMoney([{ quantity: 1, unitPrice: 100 }]).gst).toBeCloseTo(18, 5);
});
it("treats nullish charges as zero", () => {
const m = computePoMoney(items, undefined, undefined);
expect(m.netPayable).toBeCloseTo(1285, 5);
});
});
describe("poNetPayable", () => {
it("is incl-GST + TCS Discount", () => {
expect(poNetPayable(items, 100, 200)).toBeCloseTo(1285 + 100 - 200, 5);
});
});
describe("percent ↔ amount conversion", () => {
it("round-trips against a base", () => {
expect(amountToPercent(128.5, 1285)).toBeCloseTo(10, 5);
expect(percentToAmount(10, 1285)).toBeCloseTo(128.5, 5);
});
it("is zero-safe when the base is zero", () => {
expect(amountToPercent(50, 0)).toBe(0);
expect(percentToAmount(10, 0)).toBe(0);
});
});