fix: Activity should log partial payment amount #141

Merged
shad0w merged 3 commits from claude/issue-140 into master 2026-06-28 01:41:19 +00:00
3 changed files with 96 additions and 21 deletions

View file

@ -12,6 +12,7 @@ import { groupAttachments } from "@/lib/attachments";
import { canAddPoAttachment, hasPermission } from "@/lib/permissions"; import { canAddPoAttachment, hasPermission } from "@/lib/permissions";
import { TC_FIXED_LINE } from "@/lib/validations/po"; import { TC_FIXED_LINE } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms"; import { parsePoTerms } from "@/lib/terms";
import { actionLabel } from "@/lib/po-activity";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
@ -89,26 +90,6 @@ interface Props {
vendorEmail?: string | null; 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) { export async function PoDetail({ po, currentUserId, currentRole, readOnly = false, vendorEmail = null }: Props) {
const lineItemsForEditor = po.lineItems.map((li) => ({ const lineItemsForEditor = po.lineItems.map((li) => ({
name: li.name, name: li.name,
@ -600,7 +581,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="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"> <div className="flex items-baseline gap-2">
<span className="text-sm font-medium text-neutral-900"> <span className="text-sm font-medium text-neutral-900">
{ACTION_LABELS[action.actionType] ?? action.actionType} {actionLabel(action, po.currency)}
</span> </span>
<span className="text-xs text-neutral-400">by {action.actor.name}</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> <span className="text-xs text-neutral-400 ml-auto">{formatDateTime(action.createdAt)}</span>

40
App/lib/po-activity.ts Normal file
View file

@ -0,0 +1,40 @@
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

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