/** 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 };