// Node.js 24.12+. Place behind an HTTPS reverse proxy. import {createServer} from 'node:http';import {createHmac,timingSafeEqual} from 'node:crypto';import {DatabaseSync} from 'node:sqlite'; const db=new DatabaseSync(process.env.BLUAUTO_WEBHOOK_DB||'merchant.sqlite'); db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=10000; CREATE TABLE IF NOT EXISTS orders(order_id TEXT PRIMARY KEY,invoice_id TEXT UNIQUE NOT NULL,amount_rial TEXT NOT NULL,paid INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS webhook_receipts(event_id TEXT PRIMARY KEY,invoice_id TEXT NOT NULL); CREATE TABLE IF NOT EXISTS fulfillment_jobs(order_id TEXT PRIMARY KEY,status TEXT NOT NULL DEFAULT 0);'); const secret=process.env.BLUAUTO_WEBHOOK_SECRET;if(!secret)throw new Error('Set BLUAUTO_WEBHOOK_SECRET'); createServer(async(req,res)=>{const reply=(s,t)=>{res.writeHead(s);res.end(t);};if(req.method!=='POST'||req.url!=='/webhook')return reply(404,'Not found');let length=0,chunks=[]; try{for await(const c of req){length+=c.length;if(length>65536)return reply(413,'Too large');chunks.push(c);}const raw=Buffer.concat(chunks),ts=req.headers['x-didban-timestamp']||'',sig=req.headers['x-didban-signature']||''; if(typeof ts!=='string'||!/^\d{1,12}$/.test(ts)||Math.abs(Date.now()/1000-Number(ts))>300)return reply(401,'Expired signature');const expected=Buffer.from('v1='+createHmac('sha256',secret).update(ts+'.').update(raw).digest('hex'));if(typeof sig!=='string'||Buffer.byteLength(sig)!==expected.length||!timingSafeEqual(expected,Buffer.from(sig)))return reply(401,'Invalid signature'); let e;try{e=JSON.parse(raw.toString('utf8'));}catch{return reply(400,'Invalid JSON');}const d=e?.data; if(e?.type==='webhook.test'&&typeof e.event_id==='string'&&e.event_id.length>0&&e.event_id.length<=100&&e.event_id===req.headers['x-didban-event-id']&&d?.mode==='test'&&d.paid===false)return reply(200,'Test acknowledged'); if(typeof e?.event_id!=='string'||e.event_id.length>100||!e.event_id||e.event_id!==req.headers['x-didban-event-id']||e.type!=='invoice.paid'||d?.mode!=='live'||d.status!=='PAID'||d.paid!==true||typeof d.amount_rial!=='string'||!/^\d{1,24}$/.test(d.amount_rial)||typeof d.order_id!=='string'||typeof d.id!=='string')return reply(400,'Invalid event'); db.exec('BEGIN IMMEDIATE');try{const row=db.prepare('SELECT invoice_id,amount_rial FROM orders WHERE order_id=?').get(d.order_id);if(!row||row.invoice_id!==d.id||row.amount_rial!==d.amount_rial){db.exec('ROLLBACK');return reply(409,'Order mismatch');}const old=db.prepare('SELECT invoice_id FROM webhook_receipts WHERE event_id=?').get(e.event_id);if(old&&old.invoice_id!==d.id){db.exec('ROLLBACK');return reply(409,'Event conflict');}db.prepare('INSERT OR IGNORE INTO webhook_receipts VALUES(?,?)').run(e.event_id,d.id);db.prepare('UPDATE orders SET paid=1 WHERE order_id=? AND paid=0').run(d.order_id); db.prepare('INSERT OR IGNORE INTO fulfillment_jobs(order_id) VALUES(?)').run(d.order_id); // Insert a unique fulfillment job inside this same transaction. db.exec('COMMIT');return reply(200,'OK');}catch{db.exec('ROLLBACK');return reply(503,'Retry later');} }catch{return reply(503,'Retry later');}}).listen(Number(process.env.PORT||8088),'127.0.0.1');