Compare commits

..

No commits in common. "master" and "claude/flamboyant-gagarin-370922" have entirely different histories.

28 changed files with 143 additions and 1063 deletions

View file

@ -78,9 +78,8 @@ FORGEJO_TOKEN=
# Let submitters (TECHNICAL/MANNING) read & export every PO and open the History
# page (read-only). Opt-in — on only when exactly "true".
# NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true
# Let a PO's own submitter (plus Accounts/Manager/SuperUser) add attachments to it
# in any state except rejected/cancelled — remediation for POs whose uploads were
# lost to the document-upload bug, and the general "attach after the fact" affordance.
# Let a CLOSED PO's own submitter (plus Accounts/Manager/SuperUser) add attachments
# to it — remediation for POs whose uploads were lost to the document-upload bug.
# Opt-in — on only when exactly "true".
# NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true

View file

@ -267,17 +267,7 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
### GST Calculation
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.
`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`.
### Environment Variables
@ -305,7 +295,7 @@ APP_INTERNAL_URL # Base URL PdfService reaches the app at (falls back to
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only)
NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)
NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED # Opt-in ("true"): a PO's own submitter + Accounts/Manager/SuperUser may add attachments in any state except rejected/cancelled (upload-bug remediation + general "attach after the fact"). Off by default.
NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED # Opt-in ("true"): a CLOSED PO's own submitter + Accounts/Manager/SuperUser may add attachments (remediation for the upload bug). Off by default.
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
```

View file

@ -3,7 +3,6 @@
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";
@ -50,12 +49,9 @@ export async function managerEditLineItems({
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)
const newTotal = parsed.data.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0
);
await db.purchaseOrder.update({

View file

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

View file

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

View file

@ -11,9 +11,7 @@ import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { ProjectCodeField } from "@/components/po/project-code-field";
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 { computePoMoney } from "@/lib/po-money";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po";
@ -36,10 +34,8 @@ type SerializedLineItem = {
accountId: string | null;
};
type PoWithItems = Omit<PurchaseOrder, "totalAmount" | "tcsAmount" | "discountAmount"> & {
type PoWithItems = Omit<PurchaseOrder, "totalAmount"> & {
totalAmount: number;
tcsAmount: number;
discountAmount: number;
lineItems: SerializedLineItem[];
};
@ -76,8 +72,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
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 markDirty = () => setDirty(true);
@ -91,8 +85,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const data = new FormData(form);
data.set("intent", intent);
data.set("termsJson", JSON.stringify(terms));
data.set("tcsAmount", String(tcs));
data.set("discountAmount", String(discount));
lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? "");
@ -267,20 +259,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
/>
</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 */}
<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>

View file

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

View file

@ -6,7 +6,6 @@ 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";
@ -71,8 +70,6 @@ export async function createPo(
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,
});
@ -86,8 +83,11 @@ export async function createPo(
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);
// totalAmount = grand total including GST
const total = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0
);
const po = await db.purchaseOrder.create({
data: {
@ -95,8 +95,6 @@ export async function createPo(
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,

View file

@ -11,8 +11,6 @@ import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { ProjectCodeField } from "@/components/po/project-code-field";
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 type { CatalogueCategory, PoTerm } from "@/lib/terms";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
@ -40,35 +38,19 @@ interface Props {
initialVendorId?: string;
initialVesselId?: string;
initialCompanyId?: string;
// Duplicate-PO prefill (issue #142) — copy editable order fields onto a new draft.
initialTitle?: string;
initialAccountId?: string;
initialMultiAccount?: boolean;
initialProjectCode?: string | null;
initialPlaceOfDelivery?: string | null;
initialDateRequired?: string;
initialPiQuotationNo?: string;
initialPiQuotationDate?: string;
initialRequisitionNo?: string;
initialRequisitionDate?: string;
initialTerms?: PoTerm[];
}
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId, initialTitle, initialAccountId, initialMultiAccount, initialProjectCode, initialPlaceOfDelivery, initialDateRequired, initialPiQuotationNo, initialPiQuotationDate, initialRequisitionNo, initialRequisitionDate, initialTerms }: Props) {
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
);
const [files, setFiles] = useState<File[]>([]);
const [tcs, setTcs] = useState(0);
const [discount, setDiscount] = useState(0);
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
const [error, setError] = useState("");
const [multiAccount, setMultiAccount] = useState(initialMultiAccount ?? false);
const [defaultAccountId, setDefaultAccountId] = useState(initialAccountId ?? "");
const [terms, setTerms] = useState<PoTerm[]>(
initialTerms && initialTerms.length > 0 ? initialTerms : defaultTerms
);
const [multiAccount, setMultiAccount] = useState(false);
const [defaultAccountId, setDefaultAccountId] = useState("");
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
const [dirty, setDirty] = useState(false);
const markDirty = () => setDirty(true);
@ -79,8 +61,6 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
const data = new FormData(form);
data.set("intent", intent);
data.set("termsJson", JSON.stringify(terms));
data.set("tcsAmount", String(tcs));
data.set("discountAmount", String(discount));
lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? "");
@ -134,7 +114,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Title <span className="text-danger">*</span>
</label>
<input name="title" required defaultValue={initialTitle ?? ""} className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
<input name="title" required className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
</div>
{/* Cost Centre — vessels only */}
@ -183,7 +163,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
<ProjectCodeField options={projectCodeOptions} current={initialProjectCode} className={INPUT_CLS} />
<ProjectCodeField options={projectCodeOptions} className={INPUT_CLS} />
{projectCodeOptions.length === 0 && (
<p className="mt-1.5 text-xs text-neutral-500">
No project codes configured yet a Manager can add them under Administration Project Codes.
@ -192,7 +172,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
<input name="dateRequired" type="date" defaultValue={initialDateRequired ?? ""} className={INPUT_CLS} />
<input name="dateRequired" type="date" className={INPUT_CLS} />
</div>
</div>
</section>
@ -203,11 +183,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation No.</label>
<input name="piQuotationNo" defaultValue={initialPiQuotationNo ?? ""} className={INPUT_CLS} placeholder='e.g. Verbal, INV-001' />
<input name="piQuotationNo" className={INPUT_CLS} placeholder='e.g. Verbal, INV-001' />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation Date</label>
<input name="piQuotationDate" type="date" defaultValue={initialPiQuotationDate ?? ""} className={INPUT_CLS} />
<input name="piQuotationDate" type="date" className={INPUT_CLS} />
</div>
</div>
</section>
@ -218,11 +198,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Cost Centre / Office Requisition No.</label>
<input name="requisitionNo" defaultValue={initialRequisitionNo ?? ""} className={INPUT_CLS} placeholder="Optional" />
<input name="requisitionNo" className={INPUT_CLS} placeholder="Optional" />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Requisition Date</label>
<input name="requisitionDate" type="date" defaultValue={initialRequisitionDate ?? ""} className={INPUT_CLS} />
<input name="requisitionDate" type="date" className={INPUT_CLS} />
</div>
</div>
</section>
@ -232,7 +212,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
<DeliveryLocationField options={deliveryOptions} current={initialPlaceOfDelivery} className={INPUT_CLS} />
<DeliveryLocationField options={deliveryOptions} className={INPUT_CLS} />
{deliveryOptions.length === 0 && (
<p className="mt-1.5 text-xs text-neutral-500">
No delivery locations configured yet a Manager can add them under Administration Delivery Locations.
@ -253,20 +233,6 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
/>
</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 */}
<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>

View file

@ -6,7 +6,6 @@ import { NewPoForm } from "./new-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
import { buildDuplicatePrefill, type DuplicatePrefill } from "@/lib/duplicate-po";
import type { Metadata } from "next";
import type { LineItemInput } from "@/lib/validations/po";
import type { CartItem } from "@/lib/cart";
@ -14,7 +13,7 @@ import type { CartItem } from "@/lib/cart";
export const metadata: Metadata = { title: "New Purchase Order" };
interface Props {
searchParams: Promise<{ cart?: string; vesselId?: string; duplicate?: string }>;
searchParams: Promise<{ cart?: string; vesselId?: string }>;
}
export default async function NewPoPage({ searchParams }: Props) {
@ -23,23 +22,11 @@ export default async function NewPoPage({ searchParams }: Props) {
if (!hasPermission(session.user.role, "create_po")) redirect("/dashboard");
const { cart, vesselId, duplicate } = await searchParams;
const { cart, vesselId: initialVesselId } = await searchParams;
// Duplicate-PO prefill (issue #142): copy a source PO's editable order fields
// onto a fresh draft. Nothing is written until the user saves/submits — same
// shape as the cart→new-PO prefill below, just a richer field set.
let dup: DuplicatePrefill | null = null;
let initialLineItems: LineItemInput[] | undefined;
let initialVendorId: string | undefined;
let initialVesselId: string | undefined = vesselId;
if (duplicate) {
const source = await db.purchaseOrder.findUnique({
where: { id: duplicate },
include: { lineItems: { orderBy: { sortOrder: "asc" } } },
});
if (source) dup = buildDuplicatePrefill(source);
} else if (cart) {
if (cart) {
try {
const cartItems: CartItem[] = JSON.parse(decodeURIComponent(cart));
if (Array.isArray(cartItems) && cartItems.length > 0) {
@ -96,21 +83,9 @@ export default async function NewPoPage({ searchParams }: Props) {
projectCodeOptions={projectCodeOptions}
termsCatalogue={termsCatalogue}
defaultTerms={defaultTerms}
initialLineItems={dup?.initialLineItems ?? initialLineItems}
initialVendorId={dup?.initialVendorId ?? initialVendorId}
initialVesselId={dup?.initialVesselId ?? initialVesselId}
initialCompanyId={dup?.initialCompanyId}
initialTitle={dup?.initialTitle}
initialAccountId={dup?.initialAccountId}
initialMultiAccount={dup?.initialMultiAccount}
initialProjectCode={dup?.initialProjectCode}
initialPlaceOfDelivery={dup?.initialPlaceOfDelivery}
initialDateRequired={dup?.initialDateRequired}
initialPiQuotationNo={dup?.initialPiQuotationNo}
initialPiQuotationDate={dup?.initialPiQuotationDate}
initialRequisitionNo={dup?.initialRequisitionNo}
initialRequisitionDate={dup?.initialRequisitionDate}
initialTerms={dup?.initialTerms}
initialLineItems={initialLineItems}
initialVendorId={initialVendorId}
initialVesselId={initialVesselId}
/>
</div>
);

View file

@ -3,8 +3,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { canAddPoAttachment } from "@/lib/permissions";
import { CLOSED_PO_ATTACHMENTS_ENABLED } from "@/lib/feature-flags";
import { canAddClosedPoAttachment } from "@/lib/permissions";
import { revalidatePath } from "next/cache";
// Matches the FileUploader hint ("up to 10 MB each") and
@ -44,25 +43,16 @@ export async function uploadPoDocuments(
});
if (!po) return { error: "PO not found" };
// A voided PO never accepts attachments, regardless of the flag.
if (po.status === "REJECTED" || po.status === "CANCELLED") {
return { error: "Attachments can't be added to a rejected or cancelled purchase order." };
}
if (CLOSED_PO_ATTACHMENTS_ENABLED) {
// Feature on: only the PO's submitter + Accounts / Manager / SuperUser may
// attach, in any non-voided state. The normal create / receipt flows are run
// by exactly those actors, so they keep working.
const allowed = canAddPoAttachment(session.user.role, po.status, {
// A CLOSED PO is otherwise immutable; attaching to one is only allowed via the
// feature-flagged remediation path (canAddClosedPoAttachment). The normal create
// and receipt flows upload while the PO is pre-CLOSED, so they're unaffected.
if (po.status === "CLOSED") {
const allowed = canAddClosedPoAttachment(session.user.role, {
isSubmitter: po.submitterId === session.user.id,
});
if (!allowed) {
return { error: "Adding attachments to this purchase order isn't allowed." };
return { error: "Adding attachments to a closed purchase order isn't allowed." };
}
} else if (po.status === "CLOSED") {
// Feature off: a closed PO stays immutable (legacy behaviour). The create /
// receipt flows upload while the PO is pre-CLOSED, so they're unaffected.
return { error: "Adding attachments to a closed purchase order isn't allowed." };
}
for (const file of files) {

View file

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

View file

@ -6,12 +6,12 @@ import { FileUploader } from "@/components/po/file-uploader";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
/**
* Feature-flagged uploader shown on a PO's detail page so its submitter (or
* Accounts / Manager / SuperUser) can add documents after the fact in any state
* except rejected/cancelled. Gating is decided server-side in po-detail.tsx; the
* server action re-checks the permission, so this component is only the UI.
* Feature-flagged uploader shown on a CLOSED PO's detail page so its submitter (or
* Accounts / Manager / SuperUser) can attach documents that were lost to the upload
* bug. Gating is decided server-side in po-detail.tsx; the server action re-checks
* the permission, so this component is only the UI.
*/
export function PoAttachmentUploader({ poId }: { poId: string }) {
export function ClosedPoAttachmentUploader({ poId }: { poId: string }) {
const router = useRouter();
const [files, setFiles] = useState<File[]>([]);
const [busy, setBusy] = useState(false);
@ -36,7 +36,7 @@ export function PoAttachmentUploader({ poId }: { poId: string }) {
<div className="mt-5 border-t border-neutral-100 pt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Add attachments</p>
<p className="mt-0.5 text-xs text-neutral-400">
Attach any documents that are missing from this purchase order.
This purchase order is closed. Attach any documents that are missing.
</p>
<div className="mt-3">
<FileUploader files={files} onChange={setFiles} disabled={busy} />

View file

@ -5,14 +5,13 @@ import { DiscardDraftButton } from "@/components/po/discard-draft-button";
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
import { EmailVendorButton } from "@/components/po/email-vendor-button";
import { PoAttachmentUploader } from "@/components/po/po-attachment-uploader";
import { ClosedPoAttachmentUploader } from "@/components/po/closed-po-attachment-uploader";
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage";
import { groupAttachments } from "@/lib/attachments";
import { canAddPoAttachment, hasPermission } from "@/lib/permissions";
import { canAddClosedPoAttachment } from "@/lib/permissions";
import { TC_FIXED_LINE } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { actionLabel } from "@/lib/po-activity";
import type { LineItemInput } from "@/lib/validations/po";
import type { Role } from "@prisma/client";
@ -22,8 +21,6 @@ type PoWithRelations = {
title: string;
status: import("@prisma/client").POStatus;
totalAmount: import("@prisma/client").Prisma.Decimal;
tcsAmount?: import("@prisma/client").Prisma.Decimal | null;
discountAmount?: import("@prisma/client").Prisma.Decimal | null;
currency: string;
poDate: Date | null;
projectCode: string | null;
@ -92,6 +89,26 @@ interface Props {
vendorEmail?: string | null;
}
const ACTION_LABELS: Record<string, string> = {
CREATED: "Created",
SUBMITTED: "Submitted for review",
APPROVED: "Approved",
APPROVED_WITH_NOTE: "Approved with note",
REJECTED: "Rejected",
EDITS_REQUESTED: "Edits requested",
VENDOR_ID_REQUESTED: "Vendor ID requested",
VENDOR_ID_PROVIDED: "Vendor ID provided",
PAYMENT_SENT: "Payment confirmed",
PARTIAL_PAYMENT_CONFIRMED: "Partial payment confirmed",
RECEIPT_CONFIRMED: "Receipt confirmed",
PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed",
CLOSED: "Closed",
MANAGER_LINE_EDIT: "Manager amended line items",
PRODUCT_PRICE_UPDATED: "Product prices updated",
CANCELLED: "Cancelled",
SUPERSEDED: "Superseded",
};
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false, vendorEmail = null }: Props) {
const lineItemsForEditor = po.lineItems.map((li) => ({
name: li.name,
@ -156,21 +173,12 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
);
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
// attachments after the fact, in any state except rejected/cancelled. Never in
// readOnly. The server action re-checks this permission.
const canAddAttachment =
// Feature-flagged remediation: a closed PO's submitter (or Accounts / Manager /
// SuperUser) may attach documents that the upload bug dropped. Never in readOnly.
const canAddClosedAttachment =
!readOnly &&
canAddPoAttachment(currentRole, po.status, { isSubmitter: po.submitter.id === currentUserId });
po.status === "CLOSED" &&
canAddClosedPoAttachment(currentRole, { isSubmitter: po.submitter.id === currentUserId });
const canConfirmReceipt =
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") &&
@ -217,16 +225,6 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
!readOnly && (
<DiscardDraftButton poId={po.id} />
)}
{/* Duplicate anyone who can create POs (issue #142). Opens a new PO
form prefilled from this PO; nothing is written until they save. */}
{!readOnly && hasPermission(currentRole, "create_po") && (
<Link
href={`/po/new?duplicate=${po.id}`}
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Duplicate
</Link>
)}
{/* Export buttons — available once approved, and for cancelled POs (watermarked) */}
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<>
<a
@ -470,30 +468,6 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
originalItems={originalLineItems}
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>
{/* Terms & Conditions (issue #11): dynamic snapshot when present, else legacy tc* + fixed line. */}
@ -533,7 +507,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
})()}
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
{(attachmentGroups.length > 0 || canAddAttachment) && (
{(attachmentGroups.length > 0 || canAddClosedAttachment) && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Attachments</h3>
{attachmentGroups.length === 0 && (
@ -569,7 +543,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
</div>
))}
</div>
{canAddAttachment && <PoAttachmentUploader poId={po.id} />}
{canAddClosedAttachment && <ClosedPoAttachmentUploader poId={po.id} />}
</div>
)}
@ -616,7 +590,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
<div className="absolute -left-1.5 mt-1.5 h-3 w-3 rounded-full border-2 border-white bg-neutral-400" />
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium text-neutral-900">
{actionLabel(action, po.currency)}
{ACTION_LABELS[action.actionType] ?? action.actionType}
</span>
<span className="text-xs text-neutral-400">by {action.actor.name}</span>
<span className="text-xs text-neutral-400 ml-auto">{formatDateTime(action.createdAt)}</span>

View file

@ -1,123 +0,0 @@
"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>
);
}

View file

@ -1,105 +0,0 @@
/**
* Duplicate-PO prefill (issue #142) map a source PurchaseOrder onto the
* initial-value props the New PO form consumes. Pure (no DB / no I/O) so the
* mapping is unit-testable; the page just fetches the PO and hands it here.
*
* Nothing is written: a duplicate is only a prefilled draft. Attachments,
* status/dates, payment data and audit history are intentionally NOT copied
* a duplicate starts as a clean draft of the editable order fields.
*/
import { parsePoTerms, legacyPoTerms } from "@/lib/terms";
import type { PoTerm } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po";
type DecimalLike = { toNumber: () => number } | number | null | undefined;
const num = (v: DecimalLike, fallback = 0): number =>
v == null ? fallback : typeof v === "number" ? v : v.toNumber();
export type DuplicateSourceLineItem = {
name: string;
description?: string | null;
quantity: DecimalLike;
unit: string;
size?: string | null;
unitPrice: DecimalLike;
gstRate?: DecimalLike;
productId?: string | null;
accountId?: string | null;
};
export type DuplicateSourcePo = {
title: string;
vesselId: string;
accountId: string;
companyId?: string | null;
vendorId?: string | null;
projectCode?: string | null;
placeOfDelivery?: string | null;
dateRequired?: Date | null;
piQuotationNo?: string | null;
piQuotationDate?: Date | null;
requisitionNo?: string | null;
requisitionDate?: Date | null;
terms?: unknown;
tcDelivery?: string | null;
tcDispatch?: string | null;
tcInspection?: string | null;
tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null;
tcOthers?: string | null;
lineItems: DuplicateSourceLineItem[];
};
export type DuplicatePrefill = {
initialLineItems: LineItemInput[];
initialMultiAccount: boolean;
initialVendorId?: string;
initialVesselId: string;
initialCompanyId?: string;
initialTitle: string;
initialAccountId: string;
initialProjectCode: string | null;
initialPlaceOfDelivery: string | null;
initialDateRequired?: string;
initialPiQuotationNo?: string;
initialPiQuotationDate?: string;
initialRequisitionNo?: string;
initialRequisitionDate?: string;
initialTerms: PoTerm[];
};
/** Format a Date to a `yyyy-MM-dd` value for a native date input. */
export const toDateInputValue = (d: Date | null | undefined): string | undefined =>
d ? new Date(d).toISOString().split("T")[0] : undefined;
export function buildDuplicatePrefill(source: DuplicateSourcePo): DuplicatePrefill {
const savedTerms = parsePoTerms(source.terms);
return {
initialLineItems: source.lineItems.map((li) => ({
name: li.name,
description: li.description ?? "",
quantity: num(li.quantity, 1),
unit: li.unit,
size: li.size ?? "",
unitPrice: num(li.unitPrice, 0),
gstRate: li.gstRate != null ? num(li.gstRate, 0.18) : 0.18,
productId: li.productId ?? undefined,
accountId: li.accountId ?? undefined,
})),
initialMultiAccount: source.lineItems.some((li) => !!li.accountId),
initialVendorId: source.vendorId ?? undefined,
initialVesselId: source.vesselId,
initialCompanyId: source.companyId ?? undefined,
initialTitle: source.title,
initialAccountId: source.accountId,
initialProjectCode: source.projectCode ?? null,
initialPlaceOfDelivery: source.placeOfDelivery ?? null,
initialDateRequired: toDateInputValue(source.dateRequired),
initialPiQuotationNo: source.piQuotationNo ?? undefined,
initialPiQuotationDate: toDateInputValue(source.piQuotationDate),
initialRequisitionNo: source.requisitionNo ?? undefined,
initialRequisitionDate: toDateInputValue(source.requisitionDate),
initialTerms: savedTerms.length > 0 ? savedTerms : legacyPoTerms(source),
};
}

View file

@ -16,12 +16,11 @@
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
* and wiki Crewing-Implementation-Spec.
*
* NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true lets a PO's own submitter, plus
* Accounts / Manager / SuperUser, add attachments to it in any state EXCEPT
* rejected/cancelled. Remediation path for the upload bug where documents never persisted
* (no PODocument row), and the general "attach a document after the fact" affordance.
* Opt-in (off unless "true") so production is unchanged until enabled.
* See lib/permissions.ts (canAddPoAttachment).
* NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true lets a CLOSED PO's own submitter, plus
* Accounts / Manager / SuperUser, add attachments to it. Remediation path for the upload
* bug where documents never persisted (no PODocument row): closed POs whose files were lost
* can be fixed without reopening them. Opt-in (off unless "true") so production is unchanged
* until enabled. See lib/permissions.ts (canAddClosedPoAttachment).
*/
export const INVENTORY_ENABLED =

View file

@ -1,4 +1,4 @@
import type { Role, POStatus } from "@prisma/client";
import type { Role } from "@prisma/client";
import { SUBMITTER_VIEW_ALL_ENABLED, CLOSED_PO_ATTACHMENTS_ENABLED } from "./feature-flags";
export type Permission =
@ -279,30 +279,23 @@ export function canViewAllPos(role: Role): boolean {
return hasPermission(role, "view_all_pos") || submitterCanViewAll(role);
}
// ── PO attachments (feature-flagged) ──────────────────────────────────────────
// Roles that may attach to a PO (besides the PO's own submitter, who is always
// allowed) when NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true.
const PO_ATTACHMENT_ROLES: Role[] = ["ACCOUNTS", "MANAGER", "SUPERUSER"];
// A PO in one of these terminal/voided states never accepts new attachments.
const NO_ATTACHMENT_STATUSES: POStatus[] = ["REJECTED", "CANCELLED"];
// ── Closed-PO attachments (feature-flagged remediation) ───────────────────────
// Roles that may attach to ANY closed PO (the PO's own submitter is allowed too,
// regardless of role) when NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true.
const CLOSED_PO_ATTACHMENT_ROLES: Role[] = ["ACCOUNTS", "MANAGER", "SUPERUSER"];
/**
* Feature-flagged: whether the current user may add attachments to a PO.
* Feature-flagged: whether the current user may add attachments to a CLOSED PO.
* This is the remediation path for the upload bug where documents never persisted
* closed POs that lost their files can be re-attached without reopening them.
*
* When `NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED` is on, the PO's own submitter
* plus Accounts / Manager / SuperUser may attach documents to a PO in **any
* state except `REJECTED` / `CANCELLED`** (a voided PO is never editable). This
* is the remediation path for the upload bug where documents never persisted, and
* the general "add a document after the fact" affordance. Off by default no
* post-hoc attachment UI and closed POs stay immutable.
* Allowed (only when the flag is on) for the PO's own submitter, or for
* Accounts / Manager / SuperUser. Off by default closed POs stay immutable.
*/
export function canAddPoAttachment(
export function canAddClosedPoAttachment(
role: Role,
status: POStatus,
opts: { isSubmitter: boolean }
): boolean {
if (!CLOSED_PO_ATTACHMENTS_ENABLED) return false;
if (NO_ATTACHMENT_STATUSES.includes(status)) return false;
return opts.isSubmitter || PO_ATTACHMENT_ROLES.includes(role);
return opts.isSubmitter || CLOSED_PO_ATTACHMENT_ROLES.includes(role);
}

View file

@ -1,40 +0,0 @@
import type { Prisma } from "@prisma/client";
import { formatCurrency } from "@/lib/utils";
// Human-readable labels for each POAction type, shown in the PO Activity timeline.
export const ACTION_LABELS: Record<string, string> = {
CREATED: "Created",
SUBMITTED: "Submitted for review",
APPROVED: "Approved",
APPROVED_WITH_NOTE: "Approved with note",
REJECTED: "Rejected",
EDITS_REQUESTED: "Edits requested",
VENDOR_ID_REQUESTED: "Vendor ID requested",
VENDOR_ID_PROVIDED: "Vendor ID provided",
PAYMENT_SENT: "Payment confirmed",
PARTIAL_PAYMENT_CONFIRMED: "Partial payment confirmed",
RECEIPT_CONFIRMED: "Receipt confirmed",
PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed",
CLOSED: "Closed",
MANAGER_LINE_EDIT: "Manager amended line items",
PRODUCT_PRICE_UPDATED: "Product prices updated",
CANCELLED: "Cancelled",
SUPERSEDED: "Superseded",
};
// Produce the Activity-timeline label for an action. Most actions use the static
// ACTION_LABELS map; PARTIAL_PAYMENT_CONFIRMED interpolates the instalment amount
// from the action's metadata (already persisted by markPaid) — issue #140.
export function actionLabel(
action: { actionType: string; metadata: Prisma.JsonValue },
currency: string,
): string {
const fallback = ACTION_LABELS[action.actionType] ?? action.actionType;
if (action.actionType === "PARTIAL_PAYMENT_CONFIRMED") {
const amount = (action.metadata as { paymentAmount?: unknown } | null)?.paymentAmount;
if (typeof amount === "number" && Number.isFinite(amount)) {
return `Partial payment of ${formatCurrency(amount, currency)} confirmed`;
}
}
return fallback;
}

View file

@ -1,67 +0,0 @@
/**
* 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,9 +48,6 @@ export const createPoSchema = z.object({
tcTransitInsurance: z.string().optional(),
tcPaymentTerms: 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"),
});

View file

@ -1,5 +0,0 @@
-- 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,13 +569,6 @@ model PurchaseOrder {
status POStatus @default(DRAFT)
totalAmount Decimal @db.Decimal(12, 2)
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?
projectCode String?
managerNote String?

View file

@ -1,9 +1,8 @@
/**
* Integration test for the feature-flagged PO-attachment permission
* (NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED). With the flag ON, a PO's own
* submitter plus Accounts / Manager / SuperUser may attach documents to a PO
* in **any state except REJECTED / CANCELLED**; everyone else, and any voided PO,
* is refused. (The flag-OFF behaviour lives in po-document-upload.test.ts.)
* Integration test for the feature-flagged closed-PO attachment remediation
* (NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED). With the flag ON, a CLOSED PO's own
* submitter plus Accounts / Manager / SuperUser may attach documents; everyone
* else is refused. (The flag-OFF case lives in po-document-upload.test.ts.)
*/
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
@ -13,7 +12,7 @@ vi.mock("@/lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/storage")>();
return { ...actual, uploadBuffer: vi.fn().mockResolvedValue(undefined) };
});
// Flip ONLY the attachment flag on; everything else stays real.
// Flip ONLY the remediation flag on; everything else stays real.
vi.mock("@/lib/feature-flags", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
return { ...actual, CLOSED_PO_ATTACHMENTS_ENABLED: true };
@ -22,16 +21,13 @@ vi.mock("@/lib/feature-flags", async (importOriginal) => {
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { uploadBuffer } from "@/lib/storage";
import type { Role, POStatus } from "@prisma/client";
import type { Role } from "@prisma/client";
import { createPo } from "@/app/(portal)/po/new/actions";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, makePoForm, deletePosByTitle } from "./helpers";
const PREFIX = "INTTEST_POATTACH_";
const VOID_ERROR = "Attachments can't be added to a rejected or cancelled purchase order.";
const DENY_ERROR = "Adding attachments to this purchase order isn't allowed.";
let techId: string; // the PO's submitter
const PREFIX = "INTTEST_CLOSEDPO_";
let techId: string; // the PO's submitter
let vesselId: string;
let accountId: string;
const userIds: Record<string, string> = {};
@ -70,21 +66,19 @@ function pdf(name: string): File {
return new File(["%PDF-1.4 hello"], name, { type: "application/pdf" });
}
// A PO submitted by the TECHNICAL user, forced into `status`.
async function makePo(title: string, status: POStatus): Promise<string> {
// A CLOSED PO submitted by the TECHNICAL user.
async function makeClosedPo(title: string): Promise<string> {
as(techId, "TECHNICAL");
const result = await createPo(makePoForm({ title, vesselId, accountId, intent: "draft" }));
expect(result).not.toHaveProperty("error");
const poId = (result as { id: string }).id;
if (status !== "DRAFT") {
await db.purchaseOrder.update({ where: { id: poId }, data: { status } });
}
await db.purchaseOrder.update({ where: { id: poId }, data: { status: "CLOSED" } });
return poId;
}
describe("PO attachment permissions (flag on)", () => {
it("lets the PO's own submitter attach to their PO", async () => {
const poId = await makePo(`${PREFIX}Submitter`, "CLOSED");
describe("closed-PO attachments (flag on)", () => {
it("lets the PO's own submitter attach to their closed PO", async () => {
const poId = await makeClosedPo(`${PREFIX}Submitter`);
as(techId, "TECHNICAL");
const err = await uploadPoDocuments(poId, [pdf("missing-invoice.pdf")]);
@ -97,7 +91,7 @@ describe("PO attachment permissions (flag on)", () => {
["MANAGER", "MANAGER"],
["SUPERUSER", "SUPERUSER"],
])("lets %s attach to a closed PO they did not submit", async (key, role) => {
const poId = await makePo(`${PREFIX}${key}`, "CLOSED");
const poId = await makeClosedPo(`${PREFIX}${key}`);
as(userIds[key], role);
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
@ -105,62 +99,29 @@ describe("PO attachment permissions (flag on)", () => {
expect(await db.pODocument.count({ where: { poId } })).toBe(1);
});
// The headline of this change: not just CLOSED — any live state.
it.each<POStatus>(["MGR_REVIEW", "MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "EDITS_REQUESTED"])(
"lets Manager attach to a PO in %s",
async (status) => {
const poId = await makePo(`${PREFIX}${status}`, status);
as(userIds.MANAGER, "MANAGER");
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toBeNull();
expect(await db.pODocument.count({ where: { poId } })).toBe(1);
}
);
it.each<POStatus>(["REJECTED", "CANCELLED"])(
"refuses attachments to a %s PO, even for Manager",
async (status) => {
const poId = await makePo(`${PREFIX}${status}`, status);
as(userIds.MANAGER, "MANAGER");
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toEqual({ error: VOID_ERROR });
expect(uploadBuffer).not.toHaveBeenCalled();
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
}
);
it("refuses a voided PO even for its own submitter", async () => {
const poId = await makePo(`${PREFIX}SubmitterRejected`, "REJECTED");
as(techId, "TECHNICAL");
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toEqual({ error: VOID_ERROR });
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("refuses a submitter-role user who is not this PO's submitter", async () => {
const poId = await makePo(`${PREFIX}OtherSubmitter`, "MGR_APPROVED");
const poId = await makeClosedPo(`${PREFIX}OtherSubmitter`);
as(userIds.MANNING, "MANNING"); // a submitter role, but not the PO's submitter
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toEqual({ error: DENY_ERROR });
expect(err).toEqual({ error: "Adding attachments to a closed purchase order isn't allowed." });
expect(uploadBuffer).not.toHaveBeenCalled();
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("refuses a role outside the allow-list (auditor)", async () => {
const poId = await makePo(`${PREFIX}Auditor`, "CLOSED");
const poId = await makeClosedPo(`${PREFIX}Auditor`);
as(userIds.AUDITOR, "AUDITOR");
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toEqual({ error: DENY_ERROR });
expect(err).toEqual({ error: "Adding attachments to a closed purchase order isn't allowed." });
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("still allows the normal create flow (DRAFT submitter)", async () => {
const poId = await makePo(`${PREFIX}Draft`, "DRAFT");
it("still allows uploads to a non-closed PO (normal flow unaffected)", async () => {
as(techId, "TECHNICAL");
const result = await createPo(makePoForm({ title: `${PREFIX}Draft`, vesselId, accountId, intent: "draft" }));
const poId = (result as { id: string }).id; // stays DRAFT
as(techId, "TECHNICAL");
const err = await uploadPoDocuments(poId, [pdf("draft-doc.pdf")]);

View file

@ -1,115 +0,0 @@
/**
* 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

@ -1,145 +0,0 @@
import { describe, it, expect } from "vitest";
import { buildDuplicatePrefill, toDateInputValue } from "@/lib/duplicate-po";
import type { DuplicateSourcePo } from "@/lib/duplicate-po";
// A Prisma Decimal stand-in: just needs a toNumber().
const dec = (n: number) => ({ toNumber: () => n });
function makeSource(overrides: Partial<DuplicateSourcePo> = {}): DuplicateSourcePo {
return {
title: "Spare parts for HNR1",
vesselId: "vsl_1",
accountId: "acc_1",
companyId: "co_1",
vendorId: "ven_1",
projectCode: "Haldia Reach",
placeOfDelivery: "Pelagia — Cochin yard",
dateRequired: new Date("2026-07-15T00:00:00.000Z"),
piQuotationNo: "INV-001",
piQuotationDate: new Date("2026-06-01T00:00:00.000Z"),
requisitionNo: "REQ-42",
requisitionDate: new Date("2026-05-20T00:00:00.000Z"),
terms: [{ category: "Delivery", text: "Within 4 to 5 days" }],
lineItems: [
{
name: "Gasket",
description: "Rubber gasket",
quantity: dec(3),
unit: "pc",
size: "M",
unitPrice: dec(120.5),
gstRate: dec(0.18),
productId: "prod_1",
accountId: null,
},
],
...overrides,
};
}
describe("buildDuplicatePrefill", () => {
it("copies the editable order fields onto the new draft", () => {
const r = buildDuplicatePrefill(makeSource());
expect(r.initialTitle).toBe("Spare parts for HNR1");
expect(r.initialVesselId).toBe("vsl_1");
expect(r.initialAccountId).toBe("acc_1");
expect(r.initialCompanyId).toBe("co_1");
expect(r.initialVendorId).toBe("ven_1");
expect(r.initialProjectCode).toBe("Haldia Reach");
expect(r.initialPlaceOfDelivery).toBe("Pelagia — Cochin yard");
expect(r.initialPiQuotationNo).toBe("INV-001");
expect(r.initialRequisitionNo).toBe("REQ-42");
});
it("formats dates as yyyy-MM-dd for native date inputs", () => {
const r = buildDuplicatePrefill(makeSource());
expect(r.initialDateRequired).toBe("2026-07-15");
expect(r.initialPiQuotationDate).toBe("2026-06-01");
expect(r.initialRequisitionDate).toBe("2026-05-20");
});
it("maps line items to the editor shape, converting Decimals to numbers", () => {
const r = buildDuplicatePrefill(makeSource());
expect(r.initialLineItems).toEqual([
{
name: "Gasket",
description: "Rubber gasket",
quantity: 3,
unit: "pc",
size: "M",
unitPrice: 120.5,
gstRate: 0.18,
productId: "prod_1",
accountId: undefined,
},
]);
});
it("enables per-item accounting codes only when a line item carries one", () => {
expect(buildDuplicatePrefill(makeSource()).initialMultiAccount).toBe(false);
const multi = makeSource({
lineItems: [
{ name: "A", quantity: dec(1), unit: "pc", unitPrice: dec(10), accountId: "acc_2" },
],
});
const r = buildDuplicatePrefill(multi);
expect(r.initialMultiAccount).toBe(true);
expect(r.initialLineItems[0].accountId).toBe("acc_2");
});
it("defaults a missing gstRate to 0.18", () => {
const src = makeSource({
lineItems: [{ name: "A", quantity: dec(2), unit: "pc", unitPrice: dec(5) }],
});
expect(buildDuplicatePrefill(src).initialLineItems[0].gstRate).toBe(0.18);
});
it("uses the saved terms snapshot when present", () => {
const r = buildDuplicatePrefill(makeSource());
expect(r.initialTerms).toEqual([{ category: "Delivery", text: "Within 4 to 5 days" }]);
});
it("falls back to legacy tc* terms when no JSON snapshot exists", () => {
const src = makeSource({
terms: null,
tcDelivery: "Next day",
tcPaymentTerms: "Net 15",
});
const r = buildDuplicatePrefill(src);
expect(r.initialTerms).toEqual(
expect.arrayContaining([
{ category: "Delivery", text: "Next day" },
{ category: "Payment Terms", text: "Net 15" },
])
);
});
it("normalises absent optional fields to undefined/null", () => {
const src = makeSource({
companyId: null,
vendorId: null,
projectCode: null,
placeOfDelivery: null,
dateRequired: null,
piQuotationNo: null,
piQuotationDate: null,
requisitionNo: null,
requisitionDate: null,
});
const r = buildDuplicatePrefill(src);
expect(r.initialCompanyId).toBeUndefined();
expect(r.initialVendorId).toBeUndefined();
expect(r.initialDateRequired).toBeUndefined();
expect(r.initialPiQuotationNo).toBeUndefined();
expect(r.initialProjectCode).toBeNull();
expect(r.initialPlaceOfDelivery).toBeNull();
});
});
describe("toDateInputValue", () => {
it("returns yyyy-MM-dd for a date and undefined for null", () => {
expect(toDateInputValue(new Date("2026-01-09T12:00:00.000Z"))).toBe("2026-01-09");
expect(toDateInputValue(null)).toBeUndefined();
expect(toDateInputValue(undefined)).toBeUndefined();
});
});

View file

@ -1,54 +0,0 @@
import { describe, it, expect } from "vitest";
import { actionLabel } from "@/lib/po-activity";
import { formatCurrency } from "@/lib/utils";
describe("actionLabel (Activity timeline)", () => {
it("interpolates the instalment amount for a partial payment (issue #140)", () => {
const label = actionLabel(
{ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentAmount: 5000 } },
"INR"
);
expect(label).toBe(`Partial payment of ${formatCurrency(5000, "INR")} confirmed`);
expect(label).toContain("5,000");
});
it("respects the PO currency", () => {
const label = actionLabel(
{ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentAmount: 1200 } },
"USD"
);
expect(label).toBe(`Partial payment of ${formatCurrency(1200, "USD")} confirmed`);
});
it("falls back to the plain label when paymentAmount is missing (older audit rows)", () => {
expect(
actionLabel({ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: null }, "INR")
).toBe("Partial payment confirmed");
expect(
actionLabel(
{ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentRef: "TXN-1" } },
"INR"
)
).toBe("Partial payment confirmed");
});
it("falls back when paymentAmount is non-numeric, never rendering NaN", () => {
const label = actionLabel(
{ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentAmount: "5000" } },
"INR"
);
expect(label).toBe("Partial payment confirmed");
expect(label).not.toContain("NaN");
});
it("leaves other action labels unchanged", () => {
expect(
actionLabel({ actionType: "PAYMENT_SENT", metadata: { paymentAmount: 5000 } }, "INR")
).toBe("Payment confirmed");
expect(actionLabel({ actionType: "APPROVED", metadata: null }, "INR")).toBe("Approved");
});
it("falls back to the raw action type for unknown actions", () => {
expect(actionLabel({ actionType: "MYSTERY", metadata: null }, "INR")).toBe("MYSTERY");
});
});

View file

@ -1,56 +0,0 @@
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);
});
});