Jun 28 - Update and polish UI/UX
This commit is contained in:
+48
-58
@@ -2,98 +2,89 @@
|
||||
app/billing/emails.py
|
||||
---------------------
|
||||
Dunning / billing lifecycle emails sent via Flask-Mail in a background thread.
|
||||
|
||||
Pattern mirrors app/utils/notifications.py: captures the app object before
|
||||
spawning the thread so the mail context is valid inside the worker.
|
||||
|
||||
Public API:
|
||||
send_billing_email(to_addr, event_type, context_dict)
|
||||
event_type in ('payment_failed', 'trial_ending', 'subscription_cancelled')
|
||||
Sends multipart HTML + plain-text messages using Jinja2 templates in
|
||||
app/templates/billing/email/.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from flask import current_app
|
||||
from flask import current_app, render_template
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Email templates ────────────────────────────────────────────────────────────
|
||||
|
||||
_SUBJECTS = {
|
||||
'payment_failed': 'Action Required: Payment failed for your JQC subscription',
|
||||
'trial_ending': 'Your JQC free trial ends in 3 days',
|
||||
'subscription_cancelled': 'Your JQC subscription has been cancelled',
|
||||
'payment_failed': 'Action Required: Payment failed for your JQC subscription',
|
||||
'trial_ending': 'Your JQC free trial ends in 3 days',
|
||||
'subscription_cancelled': 'Your JQC subscription has been cancelled',
|
||||
}
|
||||
|
||||
_BODIES = {
|
||||
'payment_failed': """\
|
||||
Hi,
|
||||
_HTML_TEMPLATES = {
|
||||
'payment_failed': 'billing/email/payment_failed.html',
|
||||
'trial_ending': 'billing/email/trial_ending.html',
|
||||
'subscription_cancelled': 'billing/email/subscription_cancelled.html',
|
||||
}
|
||||
|
||||
We were unable to process your most recent payment for your JQC subscription.
|
||||
|
||||
Please update your payment method to avoid interruption to your service:
|
||||
{portal_url}
|
||||
|
||||
If you have any questions, please contact support.
|
||||
|
||||
— The JQC Team
|
||||
""",
|
||||
|
||||
'trial_ending': """\
|
||||
Hi,
|
||||
|
||||
Your JQC free trial will end on {trial_ends_at}. After that date, you will
|
||||
need an active subscription to continue using the service.
|
||||
|
||||
Subscribe now to keep your workspace active:
|
||||
{subscribe_url}
|
||||
|
||||
— The JQC Team
|
||||
""",
|
||||
|
||||
'subscription_cancelled': """\
|
||||
Hi,
|
||||
|
||||
Your JQC subscription has been cancelled and your workspace access has
|
||||
been suspended. You can reactivate your subscription at any time:
|
||||
{portal_url}
|
||||
|
||||
— The JQC Team
|
||||
""",
|
||||
_PLAIN_BODIES = {
|
||||
'payment_failed': (
|
||||
"We were unable to process your most recent payment for your JQC subscription.\n\n"
|
||||
"Please update your payment method to avoid interruption to your service:\n"
|
||||
" {portal_url}\n\n— The JQC Team"
|
||||
),
|
||||
'trial_ending': (
|
||||
"Your JQC free trial will end on {trial_ends_at}.\n\n"
|
||||
"Subscribe now to keep your workspace active:\n"
|
||||
" {subscribe_url}\n\n— The JQC Team"
|
||||
),
|
||||
'subscription_cancelled': (
|
||||
"Your JQC subscription has been cancelled and your workspace access has been suspended.\n\n"
|
||||
"You can reactivate at any time:\n"
|
||||
" {portal_url}\n\n— The JQC Team"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
|
||||
"""Send a billing lifecycle email in a background thread.
|
||||
"""Send a billing lifecycle HTML+plain email in a background thread.
|
||||
|
||||
Args:
|
||||
to_addr: Recipient email address (tenant admin or billing contact).
|
||||
event_type: Key in _SUBJECTS / _BODIES.
|
||||
context_dict: Variables substituted into the body template.
|
||||
to_addr: Recipient email address.
|
||||
event_type: One of 'payment_failed', 'trial_ending', 'subscription_cancelled'.
|
||||
context_dict: Variables substituted into both the HTML template and plain body.
|
||||
"""
|
||||
if not to_addr:
|
||||
logger.warning('BILLING EMAIL | skipped | event=%s | reason=no_recipient', event_type)
|
||||
return
|
||||
|
||||
subject = _SUBJECTS.get(event_type, 'JQC Billing Notification')
|
||||
body_template = _BODIES.get(event_type, '')
|
||||
plain_tmpl = _PLAIN_BODIES.get(event_type, '')
|
||||
html_tmpl_path = _HTML_TEMPLATES.get(event_type)
|
||||
|
||||
try:
|
||||
body = body_template.format(**context_dict)
|
||||
plain_body = plain_tmpl.format(**context_dict)
|
||||
except KeyError as exc:
|
||||
logger.error('BILLING EMAIL | template_error | event=%s | missing_key=%s', event_type, exc)
|
||||
body = body_template # send with unfilled placeholders rather than crashing
|
||||
logger.error('BILLING EMAIL | plain_template_error | event=%s | key=%s', event_type, exc)
|
||||
plain_body = plain_tmpl
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def _send():
|
||||
try:
|
||||
with app.app_context():
|
||||
html_body = None
|
||||
if html_tmpl_path:
|
||||
try:
|
||||
html_body = render_template(html_tmpl_path, **context_dict)
|
||||
except Exception as exc:
|
||||
logger.error('BILLING EMAIL | html_render_failed | tmpl=%s | err=%s',
|
||||
html_tmpl_path, exc)
|
||||
|
||||
msg = Message(
|
||||
subject=subject,
|
||||
recipients=[to_addr],
|
||||
body=body,
|
||||
body=plain_body,
|
||||
html=html_body,
|
||||
)
|
||||
mail.send(msg)
|
||||
logger.info('BILLING EMAIL | sent | event=%s | to=%s', event_type, to_addr)
|
||||
@@ -101,5 +92,4 @@ def send_billing_email(to_addr: str, event_type: str, context_dict: dict):
|
||||
logger.error('BILLING EMAIL | send_failed | event=%s | to=%s | err=%s',
|
||||
event_type, to_addr, exc)
|
||||
|
||||
thread = threading.Thread(target=_send, daemon=True)
|
||||
thread.start()
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
@@ -231,12 +231,12 @@ def plan():
|
||||
subscription_status = None
|
||||
trial_ends_at = None
|
||||
has_stripe_customer = False
|
||||
invoices = []
|
||||
if _mt_enabled():
|
||||
tenant_ctx = getattr(g, 'tenant', None)
|
||||
if tenant_ctx is not None:
|
||||
subscription_status = tenant_ctx.subscription_status
|
||||
trial_ends_at = tenant_ctx.trial_ends_at
|
||||
# Check whether stripe_customer_id is set (for "Manage Billing" link).
|
||||
if billing_enabled:
|
||||
try:
|
||||
from control.base import control_session
|
||||
@@ -244,8 +244,32 @@ def plan():
|
||||
with control_session() as s:
|
||||
t = s.get(ControlTenant, tenant_ctx.id)
|
||||
has_stripe_customer = bool(t and t.stripe_customer_id)
|
||||
stripe_customer_id = t.stripe_customer_id if t else None
|
||||
except Exception:
|
||||
pass
|
||||
stripe_customer_id = None
|
||||
|
||||
# Fetch last 10 invoices from Stripe.
|
||||
if stripe_customer_id:
|
||||
try:
|
||||
import stripe as _stripe
|
||||
_stripe.api_key = current_app.config.get('STRIPE_SECRET_KEY', '')
|
||||
raw = _stripe.Invoice.list(customer=stripe_customer_id, limit=10)
|
||||
for inv in raw.auto_paging_iter():
|
||||
import datetime as _dt
|
||||
invoices.append({
|
||||
'id': inv.id,
|
||||
'number': inv.number or inv.id,
|
||||
'date': _dt.datetime.fromtimestamp(inv.created).strftime('%b %d, %Y'),
|
||||
'amount': '${:,.2f}'.format(inv.amount_paid / 100),
|
||||
'currency': (inv.currency or 'usd').upper(),
|
||||
'status': inv.status,
|
||||
'pdf_url': inv.invoice_pdf,
|
||||
'hosted_url': inv.hosted_invoice_url,
|
||||
})
|
||||
if len(invoices) >= 10:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.error('tenant_settings.plan: stripe invoice fetch failed: %s', exc)
|
||||
|
||||
return render_template(
|
||||
'tenant_settings/plan.html',
|
||||
@@ -255,6 +279,7 @@ def plan():
|
||||
subscription_status=subscription_status,
|
||||
trial_ends_at=trial_ends_at,
|
||||
has_stripe_customer=has_stripe_customer,
|
||||
invoices=invoices,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{ subject }}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f6f7f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f6f7f9;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);overflow:hidden;max-width:100%;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:#1a56db;padding:24px 32px;">
|
||||
<span style="color:#fff;font-size:1.1rem;font-weight:700;letter-spacing:-.01em;">
|
||||
✓ Janitorial QC
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:32px;">
|
||||
{% block body %}{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background:#f6f7f9;padding:20px 32px;border-top:1px solid #e5e7eb;">
|
||||
<p style="margin:0;font-size:.8rem;color:#9ca3af;line-height:1.5;">
|
||||
You received this email because you are the billing contact for your
|
||||
JQC workspace. If you have questions, reply to this email or contact
|
||||
<a href="mailto:support@jqc.app" style="color:#1a56db;text-decoration:none;">support@jqc.app</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "billing/email/base.html" %}
|
||||
{% set subject = "Action Required: Payment failed for your JQC subscription" %}
|
||||
{% block body %}
|
||||
<h2 style="margin:0 0 8px;font-size:1.2rem;color:#111827;">Payment failed</h2>
|
||||
<p style="margin:0 0 20px;color:#6b7280;font-size:.9rem;">We were unable to process your most recent payment.</p>
|
||||
|
||||
<table width="100%" cellpadding="12" cellspacing="0" style="background:#fef3c7;border-radius:6px;margin-bottom:24px;">
|
||||
<tr>
|
||||
<td>
|
||||
<p style="margin:0;font-size:.9rem;color:#92400e;line-height:1.5;">
|
||||
<strong>⚠ Your subscription may be suspended</strong> if the payment is not resolved.
|
||||
Please update your payment method as soon as possible.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 24px;font-size:.9rem;color:#374151;line-height:1.6;">
|
||||
This can happen when a card expires, has insufficient funds, or is declined by
|
||||
your bank. Updating your payment method takes less than a minute.
|
||||
</p>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{ portal_url }}"
|
||||
style="display:inline-block;background:#1a56db;color:#fff;text-decoration:none;
|
||||
padding:12px 28px;border-radius:6px;font-size:.95rem;font-weight:600;">
|
||||
Update Payment Method
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;font-size:.85rem;color:#9ca3af;">
|
||||
Or copy this link into your browser:<br>
|
||||
<a href="{{ portal_url }}" style="color:#1a56db;word-break:break-all;">{{ portal_url }}</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "billing/email/base.html" %}
|
||||
{% set subject = "Your JQC subscription has been cancelled" %}
|
||||
{% block body %}
|
||||
<h2 style="margin:0 0 8px;font-size:1.2rem;color:#111827;">Subscription cancelled</h2>
|
||||
<p style="margin:0 0 20px;color:#6b7280;font-size:.9rem;">Your JQC subscription has ended and your workspace has been suspended.</p>
|
||||
|
||||
<table width="100%" cellpadding="12" cellspacing="0" style="background:#fef2f2;border-radius:6px;margin-bottom:24px;">
|
||||
<tr>
|
||||
<td>
|
||||
<p style="margin:0;font-size:.9rem;color:#991b1b;line-height:1.5;">
|
||||
<strong>✗ Workspace access suspended</strong><br>
|
||||
Your data is safe and will be retained for 30 days. Reactivate at any time to restore access immediately.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 24px;font-size:.9rem;color:#374151;line-height:1.6;">
|
||||
If this was a mistake or you'd like to reactivate your subscription,
|
||||
click the button below to manage your billing.
|
||||
</p>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{ portal_url }}"
|
||||
style="display:inline-block;background:#1a56db;color:#fff;text-decoration:none;
|
||||
padding:12px 28px;border-radius:6px;font-size:.95rem;font-weight:600;">
|
||||
Reactivate Subscription
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;font-size:.85rem;color:#9ca3af;">
|
||||
Or copy this link into your browser:<br>
|
||||
<a href="{{ portal_url }}" style="color:#1a56db;word-break:break-all;">{{ portal_url }}</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "billing/email/base.html" %}
|
||||
{% set subject = "Your JQC free trial ends in 3 days" %}
|
||||
{% block body %}
|
||||
<h2 style="margin:0 0 8px;font-size:1.2rem;color:#111827;">Your free trial is ending soon</h2>
|
||||
<p style="margin:0 0 20px;color:#6b7280;font-size:.9rem;">Subscribe before <strong>{{ trial_ends_at }}</strong> to keep your workspace active.</p>
|
||||
|
||||
<table width="100%" cellpadding="12" cellspacing="0" style="background:#eff6ff;border-radius:6px;margin-bottom:24px;">
|
||||
<tr>
|
||||
<td>
|
||||
<p style="margin:0;font-size:.9rem;color:#1e40af;line-height:1.5;">
|
||||
<strong>⌛ Trial expiry: {{ trial_ends_at }}</strong><br>
|
||||
After this date your workspace will be locked until you subscribe.
|
||||
All your data is preserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 24px;font-size:.9rem;color:#374151;line-height:1.6;">
|
||||
Choose the plan that fits your team — you can upgrade or downgrade at any time,
|
||||
and your first bill is prorated to today.
|
||||
</p>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{ subscribe_url }}"
|
||||
style="display:inline-block;background:#1a56db;color:#fff;text-decoration:none;
|
||||
padding:12px 28px;border-radius:6px;font-size:.95rem;font-weight:600;">
|
||||
Subscribe Now
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;font-size:.85rem;color:#9ca3af;">
|
||||
Or copy this link into your browser:<br>
|
||||
<a href="{{ subscribe_url }}" style="color:#1a56db;word-break:break-all;">{{ subscribe_url }}</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -121,6 +121,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if billing_enabled and invoices %}
|
||||
<div class="card border-0 shadow-sm mt-3">
|
||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Invoice History</span>
|
||||
<a href="{{ url_for('billing.portal') }}" class="btn btn-xs btn-outline-secondary"
|
||||
style="font-size:.75rem;padding:2px 10px;">
|
||||
Manage in Stripe ›
|
||||
</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>Date</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inv in invoices %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ inv.number }}</td>
|
||||
<td>{{ inv.date }}</td>
|
||||
<td>{{ inv.amount }}</td>
|
||||
<td>
|
||||
{% if inv.status == 'paid' %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">Paid</span>
|
||||
{% elif inv.status == 'open' %}
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">Open</span>
|
||||
{% elif inv.status == 'void' %}
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Void</span>
|
||||
{% elif inv.status == 'uncollectible' %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">Uncollectible</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-muted border">{{ inv.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if inv.pdf_url %}
|
||||
<a href="{{ inv.pdf_url }}" target="_blank" class="btn btn-xs btn-outline-secondary me-1"
|
||||
style="font-size:.75rem;padding:2px 8px;">
|
||||
<i class="bi bi-file-pdf me-1"></i>PDF
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if inv.hosted_url %}
|
||||
<a href="{{ inv.hosted_url }}" target="_blank" class="btn btn-xs btn-outline-primary"
|
||||
style="font-size:.75rem;padding:2px 8px;">
|
||||
View
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
Plan information is only available when multi-tenancy is enabled.
|
||||
|
||||
Reference in New Issue
Block a user