diff --git a/.env.example b/.env.example index 74b212d..5870427 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,8 @@ POSTMARK_API_KEY=your_postmark_server_token # Optional BTCPAY_URL=https://payment.nxtgroup.org -FROM_EMAIL=billing@nxtgroup.org +FROM_NAME=NXT Payments +FROM_EMAIL=noreply@nxtgroup.org BCC_EMAIL=admin@nxtgroup.org,finance@nxtgroup.org WEBHOOK_TEST_EMAIL=erling@nxtgroup.org DEBUG=false diff --git a/SETUP.md b/SETUP.md index 3ff23eb..44283e1 100644 --- a/SETUP.md +++ b/SETUP.md @@ -7,7 +7,8 @@ One mailer instance handles **multiple BTCPay stores** on the same server (`paym ## 1. Postmark 1. Copy **Server API token** → `POSTMARK_API_KEY` -2. Verify sender → `FROM_EMAIL` (e.g. `billing@nxtgroup.org`) +2. Verify sender → `FROM_EMAIL` (e.g. `noreply@nxtgroup.org`) +3. Optional display name → `FROM_NAME` (e.g. `NXT Payments` → sends as `NXT Payments `) 3. Set `BCC_EMAIL` (comma-separated, optional) ## 2. BTCPay API key (all stores) @@ -46,7 +47,8 @@ services: BTCPAY_URL: https://payment.nxtgroup.org BTCPAY_API_KEY: PASTE_BTCPAY_API_KEY POSTMARK_API_KEY: PASTE_POSTMARK_TOKEN - FROM_EMAIL: billing@nxtgroup.org + FROM_NAME: NXT Payments + FROM_EMAIL: noreply@nxtgroup.org BCC_EMAIL: admin@nxtgroup.org DEBUG: "true" networks: diff --git a/app.py b/app.py index f99e46e..9a1ab35 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,7 @@ TEST_TOKEN = os.getenv("TEST_TOKEN") 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_NAME = os.getenv("FROM_NAME", "") FROM_EMAIL = os.getenv("FROM_EMAIL", "billing@nxtgroup.org") BCC_EMAIL = os.getenv("BCC_EMAIL", "admin@nxtgroup.org") WEBHOOK_TEST_EMAIL = os.getenv("WEBHOOK_TEST_EMAIL") @@ -72,7 +73,119 @@ def get_webhook_test_recipient(): return FROM_EMAIL +def get_from_address(): + if FROM_NAME: + return f"{FROM_NAME} <{FROM_EMAIL}>" + return FROM_EMAIL + + +def btcpay_headers(): + return {"Authorization": f"token {BTCPAY_API_KEY}"} + + +def format_crypto_amount(value): + return f"{float(value):.8f}".rstrip("0").rstrip(".") + + +def pick_btc_payment_method(payment_methods): + if not payment_methods: + return None + + for method in payment_methods: + payment_method_id = (method.get("paymentMethodId") or "").upper() + currency = (method.get("currency") or "").upper() + if payment_method_id == "BTC" or currency == "BTC": + return method + + for method in payment_methods: + payment_method_id = (method.get("paymentMethodId") or "").upper() + if payment_method_id.startswith("BTC"): + return method + + return payment_methods[0] + + +def extract_payment_amounts(invoice_data, payment_methods): + method = pick_btc_payment_method(payment_methods) + if method: + amount = float(method.get("amount", 0)) + amount_paid = float(method.get("totalPaid", 0)) + remaining = float(method.get("due", 0)) + if remaining <= 0 and amount > amount_paid: + remaining = round(amount - amount_paid, 8) + currency = method.get("currency") or "BTC" + source = "payment_method" + else: + amount = float(invoice_data.get("amount", 0)) + amount_paid = float(invoice_data.get("amountPaid", 0)) + remaining = round(amount - amount_paid, 8) if amount_paid < amount else 0 + currency = invoice_data.get("currency", "") + source = "invoice_fallback" + + return { + "amount": amount, + "amount_paid": amount_paid, + "remaining": remaining, + "currency": currency, + "source": source, + "payment_method_id": method.get("paymentMethodId") if method else None, + } + + +def fetch_btcpay_invoice(store_id, invoice_id): + response = requests.get( + f"{BTCPAY_URL}/api/v1/stores/{store_id}/invoices/{invoice_id}", + headers=btcpay_headers(), + ) + return response + + +def fetch_btcpay_payment_methods(store_id, invoice_id): + response = requests.get( + f"{BTCPAY_URL}/api/v1/stores/{store_id}/invoices/{invoice_id}/payment-methods", + headers=btcpay_headers(), + ) + return response + + +def log_invoice_api_response(invoice_id, invoice_data, payment_methods, amounts): + invoice_summary = { + "invoice_id": invoice_id, + "status": invoice_data.get("status"), + "additional_status": invoice_data.get("additionalStatus"), + "fiat_amount": invoice_data.get("amount"), + "fiat_amount_paid": invoice_data.get("amountPaid"), + "fiat_currency": invoice_data.get("currency"), + } + payment_summary = [ + { + "paymentMethodId": method.get("paymentMethodId"), + "currency": method.get("currency"), + "amount": method.get("amount"), + "totalPaid": method.get("totalPaid"), + "due": method.get("due"), + } + for method in (payment_methods or []) + ] + + log_debug("BTCPay invoice API response", **invoice_summary) + log_debug("BTCPay payment-methods API response", invoice_id=invoice_id, payment_methods=payment_summary) + log_evaluation( + "amounts resolved for email", + invoice_id=invoice_id, + source=amounts["source"], + payment_method_id=amounts["payment_method_id"], + total=amounts["amount"], + paid=amounts["amount_paid"], + remaining=amounts["remaining"], + currency=amounts["currency"], + ) + + def get_html_template(order_id, invoice_id, amount, currency, amount_paid, remaining, test_mode=False): + total_display = format_crypto_amount(amount) + paid_display = format_crypto_amount(amount_paid) + remaining_display = format_crypto_amount(remaining) test_banner = "" if test_mode: test_banner = f""" @@ -91,11 +204,12 @@ def get_html_template(order_id, invoice_id, amount, currency, amount_paid, remai

