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.
481 lines
26 KiB
JavaScript
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 };
|