Merge pull request 'feat(po): TCS & Discount below GST (#133)' (#149) from claude/issue-133-tcs-discount into master
Reviewed-on: #149
This commit is contained in:
commit
98eeb64045
17 changed files with 527 additions and 39 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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. */}
|
||||||
|
|
|
||||||
123
App/components/po/tcs-discount-fields.tsx
Normal file
123
App/components/po/tcs-discount-fields.tsx
Normal 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
67
App/lib/po-money.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
115
App/tests/integration/po-tcs-discount.test.ts
Normal file
115
App/tests/integration/po-tcs-discount.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
56
App/tests/unit/po-money.test.ts
Normal file
56
App/tests/unit/po-money.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue