"""Local unified acquire demo. Run: python receiver.py --init-demo; python receiver.py Set DEMO_APP_ID, DEMO_SIGN_KEY, DEMO_SIGN_TYPE (MD5/HMAC-SHA256), DEMO_DB if needed. Only --init-demo seeds orders. Adapt order ownership and wallet model for production. """ import hashlib import hmac import json import os import sqlite3 import sys from contextlib import closing from decimal import Decimal from datetime import datetime from http.server import BaseHTTPRequestHandler, HTTPServer APP = os.environ.get("DEMO_APP_ID", "appid_demo_001") KEY = os.environ.get("DEMO_SIGN_KEY", "DOC_EXAMPLE_KEY") ALGORITHM = os.environ.get("DEMO_SIGN_TYPE", "MD5") DB = os.environ.get("DEMO_DB", "merchant-demo.sqlite") SAFE = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!()*-._" def form_encode(value): return "".join(chr(b) if b in SAFE else "+" if b == 32 else f"%{b:02X}" for b in str(value).encode("utf-8")) def canonical(payload): # Current acquire notification is flat. Keep 0/empty strings; omit only null/sign. fields = {k.lower(): v for k, v in payload.items() if k.lower() != "sign" and v is not None} if len(fields) != sum(k.lower() != "sign" and v is not None for k, v in payload.items()): raise ValueError("duplicate signature field") if any(isinstance(v, (dict, list, bool)) for v in fields.values()): raise ValueError("unexpected notification field type") # Existing platform DateTime signature convention (unchanged by this feature). for name in ("createdtime", "updatedtime"): if fields.get(name): date = datetime.fromisoformat(fields[name].replace("Z", "+00:00")) fields[name] = f"{date.month:02d}/{date.day:02d}/{date.year:04d} {date.hour:02d}:{date.minute:02d}:{date.second:02d}" return "&".join(k + "=" + form_encode(fields[k]) for k in sorted(fields)) def signature(payload): base = canonical(payload) if ALGORITHM == "MD5": return hashlib.md5((base + "&key=" + KEY).encode()).hexdigest() if ALGORITHM == "HMAC-SHA256": return hmac.new(KEY.encode(), base.encode(), hashlib.sha256).hexdigest().upper() raise ValueError("unsupported configured algorithm") def connection(): db = sqlite3.connect(DB, timeout=10, isolation_level=None) db.execute("PRAGMA foreign_keys=ON") return db def init_demo(): with closing(connection()) as db: db.executescript(""" CREATE TABLE IF NOT EXISTS wallets(user_id TEXT PRIMARY KEY, balance INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS orders(app_id TEXT, merchant_order TEXT, platform_order TEXT, user_id TEXT REFERENCES wallets(user_id), currency TEXT, target INTEGER, PRIMARY KEY(app_id,merchant_order)); CREATE TABLE IF NOT EXISTS payments(app_id TEXT, payment_no TEXT NOT NULL, merchant_order TEXT, snapshot TEXT, amount INTEGER NOT NULL, credited INTEGER NOT NULL, PRIMARY KEY(app_id,payment_no,merchant_order)); """) db.execute("INSERT OR IGNORE INTO wallets VALUES('USER_DEMO',0)") for merchant, platform, target in (("ORD_DEMO_50", "P_DEMO_50", 5000), ("ORD_DEMO_150", "P_DEMO_150", 15000), ("ORD_DEMO_NORMAL", "P_DEMO_NORMAL", 5000)): db.execute("INSERT OR IGNORE INTO orders VALUES(?,?,?,?,?,?)", (APP, merchant, platform, "USER_DEMO", "MXN", target)) def apply(payload): if payload["appId"] != APP or not hmac.compare_digest( str(payload["sign"]).upper(), signature(payload).upper()): raise ValueError("invalid app/signature") if payload["status"] not in ("SUCCESS", "FAILED"): raise ValueError("unsupported notification") # Verify the original payload first. Only then normalize old missing/null paymentNo. payload = dict(payload) payload["paymentNo"] = "" if payload.get("paymentNo") is None else payload["paymentNo"] if type(payload["paymentNo"]) is not str: raise ValueError("invalid payment identity") success = payload["status"] == "SUCCESS" integers = ("amount", "paidAmount", "totalPaidAmount", "actualAmount", "feeAmount", "merchantCostAmount") if any(payload.get(k) is not None and (type(payload[k]) is not int or not 0 <= payload[k] <= 9223372036854775807) for k in integers): raise ValueError("integer cents required") if payload.get("totalPaidAmount") is None: payload["totalPaidAmount"] = payload["paidAmount"] if success else 0 if success and not (payload["paidAmount"] > 0 and payload["totalPaidAmount"] >= payload["paidAmount"]): raise ValueError("inconsistent amounts") # Compare success money and ownership; ignore transient envelope/display text. immutable = ("appId", "paymentNo", "merchantOrderNo", "platformOrderNo", "amount", "paidAmount", "currency", "actualAmount", "feeAmount", "feeCurrency", "merchantCostAmount", "merchantCostCurrency", "totalPaidAmount") snapshot = canonical({k: payload[k] for k in immutable if k in payload}) db = connection() try: db.execute("BEGIN IMMEDIATE") order = db.execute("SELECT platform_order,user_id,currency,target FROM orders WHERE app_id=? AND merchant_order=?", (APP, payload["merchantOrderNo"])).fetchone() if not order or (order[0], order[2], order[3]) != ( payload["platformOrderNo"], payload["currency"], payload["amount"]): raise ValueError("order identity mismatch") identity = (APP, payload["paymentNo"], payload["merchantOrderNo"]) payment = db.execute("SELECT snapshot,credited FROM payments WHERE app_id=? AND payment_no=? AND merchant_order=?", identity).fetchone() if success and payment and payment[1] and payment[0] != snapshot: raise ValueError("duplicate identity with conflicting business snapshot") if not payment: db.execute("INSERT INTO payments VALUES(?,?,?,NULL,0,0)", identity) if success and (not payment or not payment[1]): # Unique posting marker and wallet credit commit in the same transaction. db.execute("UPDATE payments SET snapshot=?,amount=?,credited=1 WHERE app_id=? AND payment_no=? AND merchant_order=?", (snapshot, payload["paidAmount"], *identity)) if db.execute("UPDATE wallets SET balance=balance+? WHERE user_id=?", (payload["paidAmount"], order[1])).rowcount != 1: raise ValueError("wallet missing") db.commit() except Exception: db.rollback() raise finally: db.close() class Handler(BaseHTTPRequestHandler): def do_POST(self): try: if self.path != "/merchant/pay-notify": self.send_error(404) return size = int(self.headers.get("Content-Length", "0")) if not 0 < size <= 65536: raise ValueError("invalid request size") apply(json.loads(self.rfile.read(size), parse_float=Decimal)) except (ValueError, KeyError, TypeError): self.send_error(400, "invalid notification") return except Exception: self.send_error(500, "notification not committed") return self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() self.wfile.write(b"success") if __name__ == "__main__": if "--init-demo" in sys.argv: init_demo() else: HTTPServer(("127.0.0.1", 8088), Handler).serve_forever()