commit 41d264d8a5a6a12af68328237b662a39000aacab Author: Erling Date: Mon Jun 8 11:52:49 2026 +0200 Initial commit: BTCPay partial payment mailer Flask webhook service that emails buyers via Postmark when BTCPay receives a partial payment. Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b2fb07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +.env +.venv/ +venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c57203b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +EXPOSE 5000 +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"] \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..e1fd7c4 --- /dev/null +++ b/app.py @@ -0,0 +1,133 @@ +import os +import requests +from flask import Flask, request, jsonify + +app = Flask(__name__) + +# --- Configuration --- +# Ensure these are passed as environment variables +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") # Needs 'btcpay.store.canviewinvoices' permission +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") + +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.

+ + + +

+ 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): + 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) + return response.status_code == 200 + +@app.route('/btcpay-webhook', methods=['POST']) +def btcpay_webhook(): + # 1. Verify Secret Token + token = request.args.get('token') + if token != WEBHOOK_SECRET: + return jsonify({"error": "Unauthorized"}), 401 + + data = request.json + + # 2. Only process 'InvoiceReceivedPayment' events + if data.get('type') != 'InvoiceReceivedPayment': + return jsonify({"status": "ignored", "reason": "Not a payment event"}), 200 + + store_id = data.get('storeId') + invoice_id = data.get('invoiceId') + + # 3. Fetch full invoice details from BTCPay API + 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: + return jsonify({"error": "Failed to fetch invoice"}), 500 + + invoice_data = invoice_resp.json() + + 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) + + # 4. Check if it's a partial payment on an active invoice + if status == 'New' and 0 < amount_paid < amount: + if not buyer_email: + return jsonify({"error": "No buyer email associated with invoice"}), 400 + + remaining = round(amount - amount_paid, 8) + + # Generate HTML and Send Email + 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}" + + email_sent = send_postmark_email(buyer_email, subject, html_body) + + if email_sent: + return jsonify({"status": "success", "message": "Partial payment email sent"}), 200 + else: + return jsonify({"error": "Failed to send email via Postmark"}), 500 + + return jsonify({"status": "ignored", "reason": "Payment meets total or invoice not in New state"}), 200 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b4dca6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.8' +services: + btcpay-mailer: + build: . + container_name: btcpay-mailer + restart: unless-stopped + ports: + - "5000:5000" + environment: + - WEBHOOK_SECRET=your_super_secret_token_123 + - BTCPAY_URL=https://payment.nxtgroup.org + - BTCPAY_API_KEY=your_btcpay_api_key + - POSTMARK_API_KEY=your_postmark_server_token + - FROM_EMAIL=billing@nxtgroup.org + - BCC_EMAIL=noreply@nxtgroup.org \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5e6d320 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +requests==2.31.0 +gunicorn==22.0.0 \ No newline at end of file