import json import logging import os import sys import requests from flask import Flask, request, jsonify app = Flask(__name__) # --- Configuration --- WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "your_secret_token_here") BTCPAY_URL = os.getenv("BTCPAY_URL", "https://payment.nxtgroup.org") BTCPAY_API_KEY = os.getenv("BTCPAY_API_KEY") POSTMARK_API_KEY = os.getenv("POSTMARK_API_KEY") FROM_EMAIL = os.getenv("FROM_EMAIL", "billing@nxtgroup.org") BCC_EMAIL = os.getenv("BCC_EMAIL", "admin@nxtgroup.org") DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes") logger = logging.getLogger("btcpaymailer") logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) if not logger.handlers: _handler = logging.StreamHandler(sys.stdout) _handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(_handler) def log_debug(message, **fields): if DEBUG: suffix = f" | {json.dumps(fields, default=str)}" if fields else "" logger.debug(f"{message}{suffix}") def log_evaluation(message, **fields): suffix = f" | {json.dumps(fields, default=str)}" if fields else "" logger.info(f"[evaluation] {message}{suffix}") def get_html_template(order_id, invoice_id, amount, currency, amount_paid, remaining): return f"""

Incomplete Payment Detected

Hi,

We received a payment for your order #{order_id}, but it does not cover the full amount required to complete the purchase.

Total Amount: {amount} {currency}

Amount Paid: {amount_paid} {currency}

Remaining Balance: {remaining} {currency}

To ensure your order is processed, please return to the invoice and pay the remaining balance before the timer expires.

Pay Remaining Balance

Or copy and paste this link into your browser:
{BTCPAY_URL}/i/{invoice_id}

Important: If the full balance is not received before the invoice expires, the order will be cancelled and you will need to contact support for a refund of the partial amount.

Best regards,
NXT Group

