Tighten PO payload validator (disc/vat range, raw_ocr shape, PB filter escape)

- Validate disc_percent and vat_percent are finite numbers in [0, 100]
- Reject raw_ocr arrays (was passing typeof === 'object' check)
- Escape backslashes before quotes in matchSite's PocketBase filter

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DEls 2026-08-19 02:34:17 +02:00
parent 108b17b556
commit 75770073a6
2 changed files with 51 additions and 2 deletions

View file

@ -56,6 +56,37 @@ describe("validatePurchaseOrderPayload", () => {
expect(result.ok).toBe(false);
});
it("rejects item with disc_percent > 100", () => {
const result = validatePurchaseOrderPayload({
...VALID_PAYLOAD,
items: [{ description: "x", qty: 1, rate: 10, disc_percent: 200 }],
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.details?.items).toMatch(/disc_percent/);
});
it("rejects item with disc_percent that is not a number", () => {
const result = validatePurchaseOrderPayload({
...VALID_PAYLOAD,
items: [{ description: "x", qty: 1, rate: 10, disc_percent: "foo" }],
});
expect(result.ok).toBe(false);
});
it("rejects item with negative vat_percent", () => {
const result = validatePurchaseOrderPayload({
...VALID_PAYLOAD,
items: [{ description: "x", qty: 1, rate: 10, vat_percent: -5 }],
});
expect(result.ok).toBe(false);
});
it("rejects raw_ocr that is an array", () => {
const result = validatePurchaseOrderPayload({ ...VALID_PAYLOAD, raw_ocr: [] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.details?.raw_ocr).toBeDefined();
});
it("rejects bad date format", () => {
const result = validatePurchaseOrderPayload({
...VALID_PAYLOAD,

View file

@ -82,6 +82,24 @@ export function validatePurchaseOrderPayload(input: unknown): ValidationResult {
if (typeof it.rate !== "number" || it.rate < 0) {
itemErrors.push(`items[${i}].rate: must be a number >= 0`);
}
if (
it.disc_percent !== undefined &&
(typeof it.disc_percent !== "number" ||
!Number.isFinite(it.disc_percent) ||
it.disc_percent < 0 ||
it.disc_percent > 100)
) {
itemErrors.push(`items[${i}].disc_percent: must be a number in [0, 100]`);
}
if (
it.vat_percent !== undefined &&
(typeof it.vat_percent !== "number" ||
!Number.isFinite(it.vat_percent) ||
it.vat_percent < 0 ||
it.vat_percent > 100)
) {
itemErrors.push(`items[${i}].vat_percent: must be a number in [0, 100]`);
}
});
if (itemErrors.length > 0) {
details.items = itemErrors.join("; ");
@ -113,7 +131,7 @@ export function validatePurchaseOrderPayload(input: unknown): ValidationResult {
}
}
if (p.raw_ocr === undefined || p.raw_ocr === null || typeof p.raw_ocr !== "object") {
if (p.raw_ocr === undefined || p.raw_ocr === null || typeof p.raw_ocr !== "object" || Array.isArray(p.raw_ocr)) {
details.raw_ocr = "must be an object";
}
@ -150,7 +168,7 @@ type PbLike = {
};
export async function matchSite(pb: PbLike, projectCode: string): Promise<SiteMatchResult> {
const safe = projectCode.replace(/"/g, '\\"');
const safe = projectCode.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const exact = await pb.collection("sites").getFullList({
filter: `project_code = "${safe}"`,