// Node.js 18+ / built-in crypto and fetch. Default action is READ-ONLY balance. import {createHash,randomUUID,timingSafeEqual} from 'node:crypto'; import {pathToFileURL} from 'node:url'; const gateway=process.env.HISSPAY_GATEWAY||''; // 网关地址见商户后台「应用密钥」页面 if(!gateway)throw Error('Set HISSPAY_GATEWAY (see merchant portal > App Credentials: appId)'); const paths={balance:'/transaction/payout/v1/balance',payin_query:'/transaction/payin/v1/query',payout_query:'/transaction/payout/v1/query',payin:'/transaction/payin/v1/apply',payout:'/transaction/payout/v1/apply'}; export function canonical(body,headers){ if(['appId','timestamp','nonce','sign'].some(k=>Object.hasOwn(body,k)))throw Error('Signature fields belong in headers'); const values=Object.fromEntries(Object.entries(body).filter(([,v])=>v!==null&&v!==undefined)); for(const k of ['appId','timestamp','nonce']){if(headers[k]===undefined)throw Error('Missing signing header');values[k]=headers[k]} for(const v of Object.values(values))if(!['string','number'].includes(typeof v))throw Error('Use flat scalar fields and decimal strings for amounts'); return Object.keys(values).sort().map(k=>`${k}=${String(values[k])}`).join('&'); } export function sign(body,appId,appSecret,timestamp=Date.now(),nonce=randomUUID()){ const headers={appId,timestamp:String(timestamp),nonce}; headers.sign=createHash('sha256').update(canonical(body,headers)+appSecret,'utf8').digest('hex');return headers; } export function verifyCallback(body,headers,appId,appSecret,now=Date.now(),maxAgeMs=300000){ try{ const low=Object.fromEntries(Object.entries(headers).map(([k,v])=>[k.toLowerCase(),v])); if(low.appid!==appId||typeof low.nonce!=='string'||low.nonce.length<10||!/^\d+$/.test(low.timestamp)||Math.abs(now-Number(low.timestamp))>maxAgeMs||!/^([0-9a-f]{64})$/.test(low.sign))return false; const h={appId:low.appid,timestamp:low.timestamp,nonce:low.nonce}; const expected=createHash('sha256').update(canonical(body,h)+appSecret,'utf8').digest('hex'); return timingSafeEqual(Buffer.from(expected,'hex'),Buffer.from(low.sign,'hex')); }catch{return false} } export async function request(action,body,appId,appSecret){ if(!Object.hasOwn(paths,action))throw Error('Unknown action'); if(Object.values(body).some(v=>v===null||v===undefined))throw Error('Omit optional null fields'); const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),30000); try{ const r=await fetch(gateway+paths[action],{method:'POST',headers:{...sign(body,appId,appSecret),'Content-Type':'application/json'},body:JSON.stringify(body),redirect:'error',signal:controller.signal}); return {http:r.status,response:await r.json()}; }finally{clearTimeout(timer)} } // appId/appSecret from HISSPAY_APP_ID / HISSPAY_APP_SECRET; the old PAYPLAT_* names still work but are deprecated. export function credentials(env=process.env){ const id=env.HISSPAY_APP_ID,key=env.HISSPAY_APP_SECRET; if(id&&key)return [id,key]; if(!id&&!key&&env.PAYPLAT_APP_ID&&env.PAYPLAT_APP_SECRET){ console.error('WARNING: PAYPLAT_APP_ID/PAYPLAT_APP_SECRET are deprecated; rename them to HISSPAY_APP_ID/HISSPAY_APP_SECRET.'); return [env.PAYPLAT_APP_ID,env.PAYPLAT_APP_SECRET]; } throw Error('Set HISSPAY_APP_ID and HISSPAY_APP_SECRET in a private server environment.'); } if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){ const [id,key]=credentials(); console.log(JSON.stringify(await request('balance',{currency:'INR'},id,key),null,2)); } // Persist and validate callbacks transactionally before replying plain SUCCESS. // No request is retried automatically; use the original order number to query unknown outcomes.