2026-11-02 23:27:07 +00:00
/** Supplier-payment commands. All mutations run in one PB transaction. */
const REQUESTS = "supplier_payment_requests" ;
const ITEMS = "order_items" ;
2026-11-03 05:18:56 +00:00
const REQUEST _FIELDS = [ "order" , "supplier" , "items" , "status" , "requested_by" , "paid_by" , "paid_at" , "cancellation_reason" , "documents" , "item_snapshot" , "revision" , "history" ] ;
2026-11-02 23:27:07 +00:00
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" ) {
2026-11-03 05:18:56 +00:00
fail ( "This payment's items changed. Refresh the payment list and try again." ) ;
2026-11-02 23:27:07 +00:00
}
} ) ;
}
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 ) ;
2026-11-03 05:18:56 +00:00
if ( needSupplier && ! supplier ) fail ( "Choose a supplier on the item before marking it paid." , 422 ) ;
2026-11-02 23:27:07 +00:00
items . forEach ( ( item ) => {
2026-11-03 05:18:56 +00:00
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." ) ;
2026-11-02 23:27:07 +00:00
} ) ;
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 ;
}
2026-11-03 05:18:56 +00:00
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 ) ) ) ;
}
2026-11-02 23:27:07 +00:00
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 ; }
2026-11-03 05:18:56 +00:00
if ( po . supplier && po . supplier !== item . getString ( "supplier" ) ) return false ;
2026-11-02 23:27:07 +00:00
return array ( po . documents ) . concat ( array ( po . documents _history ) ) . indexOf ( doc . filename ) !== - 1 ;
}
return false ;
}
2026-11-03 05:18:56 +00:00
/** 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 ) {
2026-11-02 23:27:07 +00:00
array ( docs ) . forEach ( ( doc ) => {
2026-11-03 05:18:56 +00:00
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 ) ;
2026-11-02 23:27:07 +00:00
} ) ;
}
2026-11-03 05:18:56 +00:00
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 ) ) ) ;
2026-11-02 23:27:07 +00:00
}
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 ) ;
2026-11-03 05:18:56 +00:00
request . set ( "documents" , linkedPoDocuments ( app , [ item ] ) ) ;
2026-11-02 23:27:07 +00:00
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 ) ;
2026-11-03 05:18:56 +00:00
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 ) ;
2026-11-02 23:27:07 +00:00
request . set ( "supplier" , group . supplier ) ;
request . set ( "documents" , docs ) ;
request . set ( "item_snapshot" , snapshot ( items ) ) ;
2026-11-03 05:18:56 +00:00
request . set ( "status" , group . supplier && evidence ( app , docs , items , "support" , null , request ) ? "pending" : "draft" ) ;
eventHistory ( request , "documents_added" , person ) ;
2026-11-02 23:27:07 +00:00
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 ) ;
2026-11-03 05:18:56 +00:00
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 ) ;
2026-11-02 23:27:07 +00:00
request . set ( "documents" , docs ) ;
2026-11-03 05:18:56 +00:00
// 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 ) ) ;
2026-11-02 23:27:07 +00:00
request . set ( "status" , "paid" ) ;
request . set ( "paid_by" , person . name ) ;
request . set ( "paid_at" , new Date ( ) . toISOString ( ) ) ;
2026-11-03 05:18:56 +00:00
eventHistory ( request , "paid" , person ) ;
2026-11-02 23:27:07 +00:00
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 ) ) ;
2026-11-03 05:18:56 +00:00
eventHistory ( request , "cancelled" , person , { reason : reason , removed _items : removed . map ( ( item ) => item . id ) } ) ;
2026-11-02 23:27:07 +00:00
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 ) ;
2026-11-03 05:18:56 +00:00
if ( ! evidence ( app , docs , [ item ] , "proof" , candidate , request ) ) fail ( "Paid proof of payment must remain attached to this item." ) ;
2026-11-02 23:27:07 +00:00
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." ) ;
}
}
2026-11-03 05:18:56 +00:00
function invalidate ( e , app , item , audit , person , candidate ) {
2026-11-02 23:27:07 +00:00
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." ) ;
2026-11-03 05:18:56 +00:00
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 } ) ;
2026-11-02 23:27:07 +00:00
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 ) ) ) ;
2026-11-03 05:18:56 +00:00
if ( changed && stored . getString ( "payment_status" ) === "required" ) invalidate ( e , app , stored , audit , null , e . record ) ;
2026-11-02 23:27:07 +00:00
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 } ;