Log partial-payment decisions and email results to stdout for Portainer logs; DEBUG enables full webhook payload tracing. Document BTCPay payload and shutdown steps in WEBHOOK.md. Co-authored-by: Cursor <cursoragent@cursor.com>
280 lines
11 KiB
Python
280 lines
11 KiB
Python
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"""
|
|
<div style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; background-color: #f4f5f7; padding: 40px 20px; color: #333333; line-height: 1.6;">
|
|
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.05);">
|
|
|
|
<div style="background-color: #f59e0b; padding: 25px 30px; text-align: center;">
|
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600;">Incomplete Payment Detected</h1>
|
|
</div>
|
|
|
|
<div style="padding: 30px;">
|
|
<p style="margin-top: 0; font-size: 16px;">Hi,</p>
|
|
<p style="font-size: 16px;">We received a payment for your order <strong>#{order_id}</strong>, but it does not cover the full amount required to complete the purchase.</p>
|
|
|
|
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 15px 20px; margin: 25px 0;">
|
|
<p style="margin: 0 0 8px 0; font-size: 16px;"><strong>Total Amount:</strong> {amount} {currency}</p>
|
|
<p style="margin: 0 0 8px 0; font-size: 16px;"><strong>Amount Paid:</strong> {amount_paid} {currency}</p>
|
|
<p style="margin: 0; font-size: 18px; color: #b45309;"><strong>Remaining Balance:</strong> {remaining} {currency}</p>
|
|
</div>
|
|
|
|
<p style="font-size: 16px;">To ensure your order is processed, please return to the invoice and pay the remaining balance before the timer expires.</p>
|
|
|
|
<div style="text-align: center; margin: 30px 0;">
|
|
<a href="{BTCPAY_URL}/i/{invoice_id}" target="_blank" style="background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 28px; border-radius: 6px; font-weight: bold; font-size: 16px; display: inline-block;">Pay Remaining Balance</a>
|
|
</div>
|
|
|
|
<p style="font-size: 14px; color: #6b7280; word-break: break-all; text-align: center;">
|
|
Or copy and paste this link into your browser:<br>
|
|
<a href="{BTCPAY_URL}/i/{invoice_id}" style="color: #2563eb; text-decoration: underline;">{BTCPAY_URL}/i/{invoice_id}</a>
|
|
</p>
|
|
|
|
<div style="background-color: #fef2f2; border: 1px solid #fee2e2; border-left: 4px solid #ef4444; padding: 15px; border-radius: 4px; margin-top: 30px;">
|
|
<p style="margin: 0; font-size: 14px; color: #991b1b;">
|
|
<strong>Important:</strong> 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.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="background-color: #f9fafb; padding: 20px 30px; border-top: 1px solid #e5e7eb; text-align: center;">
|
|
<p style="margin: 0; font-size: 14px; color: #6b7280;">Best regards,<br><strong style="color: #374151;">NXT Group</strong></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
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)
|