Initial commit: BTCPay partial payment mailer
Flask webhook service that emails buyers via Postmark when BTCPay receives a partial payment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
41d264d8a5
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@ -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"]
|
||||
133
app.py
Normal file
133
app.py
Normal file
@ -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"""
|
||||
<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):
|
||||
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)
|
||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@ -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
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
Flask==3.0.3
|
||||
requests==2.31.0
|
||||
gunicorn==22.0.0
|
||||
Loading…
Reference in New Issue
Block a user