WFDASH-WEBAPP/pocketbase/pb_hooks/order_payment_utils.js
Dave 97603dce71 refactor: simplify supplier payments to document checklist
Remove payment amount, reference, notes and manual combining forms. Link supplier POs automatically and record paid from supplier evidence plus proof, keeping readiness and attachment safeguards.

Keep active supplier/company views current and retire stale supplier evidence on routing changes. Verified 5167 tests with only the two existing script-import errors.
2026-11-03 07:18:56 +02:00

481 lines
26 KiB
JavaScript

/** Supplier-payment commands. All mutations run in one PB transaction. */
const REQUESTS = "supplier_payment_requests";
const ITEMS = "order_items";
const REQUEST_FIELDS = ["order", "supplier", "items", "status", "requested_by", "paid_by", "paid_at", "cancellation_reason", "documents", "item_snapshot", "revision", "history"];
const BLOCKED_STAGES = ["ready_delivery", "assigned", "collected", "stock_verified", "picking", "ready_invoice", "invoiced", "stock_booked", "with_driver", "checked_in", "completed", "delivered", "delivery_note"];
const NEW_HOLD_STAGES = ["pending", "processed", "po_generated", "collection_pool", "approval_needed", "approval_rejected"];
function fail(message, status) {
throw new ApiError(status || 409, message);
}
function plain(record) { return JSON.parse(JSON.stringify(record)); }
function array(value) { return Array.isArray(value) ? value : (value ? [value] : []); }
function text(value, max) { return String(value || "").trim().slice(0, max || 2000); }
function active(record) { return ["draft", "pending"].indexOf(record.getString("status")) !== -1; }
function lookup(app, name, id) {
try { return app.findRecordById(name, text(id, 64)); }
catch (_) { fail("Payment request or order item was not found. Refresh and try again.", 404); }
}
function assertSchema(app) {
const required = { order_items: ["payment_status", "payment_request", "ready_for_collection", "order_item_attachements"], supplier_payment_requests: REQUEST_FIELDS };
Object.keys(required).forEach((name) => {
let collection;
try { collection = app.findCollectionByNameOrId(name); }
catch (_) { fail("Supplier payments need the PocketBase schema migration before use.", 503); }
required[name].forEach((field) => {
let found;
try { found = collection.fields.getByName(field); } catch (_) { found = null; }
if (!found) fail("Supplier payments need the PocketBase schema migration (" + name + "." + field + ").", 503);
});
});
}
function actor(body) {
if (!body.actor || !text(body.actor.id, 64) || !text(body.actor.name, 200)) fail("A verified payment actor is required.", 422);
return { id: text(body.actor.id, 64), name: text(body.actor.name, 200) };
}
function auditEvent(e, app, person) {
return { app: app, auth: e.auth, requestInfo: () => e.requestInfo(), paymentActor: person || null };
}
function save(e, app, record, audit, person) {
let before = null;
try { before = plain(app.findRecordById(record.collection().name, record.id)); } catch (_) { /* new record */ }
app.save(record);
if (audit) audit(auditEvent(e, app, person), before ? "update" : "create", before, plain(record));
return record;
}
function eventHistory(request, kind, person, details) {
const history = array(plain(request).history);
history.push({ at: new Date().toISOString(), action: kind, actor: person || null, details: details || {} });
request.set("history", history);
}
function bump(request) { request.set("revision", Number(request.get("revision") || 0) + 1); }
function requestItems(app, request) {
return array(plain(request).items).map((id) => lookup(app, ITEMS, id));
}
function snapshot(items) {
const result = {};
items.forEach((item) => {
result[item.id] = {
order: item.getString("order"), supplier: item.getString("supplier"),
quantity: item.get("quantity"), order_qty: item.get("order_qty"), cost_price: item.get("cost_price"),
};
});
return result;
}
function ensureMembership(request, items) {
items.forEach((item) => {
if (item.getString("payment_request") !== request.id || item.getString("payment_status") !== "required") {
fail("This payment's items changed. Refresh the payment list and try again.");
}
});
}
function consistent(items, needSupplier) {
if (!items.length) fail("A payment request must cover at least one item.", 422);
const order = items[0].getString("order");
const supplier = items[0].getString("supplier");
if (!order) fail("Only customer-order items support this payment flow.", 422);
if (needSupplier && !supplier) fail("Choose a supplier on the item before marking it paid.", 422);
items.forEach((item) => {
if (item.getString("order") !== order || item.getString("supplier") !== supplier) fail("Payment items must belong to the same order and supplier. Refresh the payment list before continuing.");
});
return { order: order, supplier: supplier };
}
function mutable(app, body) {
const request = lookup(app, REQUESTS, body.request_id);
if (!active(request)) fail("This request is already paid or cancelled. Refresh before continuing.");
if (Number(body.revision) !== Number(request.get("revision"))) fail("This payment request changed. Refresh and try again.");
return request;
}
function documentKey(doc) { return [doc.collection, doc.recordId, doc.filename, doc.role].join("|"); }
function mergeDocuments(existing, additions) {
const result = [];
const seen = {};
array(existing).concat(array(additions)).forEach((doc) => {
if (!doc || ["support", "proof"].indexOf(doc.role) === -1 || !doc.collection || !doc.recordId || !doc.filename || /[\\/]/.test(doc.filename)) fail("Invalid payment document reference.", 422);
const normalized = { collection: text(doc.collection, 100), recordId: text(doc.recordId, 64), filename: text(doc.filename, 255), role: doc.role };
const key = documentKey(normalized);
if (!seen[key]) { seen[key] = true; result.push(normalized); }
});
return result;
}
function retiredForItem(request, item, doc) {
if (!request) return false;
return array(plain(request).history).some((entry) => entry.action === "routing_changed" && entry.details && entry.details.item_id === item.id &&
array(entry.details.invalidated_documents).some((old) => documentKey(old) === documentKey(doc)));
}
function docExists(app, doc, item, candidate) {
if (doc.collection === ITEMS && doc.recordId === item.id) {
const source = candidate && candidate.id === item.id ? candidate : plain(item);
return array(source.order_item_attachements).indexOf(doc.filename) !== -1;
}
if (doc.role === "support" && doc.collection === "purchase_orders" && doc.recordId === item.getString("purchase_order")) {
let po;
try { po = plain(app.findRecordById("purchase_orders", doc.recordId)); } catch (_) { return false; }
if (po.supplier && po.supplier !== item.getString("supplier")) return false;
return array(po.documents).concat(array(po.documents_history)).indexOf(doc.filename) !== -1;
}
return false;
}
/** Linked supplier PO PDFs are supporting evidence without operator selection. */
function linkedPoDocuments(app, items, request) {
let docs = [];
items.forEach((item) => {
const poId = item.getString("purchase_order");
if (!poId) return;
let po;
try { po = plain(app.findRecordById("purchase_orders", poId)); } catch (_) { return; }
if (po.supplier && po.supplier !== item.getString("supplier")) return;
const files = array(po.documents).concat(array(po.documents_history));
const linked = files.filter((filename) => /\.pdf$/i.test(filename)).map((filename) => ({
collection: "purchase_orders", recordId: poId, filename: filename, role: "support",
})).filter((doc) => !retiredForItem(request, item, doc));
docs = mergeDocuments(docs, linked);
});
return docs;
}
function validateAdditions(app, docs, items, request) {
array(docs).forEach((doc) => {
if (!items.some((item) => docExists(app, doc, item) && !retiredForItem(request, item, doc))) fail("A payment document is missing or does not belong to the selected items.", 422);
});
}
function evidence(app, docs, items, role, candidate, request) {
return items.every((item) => docs.some((doc) => doc.role === role && docExists(app, doc, item, candidate) && !retiredForItem(request, item, doc)));
}
function createDraft(e, app, item, person, audit, extra) {
const request = new Record(app.findCollectionByNameOrId(REQUESTS));
request.set("order", item.getString("order"));
request.set("supplier", item.getString("supplier"));
request.set("items", [item.id]);
request.set("status", "draft");
request.set("revision", 1);
request.set("requested_by", person.name);
request.set("documents", linkedPoDocuments(app, [item]));
request.set("item_snapshot", snapshot([item]));
eventHistory(request, "required", person, extra);
save(e, app, request, audit, person);
item.set("payment_status", "required");
item.set("payment_request", request.id);
save(e, app, item, audit, person);
return request;
}
function requirePayment(e, app, body, person, audit) {
const item = lookup(app, ITEMS, body.item_id);
if (item.getString("payment_status") === "required" && item.getString("payment_request")) {
const existing = lookup(app, REQUESTS, item.getString("payment_request"));
if (!active(existing) || array(plain(existing).items).indexOf(item.id) === -1) fail("Payment membership needs reconciliation.");
return existing;
}
if (!item.getString("order")) fail("This item has no customer order.", 422);
if (item.getBool("ready_for_collection")) fail("Return this item to not ready for collection before requiring payment.");
if (NEW_HOLD_STAGES.indexOf(item.getString("item_status")) === -1 || item.getString("drv_id") || item.getString("drv_status") === "assigned") fail("This item is already assigned, collected or closed; a new pre-collection payment hold cannot be added.");
return createDraft(e, app, item, person, audit);
}
function saveRequest(e, app, body, person, audit) {
const request = mutable(app, body);
const items = requestItems(app, request);
ensureMembership(request, items);
const group = consistent(items, false);
const docs = mergeDocuments(mergeDocuments(plain(request).documents, body.documents), linkedPoDocuments(app, items, request));
validateAdditions(app, array(body.documents), items, request);
request.set("order", group.order);
request.set("supplier", group.supplier);
request.set("documents", docs);
request.set("item_snapshot", snapshot(items));
request.set("status", group.supplier && evidence(app, docs, items, "support", null, request) ? "pending" : "draft");
eventHistory(request, "documents_added", person);
bump(request);
return save(e, app, request, audit, person);
}
function payRequest(e, app, body, person, audit) {
const request = mutable(app, body);
const items = requestItems(app, request);
ensureMembership(request, items);
const group = consistent(items, true);
const docs = mergeDocuments(mergeDocuments(plain(request).documents, body.documents), linkedPoDocuments(app, items, request));
validateAdditions(app, array(body.documents), items, request);
if (!evidence(app, docs, items, "support", null, request)) fail("Upload the supplier invoice or pro forma, or attach the supplier PO PDF, before marking paid.", 422);
if (!evidence(app, docs, items, "proof", null, request)) fail("Upload proof of payment for every item in this request before marking paid.", 422);
request.set("order", group.order);
request.set("supplier", group.supplier);
request.set("documents", docs);
// Current revision is the stale-tab guard. The settled coverage snapshot is
// taken now, without requiring an operator to reconfirm a separate form.
request.set("item_snapshot", snapshot(items));
request.set("status", "paid");
request.set("paid_by", person.name);
request.set("paid_at", new Date().toISOString());
eventHistory(request, "paid", person);
bump(request);
save(e, app, request, audit, person);
items.forEach((item) => {
item.set("payment_status", "paid");
save(e, app, item, audit, person);
});
return request;
}
function cancelRequest(e, app, body, person, audit) {
const request = mutable(app, body);
const reason = text(body.reason, 2000);
if (!reason) fail("A reason is required to cancel a payment requirement.", 422);
const items = requestItems(app, request);
ensureMembership(request, items);
const removed = body.item_id ? items.filter((item) => item.id === body.item_id) : items;
if (!removed.length) fail("The item is not a member of this payment request.", 422);
const remaining = items.filter((item) => removed.every((r) => r.id !== item.id));
eventHistory(request, "cancelled", person, { reason: reason, removed_items: removed.map((item) => item.id) });
request.set("cancellation_reason", reason);
request.set("status", remaining.length ? "draft" : "cancelled");
// Keep membership on cancelled requests for historical lookup.
if (remaining.length) request.set("items", remaining.map((item) => item.id));
request.set("item_snapshot", snapshot(remaining.length ? remaining : items));
bump(request);
save(e, app, request, audit, person);
removed.forEach((item) => {
item.set("payment_status", "");
item.set("payment_request", "");
save(e, app, item, audit, person);
});
return request;
}
function combineRequests(e, app, body, person, audit) {
const ids = array(body.request_ids);
if (ids.length < 2 || ids.length > 100 || ids.some((id, index) => ids.indexOf(id) !== index)) fail("Select between two and 100 distinct unpaid requests.", 422);
const requests = ids.map((id) => mutable(app, { request_id: id, revision: body.revisions && body.revisions[id] }));
let items = [];
let docs = [];
requests.forEach((request) => {
const members = requestItems(app, request);
ensureMembership(request, members);
items = items.concat(members);
docs = mergeDocuments(docs, plain(request).documents);
});
const group = consistent(items, true);
const merged = new Record(app.findCollectionByNameOrId(REQUESTS));
merged.set("order", group.order);
merged.set("supplier", group.supplier);
merged.set("items", items.map((item) => item.id));
merged.set("status", "draft");
merged.set("revision", 1);
merged.set("requested_by", person.name);
merged.set("documents", docs);
merged.set("item_snapshot", snapshot(items));
eventHistory(merged, "combined", person, { source_requests: ids });
save(e, app, merged, audit, person);
requests.forEach((request) => {
request.set("status", "cancelled");
request.set("cancellation_reason", "Combined into " + merged.id);
eventHistory(request, "combined_into", person, { request_id: merged.id });
bump(request);
save(e, app, request, audit, person);
});
items.forEach((item) => {
item.set("payment_request", merged.id);
save(e, app, item, audit, person);
});
return merged;
}
function command(e, body, audit) {
if (!e.auth || !e.auth.isSuperuser || !e.auth.isSuperuser()) throw new ForbiddenError("Payment commands require the WFDASH server.");
if (!body || typeof body !== "object") fail("A payment command is required.", 422);
const person = actor(body);
let result;
e.app.runInTransaction((app) => {
assertSchema(app);
let request;
if (body.op === "require") request = requirePayment(e, app, body, person, audit);
else if (body.op === "save") request = saveRequest(e, app, body, person, audit);
else if (body.op === "paid") request = payRequest(e, app, body, person, audit);
else if (body.op === "cancel") request = cancelRequest(e, app, body, person, audit);
else if (body.op === "combine") request = combineRequests(e, app, body, person, audit);
else fail("Unknown payment command.", 422);
result = { request: plain(request) };
});
return result;
}
/** PB appends its ten-character suffix before the extension. */
function storedDocument(files, originalName) {
const dot = originalName.lastIndexOf(".");
const stem = originalName.slice(0, dot).toLowerCase();
const extension = originalName.slice(dot).toLowerCase();
const matches = array(files).filter((filename) => {
const stored = String(filename).toLowerCase();
if (stored.indexOf(stem + "_") !== 0 || stored.slice(-extension.length) !== extension) return false;
return /^[a-z0-9]{10}$/.test(stored.slice(stem.length + 1, stored.length - extension.length));
});
return matches.length ? matches[matches.length - 1] : "";
}
function uploadDocument(e, body, audit) {
if (!e.auth || !e.auth.isSuperuser || !e.auth.isSuperuser()) throw new ForbiddenError("Payment uploads require the WFDASH server.");
const person = actor({ actor: { id: body.actor_id, name: body.actor_name } });
const role = body.role;
if (["support", "proof"].indexOf(role) === -1) fail("Invalid payment document role.", 422);
const uploaded = e.findUploadedFiles("file");
if (!uploaded || uploaded.length !== 1) fail("Upload exactly one payment document.", 422);
const file = uploaded[0];
if (!Number(file.size) || Number(file.size) > 20 * 1024 * 1024) fail("Choose a non-empty payment file up to 20 MB.", 422);
const requestId = text(body.request_id, 64);
const originalName = String(file.originalName || "");
const prefix = "pay_" + requestId + "_" + role + "_";
if (originalName.indexOf(prefix) !== 0 || !/^[a-f0-9]{16}\.[a-z0-9]{1,8}$/.test(originalName.slice(prefix.length))) {
fail("Invalid payment upload identity. Refresh and retry the upload.", 422);
}
let result;
e.app.runInTransaction((app) => {
assertSchema(app);
const request = mutable(app, { request_id: requestId, revision: body.revision });
const items = requestItems(app, request);
ensureMembership(request, items);
// All capacity checks precede all writes. A concurrent upload or settlement
// uses this same database transaction and sees committed attachment names.
const planned = items.map((item) => {
const current = array(plain(item).order_item_attachements);
const existing = storedDocument(current, originalName);
if (!existing && current.length >= 10) fail("Item " + item.id + " already has 10 attachments. Resolve capacity without removing payment history.");
return { item: item, existing: existing };
});
const documents = planned.map((entry) => {
let stored = entry.existing;
if (!stored) {
entry.item.set("order_item_attachements+", [file]);
save(e, app, entry.item, audit, person);
stored = storedDocument(plain(entry.item).order_item_attachements, originalName);
if (!stored) fail("The payment upload could not be verified. Payment remains pending.");
}
return { collection: ITEMS, recordId: entry.item.id, filename: stored, role: role };
});
result = { documents: documents };
});
return result;
}
function protectProjection(info) {
const body = info.body || {};
if (Object.prototype.hasOwnProperty.call(body, "payment_status") || Object.prototype.hasOwnProperty.call(body, "payment_request")) {
throw new ForbiddenError("Payment state is managed by Payments and Approvals; use its payment actions.");
}
}
function protectPaidProofs(app, candidate) {
if (!candidate.id) return;
try { app.findCollectionByNameOrId(REQUESTS); } catch (_) { return; }
const paid = app.findRecordsByFilter(REQUESTS,
'status = "paid" && (items ?= {:item} || documents ~ {:item})', "", 0, 0, { item: candidate.id });
const attached = array(candidate.order_item_attachements);
paid.forEach((request) => {
array(plain(request).documents).forEach((doc) => {
if (doc.role === "proof" && doc.collection === ITEMS && doc.recordId === candidate.id && attached.indexOf(doc.filename) === -1) {
fail("Historical proof of payment must remain attached to this item.");
}
});
});
}
function checkReadiness(app, candidate) {
protectPaidProofs(app, candidate);
const state = candidate.payment_status || "";
const requestId = candidate.payment_request || "";
if (!state && !requestId) return;
let request;
try { request = app.findRecordById(REQUESTS, requestId); }
catch (_) { fail("Payment evidence is unavailable. Reconcile the payment request before collection."); }
const docs = array(plain(request).documents);
if (state === "paid" && request.getString("status") === "paid" && array(plain(request).items).indexOf(candidate.id) !== -1) {
const item = app.findRecordById(ITEMS, candidate.id);
if (!evidence(app, docs, [item], "proof", candidate, request)) fail("Paid proof of payment must remain attached to this item.");
const expected = (plain(request).item_snapshot || {})[candidate.id];
if (!expected || expected.order !== candidate.order || expected.supplier !== candidate.supplier) {
fail("The paid item's order or supplier changed. Create a fresh payment requirement before changing them.");
}
const preCollection = NEW_HOLD_STAGES.concat(["assigned", "ready_delivery"]);
if (preCollection.indexOf(candidate.item_status) !== -1 || preCollection.indexOf(item.getString("item_status")) !== -1) {
if (["order_qty", "cost_price"].some((field) => JSON.stringify(expected[field]) !== JSON.stringify(candidate[field]))) {
fail("The paid item's supplier quantity or cost changed. Create a fresh payment requirement and reconcile it before collection.");
}
}
return;
}
if (candidate.ready_for_collection || candidate.drv_id || ["assigned", "en_route", "collected"].indexOf(candidate.drv_status) !== -1 || BLOCKED_STAGES.indexOf(candidate.item_status) !== -1) {
fail("Payment needed: mark the supplier payment paid with proof before this item can be ready for collection or dispatched.");
}
}
function invalidate(e, app, item, audit, person, candidate) {
if (item.getString("payment_status") !== "required" || !item.getString("payment_request")) return;
const request = lookup(app, REQUESTS, item.getString("payment_request"));
if (!active(request)) fail("Payment membership needs reconciliation.");
const next = candidate || item;
const routingChanged = item.getString("supplier") !== next.getString("supplier") || item.getString("order") !== next.getString("order");
if (routingChanged) {
const before = mergeDocuments(plain(request).documents, linkedPoDocuments(app, [item], request));
const oldPo = item.getString("purchase_order");
const invalidated = before.filter((doc) => (doc.collection === ITEMS && doc.recordId === item.id) || (doc.collection === "purchase_orders" && doc.recordId === oldPo));
const otherItems = requestItems(app, request).filter((member) => member.id !== item.id);
// Shared PO evidence may still serve another member. The per-member retired
// association below prevents that shared reference serving the changed item.
const remaining = array(plain(request).documents).filter((doc) => !invalidated.some((old) => documentKey(old) === documentKey(doc)) || otherItems.some((member) => docExists(app, doc, member)));
request.set("documents", remaining);
eventHistory(request, "routing_changed", person, {
item_id: item.id,
previous: { order: item.getString("order"), supplier: item.getString("supplier") },
current: { order: next.getString("order"), supplier: next.getString("supplier") },
invalidated_documents: invalidated,
});
if (array(plain(request).items).length === 1) {
request.set("order", next.getString("order"));
request.set("supplier", next.getString("supplier"));
}
} else eventHistory(request, "item_changed", person, { item_id: item.id });
request.set("status", "draft");
bump(request);
save(e, app, request, audit, person);
}
function inheritSplit(e, record, audit) {
let details = plain(record).order_item_details;
if (typeof details === "string") { try { details = JSON.parse(details); } catch (_) { details = null; } }
const marker = details && (details.split || details.shortCollection);
if (!marker || !marker.sourceItemId) return;
let source;
try { source = e.app.findRecordById(ITEMS, marker.sourceItemId); } catch (_) { return; }
if (source.getString("payment_status") !== "required") return;
if (source.getString("order") !== record.getString("order")) fail("Split payment source must belong to the same order.");
if (record.getString("payment_status") === "required") return;
assertSchema(e.app);
const person = { id: "", name: "Payment hold inherited from split" };
createDraft(e, e.app, record, person, audit, { source_item_id: source.id });
invalidate(e, e.app, source, audit, person);
}
function protectDeletion(app, record) {
if (record.getString("payment_request") || record.getString("payment_status")) throw new ForbiddenError("Payment item history and documents cannot be deleted.");
let collection;
try { collection = app.findCollectionByNameOrId(REQUESTS); } catch (_) { return; }
if (!collection) return;
const history = app.findRecordsByFilter(REQUESTS,
"items ?= {:item} || documents ~ {:item} || history ~ {:item}", "", 1, 0, { item: record.id });
if (history.length) throw new ForbiddenError("This item's payment history and documents must be preserved. Cancel the item instead of deleting it.");
}
function protectModelCreate(e, audit) {
const originalApp = e.app;
try {
return originalApp.runInTransaction((app) => {
e.app = app;
e.next();
inheritSplit(e, e.record, audit);
checkReadiness(app, plain(e.record));
});
} finally { e.app = originalApp; }
}
function protectItemUpdate(e, audit) {
protectProjection(e.requestInfo());
const originalApp = e.app;
try {
return originalApp.runInTransaction((app) => {
e.app = app;
const stored = app.findRecordById(ITEMS, e.record.id);
// A read-before-payment mutation must not overwrite the newer hold.
e.record.set("payment_status", stored.getString("payment_status"));
e.record.set("payment_request", stored.getString("payment_request"));
checkReadiness(app, plain(e.record));
const watched = ["order", "supplier", "quantity", "order_qty", "cost_price"];
const changed = watched.some((field) => JSON.stringify(stored.get(field)) !== JSON.stringify(e.record.get(field)));
if (changed && stored.getString("payment_status") === "required") invalidate(e, app, stored, audit, null, e.record);
return e.next();
});
} finally { e.app = originalApp; }
}
module.exports = { command: command, uploadDocument: uploadDocument, assertSchema: assertSchema, protectProjection: protectProjection, checkReadiness: checkReadiness, inheritSplit: inheritSplit, protectItemUpdate: protectItemUpdate, protectDeletion: protectDeletion, protectModelCreate: protectModelCreate, plain: plain };