Hi,

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

+

This usually happens when you didn't enter the Bitcoin amount on the payment vendor, or didn't use the correct bitcoin amount.

-

Total Amount: {amount} {currency}

-

Amount Paid: {amount_paid} {currency}

-

Remaining Balance: {remaining} {currency}

+

Total Amount (Bitcoin): {total_display} {currency}

+

Amount Paid (Bitcoin): {paid_display} {currency}

+

Remaining Balance (Bitcoin): {remaining_display} {currency}

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

@@ -109,6 +223,9 @@ def get_html_template(order_id, invoice_id, amount, currency, amount_paid, remai {BTCPAY_URL}/i/{invoice_id}

+

Alternatively, you can contact us on webchat on the store you placed the order on. We can for instance, manually add a lower time amount to the subscription.

+ +

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. @@ -134,7 +251,7 @@ def send_postmark_email(to_email, subject, html_body): "X-Postmark-Server-Token": POSTMARK_API_KEY } payload = { - "From": FROM_EMAIL, + "From": get_from_address(), "To": to_email, "Bcc": BCC_EMAIL, "Subject": subject, @@ -150,7 +267,7 @@ def send_postmark_email(to_email, subject, html_body): def send_test_email(to_email): - html_body = get_html_template("TEST-001", "test-invoice-id", 100.0, "USD", 50.0, 50.0, test_mode=True) + html_body = get_html_template("TEST-001", "test-invoice-id", 0.001, "BTC", 0.0005, 0.0005, test_mode=True) subject = f"[TEST] {TEST_EMAIL_BANNER}" return send_postmark_email(to_email, subject, html_body) @@ -162,7 +279,7 @@ def handle_btcpay_test_webhook(invoice_id): invoice_id=invoice_id, to=recipient, ) - html_body = get_html_template("TEST-001", invoice_id, 100.0, "USD", 50.0, 50.0, test_mode=True) + html_body = get_html_template("TEST-001", invoice_id, 0.001, "BTC", 0.0005, 0.0005, test_mode=True) subject = f"[TEST] {TEST_EMAIL_BANNER}" result = send_postmark_email(recipient, subject, html_body) if result["ok"]: @@ -176,10 +293,11 @@ def handle_btcpay_test_webhook(invoice_id): return jsonify({"error": "Failed to send test email via Postmark", "details": result}), 500 -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', '') +def evaluate_partial_payment(invoice_data, invoice_id, amounts): + amount = amounts["amount"] + amount_paid = amounts["amount_paid"] + currency = amounts["currency"] + remaining = amounts["remaining"] status = invoice_data.get('status', '') metadata = invoice_data.get('metadata', {}) buyer_email = metadata.get('buyerEmail') @@ -196,7 +314,9 @@ def evaluate_partial_payment(invoice_data, invoice_id): "amount": amount, "amount_paid": amount_paid, "currency": currency, - "remaining": round(amount - amount_paid, 8) if amount_paid < amount else 0, + "remaining": remaining, + "amount_source": amounts["source"], + "payment_method_id": amounts["payment_method_id"], "checks": { "status_is_new": is_new, "amount_paid_gt_zero": amount_paid > 0, @@ -267,16 +387,12 @@ def btcpay_webhook(): 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, - ) - + invoice_resp = fetch_btcpay_invoice(store_id, invoice_id) if invoice_resp.status_code != 200: logger.error( "BTCPay API error | %s", json.dumps({ + "endpoint": "invoice", "store_id": store_id, "invoice_id": invoice_id, "status_code": invoice_resp.status_code, @@ -286,9 +402,26 @@ def btcpay_webhook(): 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) + payment_methods_resp = fetch_btcpay_payment_methods(store_id, invoice_id) + if payment_methods_resp.status_code != 200: + logger.error( + "BTCPay API error | %s", + json.dumps({ + "endpoint": "payment-methods", + "store_id": store_id, + "invoice_id": invoice_id, + "status_code": payment_methods_resp.status_code, + "body": payment_methods_resp.text, + }), + ) + return jsonify({"error": "Failed to fetch invoice payment methods"}), 500 + + payment_methods = payment_methods_resp.json() + amounts = extract_payment_amounts(invoice_data, payment_methods) + log_invoice_api_response(invoice_id, invoice_data, payment_methods, amounts) + + action, evaluation = evaluate_partial_payment(invoice_data, invoice_id, amounts) if action is None: return jsonify({"status": "ignored", "reason": "Payment meets total or invoice not in New state"}), 200 @@ -329,11 +462,11 @@ def test_email(): 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})) + logger.info("test email sent | %s", json.dumps({"to": to_email, "from": get_from_address(), "bcc": BCC_EMAIL})) return jsonify({ "status": "success", "message": f"Test email sent to {to_email}", - "from": FROM_EMAIL, + "from": get_from_address(), "bcc": BCC_EMAIL, }), 200 logger.error("test email failed | %s", json.dumps({"to": to_email, "result": result})) @@ -345,7 +478,7 @@ if __name__ == '__main__': 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})") + print(f"OK: test email sent to {to} (from {get_from_address()}, bcc {BCC_EMAIL})") sys.exit(0) print(f"FAIL: {outcome}", file=sys.stderr) sys.exit(1) diff --git a/docker-compose.yml b/docker-compose.yml index 9474749..9de38fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: BTCPAY_URL: ${BTCPAY_URL:-https://payment.nxtgroup.org} BTCPAY_API_KEY: ${BTCPAY_API_KEY:?Set BTCPAY_API_KEY in .env or environment} POSTMARK_API_KEY: ${POSTMARK_API_KEY:?Set POSTMARK_API_KEY in .env or environment} + FROM_NAME: ${FROM_NAME:-} FROM_EMAIL: ${FROM_EMAIL:-billing@nxtgroup.org} BCC_EMAIL: ${BCC_EMAIL:-admin@nxtgroup.org} WEBHOOK_TEST_EMAIL: ${WEBHOOK_TEST_EMAIL:-}