""" def send_postmark_email(to_email, subject, html_body): if not POSTMARK_API_KEY: return {"ok": False, "status_code": None, "error": "POSTMARK_API_KEY is not set"} headers = { "Accept": "application/json", "Content-Type": "application/json", "X-Postmark-Server-Token": POSTMARK_API_KEY } payload = { "From": FROM_EMAIL, "To": to_email, "Bcc": BCC_EMAIL, "Subject": subject, "HtmlBody": html_body, "MessageStream": "outbound" } response = requests.post("https://api.postmarkapp.com/email", json=payload, headers=headers) result = {"ok": response.status_code == 200, "status_code": response.status_code} if not result["ok"]: result["error"] = response.text return result def send_test_email(to_email): html_body = get_html_template("TEST-001", "test-invoice-id", 100.0, "USD", 50.0, 50.0) subject = "BTCPay Mailer test email" return send_postmark_email(to_email, subject, html_body) def evaluate_partial_payment(invoice_data, invoice_id): amount = float(invoice_data.get('amount', 0)) amount_paid = float(invoice_data.get('amountPaid', 0)) currency = invoice_data.get('currency', '') status = invoice_data.get('status', '') metadata = invoice_data.get('metadata', {}) buyer_email = metadata.get('buyerEmail') order_id = metadata.get('orderId', invoice_id) is_new = status == 'New' is_partial = 0 < amount_paid < amount qualifies = is_new and is_partial evaluation = { "invoice_id": invoice_id, "order_id": order_id, "status": status, "amount": amount, "amount_paid": amount_paid, "currency": currency, "remaining": round(amount - amount_paid, 8) if amount_paid < amount else 0, "checks": { "status_is_new": is_new, "amount_paid_gt_zero": amount_paid > 0, "amount_paid_lt_total": amount_paid < amount, "is_partial_payment": is_partial, "qualifies_for_email": qualifies, "has_buyer_email": bool(buyer_email), }, "buyer_email": buyer_email, } log_evaluation("invoice evaluated", **evaluation) if not qualifies: reason = [] if not is_new: reason.append(f"status is '{status}', expected 'New'") if amount_paid <= 0: reason.append("no payment received yet") if amount_paid >= amount: reason.append("payment meets or exceeds total") log_evaluation("no email sent", decision="ignored", reasons=reason) return None, evaluation if not buyer_email: log_evaluation("no email sent", decision="rejected", reason="missing metadata.buyerEmail") return "missing_email", evaluation return "send", evaluation @app.route('/btcpay-webhook', methods=['POST']) def btcpay_webhook(): data = request.json or {} event_type = data.get('type') log_debug( "webhook received", event_type=event_type, store_id=data.get('storeId'), invoice_id=data.get('invoiceId'), delivery_id=data.get('deliveryId'), is_redelivery=data.get('isRedelivery'), payload=data, ) token = request.args.get('token') if token != WEBHOOK_SECRET: log_debug("webhook rejected", reason="invalid token") return jsonify({"error": "Unauthorized"}), 401 if event_type != 'InvoiceReceivedPayment': log_debug("webhook ignored", reason="not InvoiceReceivedPayment", event_type=event_type) return jsonify({"status": "ignored", "reason": "Not a payment event"}), 200 store_id = data.get('storeId') invoice_id = data.get('invoiceId') log_debug("fetching invoice from BTCPay", store_id=store_id, invoice_id=invoice_id) headers = {"Authorization": f"token {BTCPAY_API_KEY}"} invoice_resp = requests.get( f"{BTCPAY_URL}/api/v1/stores/{store_id}/invoices/{invoice_id}", headers=headers, ) if invoice_resp.status_code != 200: logger.error( "BTCPay API error | %s", json.dumps({ "store_id": store_id, "invoice_id": invoice_id, "status_code": invoice_resp.status_code, "body": invoice_resp.text, }), ) return jsonify({"error": "Failed to fetch invoice"}), 500 invoice_data = invoice_resp.json() log_debug("invoice fetched", invoice_id=invoice_id, status=invoice_data.get('status')) action, evaluation = evaluate_partial_payment(invoice_data, invoice_id) if action is None: return jsonify({"status": "ignored", "reason": "Payment meets total or invoice not in New state"}), 200 if action == "missing_email": return jsonify({"error": "No buyer email associated with invoice"}), 400 buyer_email = evaluation["buyer_email"] order_id = evaluation["order_id"] remaining = evaluation["remaining"] amount = evaluation["amount"] amount_paid = evaluation["amount_paid"] currency = evaluation["currency"] html_body = get_html_template(order_id, invoice_id, amount, currency, amount_paid, remaining) subject = f"Action Required: Partial payment received for Order #{order_id}" log_evaluation("sending email", to=buyer_email, order_id=order_id, bcc=BCC_EMAIL) result = send_postmark_email(buyer_email, subject, html_body) if result["ok"]: log_evaluation("email sent", to=buyer_email, order_id=order_id, postmark_status=result["status_code"]) return jsonify({"status": "success", "message": "Partial payment email sent"}), 200 log_evaluation("email failed", to=buyer_email, order_id=order_id, postmark_result=result) return jsonify({"error": "Failed to send email via Postmark", "details": result}), 500 @app.route('/test-email', methods=['POST']) def test_email(): token = request.args.get('token') if token != WEBHOOK_SECRET: return jsonify({"error": "Unauthorized"}), 401 to_email = request.args.get('to') or request.json.get('to') if request.is_json else None if not to_email: return jsonify({"error": "Missing 'to' email address"}), 400 result = send_test_email(to_email) if result["ok"]: logger.info("test email sent | %s", json.dumps({"to": to_email, "from": FROM_EMAIL, "bcc": BCC_EMAIL})) return jsonify({ "status": "success", "message": f"Test email sent to {to_email}", "from": FROM_EMAIL, "bcc": BCC_EMAIL, }), 200 logger.error("test email failed | %s", json.dumps({"to": to_email, "result": result})) return jsonify({"error": "Failed to send test email via Postmark", "details": result}), 500 if __name__ == '__main__': if len(sys.argv) >= 3 and sys.argv[1] == 'test-email': to = sys.argv[2] outcome = send_test_email(to) if outcome["ok"]: print(f"OK: test email sent to {to} (from {FROM_EMAIL}, bcc {BCC_EMAIL})") sys.exit(0) print(f"FAIL: {outcome}", file=sys.stderr) sys.exit(1) app.run(host='0.0.0.0', port=5000)