61 changed files with 16 additions and 253552 deletions
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,74 +0,0 @@ |
|||||||
#!/bin/bash |
|
||||||
set -e |
|
||||||
|
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)" |
|
||||||
cp app.py "app.py.auto-expire-pending.${STAMP}.bak" |
|
||||||
|
|
||||||
python3 <<'PY' |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
p = Path("app.py") |
|
||||||
text = p.read_text() |
|
||||||
|
|
||||||
anchor = "def portal_invoice" |
|
||||||
|
|
||||||
if anchor not in text: |
|
||||||
raise SystemExit("FAILED: portal_invoice route not found") |
|
||||||
|
|
||||||
# find insertion point (start of function body) |
|
||||||
idx = text.index(anchor) |
|
||||||
start = text.index(":", idx) + 1 |
|
||||||
|
|
||||||
inject_code = ''' |
|
||||||
# === AUTO-EXPIRE STALE PENDING CRYPTO PAYMENTS === |
|
||||||
try: |
|
||||||
from datetime import datetime, timedelta |
|
||||||
|
|
||||||
conn2 = get_db_connection() |
|
||||||
cur2 = conn2.cursor(dictionary=True) |
|
||||||
|
|
||||||
cur2.execute( |
|
||||||
"SELECT id, payment_status, txid, created_at " |
|
||||||
"FROM payments " |
|
||||||
"WHERE invoice_id = %s AND payment_method = 'crypto' " |
|
||||||
"ORDER BY id DESC LIMIT 1", |
|
||||||
(invoice_id,) |
|
||||||
) |
|
||||||
last_payment = cur2.fetchone() |
|
||||||
|
|
||||||
if last_payment: |
|
||||||
is_pending = str(last_payment.get("payment_status") or "").lower() == "pending" |
|
||||||
has_tx = bool(last_payment.get("txid")) |
|
||||||
|
|
||||||
created_at = last_payment.get("created_at") |
|
||||||
is_expired = False |
|
||||||
|
|
||||||
if created_at: |
|
||||||
is_expired = datetime.utcnow() > (created_at + timedelta(minutes=15)) |
|
||||||
|
|
||||||
if is_pending and not has_tx and is_expired: |
|
||||||
cur2.execute( |
|
||||||
"UPDATE payments SET payment_status = 'expired' WHERE id = %s", |
|
||||||
(last_payment["id"],) |
|
||||||
) |
|
||||||
conn2.commit() |
|
||||||
print(f"[auto-expire] expired stale payment id={last_payment['id']} invoice_id={invoice_id}") |
|
||||||
|
|
||||||
conn2.close() |
|
||||||
|
|
||||||
except Exception as e: |
|
||||||
print(f"[auto-expire] error: {e}") |
|
||||||
# === END AUTO-EXPIRE === |
|
||||||
''' |
|
||||||
|
|
||||||
text = text[:start] + inject_code + text[start:] |
|
||||||
|
|
||||||
p.write_text(text) |
|
||||||
print("OK: auto-expire logic injected cleanly") |
|
||||||
PY |
|
||||||
|
|
||||||
python3 -m py_compile app.py |
|
||||||
echo "PY_COMPILE_OK" |
|
||||||
|
|
||||||
sudo systemctl restart otb_billing |
|
||||||
sudo systemctl status otb_billing --no-pager -l |
|
||||||
@ -1,44 +0,0 @@ |
|||||||
#!/bin/bash |
|
||||||
set -e |
|
||||||
|
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)" |
|
||||||
cp app.py "app.py.auto-expire-pending-v2.${STAMP}.bak" |
|
||||||
|
|
||||||
python3 <<'PY' |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
p = Path("app.py") |
|
||||||
text = p.read_text() |
|
||||||
|
|
||||||
old = """ cur2.execute( |
|
||||||
"SELECT id, payment_status, txid, created_at " |
|
||||||
"FROM payments " |
|
||||||
"WHERE invoice_id = %s AND payment_method = 'crypto' " |
|
||||||
"ORDER BY id DESC LIMIT 1", |
|
||||||
(invoice_id,) |
|
||||||
) |
|
||||||
""" |
|
||||||
|
|
||||||
new = """ cur2.execute( |
|
||||||
"SELECT id, payment_method, payment_currency, payment_status, txid, created_at " |
|
||||||
"FROM payments " |
|
||||||
"WHERE invoice_id = %s " |
|
||||||
"AND UPPER(COALESCE(payment_currency,'')) IN ('ETHO','ETI','EGAZ','ETH','ARB') " |
|
||||||
"ORDER BY id DESC LIMIT 1", |
|
||||||
(invoice_id,) |
|
||||||
) |
|
||||||
""" |
|
||||||
|
|
||||||
if old not in text: |
|
||||||
raise SystemExit("FAILED: old auto-expire query not found") |
|
||||||
|
|
||||||
text = text.replace(old, new, 1) |
|
||||||
p.write_text(text) |
|
||||||
print("OK: auto-expire query updated for real crypto rows") |
|
||||||
PY |
|
||||||
|
|
||||||
python3 -m py_compile app.py |
|
||||||
echo "PY_COMPILE_OK" |
|
||||||
|
|
||||||
sudo systemctl restart otb_billing |
|
||||||
sudo systemctl status otb_billing --no-pager -l |
|
||||||
@ -1,116 +0,0 @@ |
|||||||
#!/bin/bash |
|
||||||
set -e |
|
||||||
|
|
||||||
cd /home/def/otb_billing/backend || exit 1 |
|
||||||
|
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)" |
|
||||||
cp app.py "app.py.pending-payment-logic.${STAMP}.bak" |
|
||||||
|
|
||||||
python3 <<'PY' |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
p = Path("app.py") |
|
||||||
text = p.read_text() |
|
||||||
|
|
||||||
old_block_1 = """ payment_id = (request.args.get("payment_id") or "").strip() |
|
||||||
if not payment_id and pending_crypto_payment: |
|
||||||
payment_id = str(pending_crypto_payment.get("id") or "").strip() |
|
||||||
|
|
||||||
if payment_id.isdigit(): |
|
||||||
""" |
|
||||||
|
|
||||||
new_block_1 = """ payment_id = (request.args.get("payment_id") or "").strip() |
|
||||||
if not payment_id and pending_crypto_payment: |
|
||||||
stale_pending_without_tx = False |
|
||||||
try: |
|
||||||
created_dt = pending_crypto_payment.get("created_at") |
|
||||||
if created_dt and created_dt.tzinfo is None: |
|
||||||
created_dt = created_dt.replace(tzinfo=timezone.utc) |
|
||||||
stale_pending_without_tx = ( |
|
||||||
str(pending_crypto_payment.get("payment_status") or "").lower() == "pending" |
|
||||||
and not pending_crypto_payment.get("txid") |
|
||||||
and created_dt is not None |
|
||||||
and datetime.now(timezone.utc) >= (created_dt + timedelta(minutes=2)) |
|
||||||
) |
|
||||||
except Exception: |
|
||||||
stale_pending_without_tx = False |
|
||||||
|
|
||||||
if stale_pending_without_tx: |
|
||||||
try: |
|
||||||
cursor.execute(\"\"\" |
|
||||||
UPDATE payments |
|
||||||
SET payment_status = 'failed' |
|
||||||
WHERE id = %s |
|
||||||
AND payment_status = 'pending' |
|
||||||
AND (txid IS NULL OR txid = '') |
|
||||||
\"\"\", (pending_crypto_payment["id"],)) |
|
||||||
conn.commit() |
|
||||||
except Exception: |
|
||||||
pass |
|
||||||
pending_crypto_payment = None |
|
||||||
else: |
|
||||||
payment_id = str(pending_crypto_payment.get("id") or "").strip() |
|
||||||
|
|
||||||
if payment_id.isdigit(): |
|
||||||
""" |
|
||||||
|
|
||||||
if old_block_1 not in text: |
|
||||||
raise SystemExit("FAILED: block 1 not found") |
|
||||||
|
|
||||||
text = text.replace(old_block_1, new_block_1, 1) |
|
||||||
|
|
||||||
old_block_2 = """ else: |
|
||||||
pending_crypto_payment["created_at_local"] = "" |
|
||||||
pending_crypto_payment["lock_expires_at_local"] = "" |
|
||||||
pending_crypto_payment["lock_expires_at_iso"] = "" |
|
||||||
pending_crypto_payment["lock_expired"] = True |
|
||||||
|
|
||||||
received_dt = pending_crypto_payment.get("received_at") |
|
||||||
""" |
|
||||||
|
|
||||||
new_block_2 = """ else: |
|
||||||
pending_crypto_payment["created_at_local"] = "" |
|
||||||
pending_crypto_payment["lock_expires_at_local"] = "" |
|
||||||
pending_crypto_payment["lock_expires_at_iso"] = "" |
|
||||||
pending_crypto_payment["lock_expired"] = True |
|
||||||
|
|
||||||
if ( |
|
||||||
str(pending_crypto_payment.get("payment_status") or "").lower() == "pending" |
|
||||||
and not pending_crypto_payment.get("txid") |
|
||||||
and pending_crypto_payment.get("lock_expired") |
|
||||||
): |
|
||||||
try: |
|
||||||
cursor.execute(\"\"\" |
|
||||||
UPDATE payments |
|
||||||
SET payment_status = 'failed' |
|
||||||
WHERE id = %s |
|
||||||
AND payment_status = 'pending' |
|
||||||
AND (txid IS NULL OR txid = '') |
|
||||||
\"\"\", (pending_crypto_payment["id"],)) |
|
||||||
conn.commit() |
|
||||||
except Exception: |
|
||||||
pass |
|
||||||
pending_crypto_payment = None |
|
||||||
payment_id = "" |
|
||||||
|
|
||||||
received_dt = pending_crypto_payment.get("received_at") if pending_crypto_payment else None |
|
||||||
""" |
|
||||||
|
|
||||||
if old_block_2 not in text: |
|
||||||
raise SystemExit("FAILED: block 2 not found") |
|
||||||
|
|
||||||
text = text.replace(old_block_2, new_block_2, 1) |
|
||||||
|
|
||||||
p.write_text(text) |
|
||||||
print("OK: pending payment logic patched") |
|
||||||
PY |
|
||||||
|
|
||||||
python3 -m py_compile app.py |
|
||||||
echo "PY_COMPILE_OK" |
|
||||||
|
|
||||||
sudo systemctl restart otb_billing |
|
||||||
sudo systemctl status otb_billing --no-pager -l |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== verify patched area =====" |
|
||||||
sed -n '5220,5325p' app.py |
|
||||||
@ -1,29 +0,0 @@ |
|||||||
#!/bin/bash |
|
||||||
set -e |
|
||||||
|
|
||||||
python3 <<'PY' |
|
||||||
from pathlib import Path |
|
||||||
import re |
|
||||||
|
|
||||||
p = Path("app.py") |
|
||||||
text = p.read_text() |
|
||||||
|
|
||||||
pattern = re.compile( |
|
||||||
r"\n\s*# === KILL DEAD PENDING PAYMENT \(no txid \+ expired lock\) ===.*?" |
|
||||||
r'print\("\[dead pending cleanup error\]", e\)\n', |
|
||||||
re.DOTALL |
|
||||||
) |
|
||||||
|
|
||||||
new_text, count = pattern.subn("\n", text, count=1) |
|
||||||
if count == 0: |
|
||||||
raise SystemExit("FAILED: broken dead-pending block not found") |
|
||||||
|
|
||||||
p.write_text(new_text) |
|
||||||
print("OK: removed broken dead-pending block") |
|
||||||
PY |
|
||||||
|
|
||||||
python3 -m py_compile app.py |
|
||||||
echo "PY_COMPILE_OK" |
|
||||||
|
|
||||||
sudo systemctl restart otb_billing |
|
||||||
sudo systemctl status otb_billing --no-pager -l |
|
||||||
@ -1,26 +0,0 @@ |
|||||||
#!/bin/bash |
|
||||||
set -e |
|
||||||
|
|
||||||
cd /home/def/otb_billing/backend || exit 1 |
|
||||||
|
|
||||||
GOOD_BAK="app.py.retry-email.20260327-023404.bak" |
|
||||||
|
|
||||||
echo "===== restoring known-good app.py =====" |
|
||||||
cp "$GOOD_BAK" app.py |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== compile check =====" |
|
||||||
python3 -m py_compile app.py |
|
||||||
echo "PY_COMPILE_OK" |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== restart service =====" |
|
||||||
sudo systemctl restart otb_billing |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== service status =====" |
|
||||||
sudo systemctl status otb_billing --no-pager -l |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== last 20 journal lines =====" |
|
||||||
sudo journalctl -u otb_billing.service -n 20 --no-pager |
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,100 +0,0 @@ |
|||||||
<!doctype html> |
|
||||||
<html> |
|
||||||
<head> |
|
||||||
<title>Payments</title> |
|
||||||
<style> |
|
||||||
.status-badge { |
|
||||||
display: inline-block; |
|
||||||
padding: 3px 8px; |
|
||||||
border-radius: 999px; |
|
||||||
font-size: 12px; |
|
||||||
font-weight: bold; |
|
||||||
text-transform: uppercase; |
|
||||||
letter-spacing: 0.03em; |
|
||||||
} |
|
||||||
.status-confirmed { background: #dcfce7; color: #166534; } |
|
||||||
.status-reversed { background: #fee2e2; color: #991b1b; } |
|
||||||
|
|
||||||
.invoice-badge { |
|
||||||
display: inline-block; |
|
||||||
padding: 3px 8px; |
|
||||||
border-radius: 999px; |
|
||||||
font-size: 11px; |
|
||||||
font-weight: bold; |
|
||||||
text-transform: uppercase; |
|
||||||
letter-spacing: 0.03em; |
|
||||||
} |
|
||||||
.invoice-pending { background: #dbeafe; color: #1d4ed8; } |
|
||||||
.invoice-partial { background: #fef3c7; color: #92400e; } |
|
||||||
.invoice-paid { background: #dcfce7; color: #166534; } |
|
||||||
.invoice-overdue { background: #fee2e2; color: #991b1b; } |
|
||||||
.invoice-cancelled { background: #e5e7eb; color: #4b5563; } |
|
||||||
.invoice-draft { background: #e5e7eb; color: #111827; } |
|
||||||
|
|
||||||
.inline-form { |
|
||||||
display: inline; |
|
||||||
margin: 0; |
|
||||||
} |
|
||||||
.void-btn { |
|
||||||
background: #991b1b; |
|
||||||
color: white; |
|
||||||
border: 0; |
|
||||||
padding: 4px 8px; |
|
||||||
cursor: pointer; |
|
||||||
} |
|
||||||
.void-btn:hover { |
|
||||||
opacity: 0.9; |
|
||||||
} |
|
||||||
</style> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
|
|
||||||
<h1>Payments</h1> |
|
||||||
|
|
||||||
<p><a href="/">Home</a></p> |
|
||||||
<p><a href="/payments/new">Record Payment</a></p> |
|
||||||
|
|
||||||
<table border="1" cellpadding="6"> |
|
||||||
<tr> |
|
||||||
<th>ID</th> |
|
||||||
<th>Invoice</th> |
|
||||||
<th>Client</th> |
|
||||||
<th>Method</th> |
|
||||||
<th>Currency</th> |
|
||||||
<th>Amount</th> |
|
||||||
<th>CAD Value</th> |
|
||||||
<th>Payment Status</th> |
|
||||||
<th>Invoice Status</th> |
|
||||||
<th>Received</th> |
|
||||||
<th>Actions</th> |
|
||||||
</tr> |
|
||||||
|
|
||||||
{% for p in payments %} |
|
||||||
<tr> |
|
||||||
<td>{{ p.id }}</td> |
|
||||||
<td>{{ p.invoice_number }}</td> |
|
||||||
<td>{{ p.client_code }} - {{ p.company_name }}</td> |
|
||||||
<td>{{ p.payment_method }}</td> |
|
||||||
<td>{{ p.payment_currency }}</td> |
|
||||||
<td>{{ p.payment_amount|money(p.payment_currency) }}</td> |
|
||||||
<td>{{ p.cad_value_at_payment|money('CAD') }}</td> |
|
||||||
<td><span class="status-badge status-{{ p.payment_status }}">{{ p.payment_status }}</span></td> |
|
||||||
<td><span class="invoice-badge invoice-{{ p.invoice_status }}">{{ p.invoice_status }}</span></td> |
|
||||||
<td>{{ p.received_at|localtime }}</td> |
|
||||||
<td> |
|
||||||
<a href="/payments/edit/{{ p.id }}">Edit</a> |
|
||||||
{% if p.payment_status == 'confirmed' %} |
|
||||||
| |
|
||||||
<form method="post" action="/payments/void/{{ p.id }}" class="inline-form" onsubmit="return confirm('Void this payment? This will reverse it from invoice totals but keep the record for history.');"> |
|
||||||
<button type="submit" class="void-btn">Void</button> |
|
||||||
</form> |
|
||||||
{% endif %} |
|
||||||
</td> |
|
||||||
</tr> |
|
||||||
{% endfor %} |
|
||||||
|
|
||||||
</table> |
|
||||||
|
|
||||||
{% include "footer.html" %} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,36 +0,0 @@ |
|||||||
<!doctype html> |
|
||||||
<html> |
|
||||||
<head> |
|
||||||
<title>OTB Billing Dashboard</title> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
|
|
||||||
<h1>{{ app_settings.business_name or 'OTB Billing' }} Dashboard</h1> |
|
||||||
|
|
||||||
<p><a href="/clients">Clients</a></p> |
|
||||||
<p><a href="/services">Services</a></p> |
|
||||||
<p><a href="/invoices">Invoices</a></p> |
|
||||||
<p><a href="/payments">Payments</a></p> |
|
||||||
<p><a href="/settings">Settings / Config</a></p> |
|
||||||
<p><a href="/dbtest">DB Test</a></p> |
|
||||||
|
|
||||||
<table border="1" cellpadding="10"> |
|
||||||
<tr> |
|
||||||
<th>Total Clients</th> |
|
||||||
<th>Active Services</th> |
|
||||||
<th>Outstanding Invoices</th> |
|
||||||
<th>Revenue Received (CAD)</th> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<td>{{ total_clients }}</td> |
|
||||||
<td>{{ active_services }}</td> |
|
||||||
<td>{{ outstanding_invoices }}</td> |
|
||||||
<td>{{ revenue_received|money('CAD') }}</td> |
|
||||||
</tr> |
|
||||||
</table> |
|
||||||
|
|
||||||
<p>Displayed times are shown in Eastern Time (Toronto).</p> |
|
||||||
|
|
||||||
{% include "footer.html" %} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -1,202 +0,0 @@ |
|||||||
<!doctype html> |
|
||||||
<html> |
|
||||||
<head> |
|
||||||
<title>Invoice {{ invoice.invoice_number }}</title> |
|
||||||
<style> |
|
||||||
body { |
|
||||||
font-family: Arial, sans-serif; |
|
||||||
margin: 30px; |
|
||||||
color: #111; |
|
||||||
} |
|
||||||
.top-links { |
|
||||||
margin-bottom: 20px; |
|
||||||
} |
|
||||||
.top-links a { |
|
||||||
margin-right: 15px; |
|
||||||
} |
|
||||||
.invoice-wrap { |
|
||||||
max-width: 900px; |
|
||||||
} |
|
||||||
.header-row { |
|
||||||
display: flex; |
|
||||||
justify-content: space-between; |
|
||||||
align-items: flex-start; |
|
||||||
margin-bottom: 25px; |
|
||||||
} |
|
||||||
.title-box h1 { |
|
||||||
margin: 0 0 8px 0; |
|
||||||
} |
|
||||||
.status-badge { |
|
||||||
display: inline-block; |
|
||||||
padding: 4px 10px; |
|
||||||
border-radius: 999px; |
|
||||||
font-size: 12px; |
|
||||||
font-weight: bold; |
|
||||||
text-transform: uppercase; |
|
||||||
} |
|
||||||
.status-draft { background: #e5e7eb; color: #111827; } |
|
||||||
.status-pending { background: #dbeafe; color: #1d4ed8; } |
|
||||||
.status-partial { background: #fef3c7; color: #92400e; } |
|
||||||
.status-paid { background: #dcfce7; color: #166534; } |
|
||||||
.status-overdue { background: #fee2e2; color: #991b1b; } |
|
||||||
.status-cancelled { background: #e5e7eb; color: #4b5563; } |
|
||||||
|
|
||||||
.info-grid { |
|
||||||
display: grid; |
|
||||||
grid-template-columns: 1fr 1fr; |
|
||||||
gap: 30px; |
|
||||||
margin-bottom: 25px; |
|
||||||
} |
|
||||||
.info-card { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 15px; |
|
||||||
} |
|
||||||
.info-card h3 { |
|
||||||
margin-top: 0; |
|
||||||
} |
|
||||||
.summary-table, |
|
||||||
.total-table { |
|
||||||
width: 100%; |
|
||||||
border-collapse: collapse; |
|
||||||
margin-bottom: 25px; |
|
||||||
} |
|
||||||
.summary-table th, |
|
||||||
.summary-table td, |
|
||||||
.total-table th, |
|
||||||
.total-table td { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 10px; |
|
||||||
text-align: left; |
|
||||||
} |
|
||||||
.total-table { |
|
||||||
max-width: 420px; |
|
||||||
margin-left: auto; |
|
||||||
} |
|
||||||
.notes-box { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 15px; |
|
||||||
white-space: pre-wrap; |
|
||||||
margin-top: 20px; |
|
||||||
} |
|
||||||
.print-only { |
|
||||||
display: none; |
|
||||||
} |
|
||||||
@media print { |
|
||||||
.top-links, |
|
||||||
.screen-only, |
|
||||||
footer { |
|
||||||
display: none !important; |
|
||||||
} |
|
||||||
.print-only { |
|
||||||
display: block; |
|
||||||
} |
|
||||||
body { |
|
||||||
margin: 0; |
|
||||||
} |
|
||||||
} |
|
||||||
</style> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
|
|
||||||
<div class="invoice-wrap"> |
|
||||||
<div class="top-links screen-only"> |
|
||||||
<a href="/">Home</a> |
|
||||||
<a href="/invoices">Back to Invoices</a> |
|
||||||
<a href="/invoices/edit/{{ invoice.id }}">Edit Invoice</a> |
|
||||||
<a href="#" onclick="window.print(); return false;">Print</a> |
|
||||||
<a href="/invoices/pdf/{{ invoice.id }}">PDF</a> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="header-row"> |
|
||||||
<div class="title-box"> |
|
||||||
<h1>Invoice {{ invoice.invoice_number }}</h1> |
|
||||||
<span class="status-badge status-{{ invoice.status }}">{{ invoice.status }}</span> |
|
||||||
</div> |
|
||||||
<div style="text-align:right;"> |
|
||||||
<strong>{{ settings.business_name or 'OTB Billing' }}</strong><br> |
|
||||||
{{ settings.business_tagline or '' }}<br> |
|
||||||
{% if settings.business_address %}{{ settings.business_address }}<br>{% endif %} |
|
||||||
{% if settings.business_email %}{{ settings.business_email }}<br>{% endif %} |
|
||||||
{% if settings.business_phone %}{{ settings.business_phone }}<br>{% endif %} |
|
||||||
{% if settings.business_website %}{{ settings.business_website }}{% endif %} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="info-grid"> |
|
||||||
<div class="info-card"> |
|
||||||
<h3>Bill To</h3> |
|
||||||
<strong>{{ invoice.company_name }}</strong><br> |
|
||||||
{% if invoice.contact_name %}{{ invoice.contact_name }}<br>{% endif %} |
|
||||||
{% if invoice.email %}{{ invoice.email }}<br>{% endif %} |
|
||||||
{% if invoice.phone %}{{ invoice.phone }}<br>{% endif %} |
|
||||||
Client Code: {{ invoice.client_code }} |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="info-card"> |
|
||||||
<h3>Invoice Details</h3> |
|
||||||
Invoice #: {{ invoice.invoice_number }}<br> |
|
||||||
Issued: {{ invoice.issued_at|localtime }}<br> |
|
||||||
Due: {{ invoice.due_at|localtime }}<br> |
|
||||||
{% if invoice.paid_at %}Paid: {{ invoice.paid_at|localtime }}<br>{% endif %} |
|
||||||
Currency: {{ invoice.currency_code }}<br> |
|
||||||
{% if settings.tax_number %}{{ settings.tax_label or 'Tax' }} Number: {{ settings.tax_number }}<br>{% endif %} |
|
||||||
{% if settings.business_number %}Business Number: {{ settings.business_number }}{% endif %} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<table class="summary-table"> |
|
||||||
<tr> |
|
||||||
<th>Service Code</th> |
|
||||||
<th>Service</th> |
|
||||||
<th>Description</th> |
|
||||||
<th>Total</th> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<td>{{ invoice.service_code or '-' }}</td> |
|
||||||
<td>{{ invoice.service_name or '-' }}</td> |
|
||||||
<td>{{ invoice.notes or '-' }}</td> |
|
||||||
<td>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
</table> |
|
||||||
|
|
||||||
<table class="total-table"> |
|
||||||
<tr> |
|
||||||
<th>Subtotal</th> |
|
||||||
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>{{ settings.tax_label or 'Tax' }}</th> |
|
||||||
<td>{{ invoice.tax_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>Total</th> |
|
||||||
<td><strong>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</strong></td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>Paid</th> |
|
||||||
<td>{{ invoice.amount_paid|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>Remaining</th> |
|
||||||
<td>{{ (invoice.total_amount - invoice.amount_paid)|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
</table> |
|
||||||
|
|
||||||
{% if settings.payment_terms %} |
|
||||||
<div class="notes-box"> |
|
||||||
<strong>Payment Terms</strong><br><br> |
|
||||||
{{ settings.payment_terms }} |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
{% if settings.invoice_footer %} |
|
||||||
<div class="notes-box"> |
|
||||||
<strong>Footer</strong><br><br> |
|
||||||
{{ settings.invoice_footer }} |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
</div> |
|
||||||
|
|
||||||
{% include "footer.html" %} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -1,169 +0,0 @@ |
|||||||
<!doctype html> |
|
||||||
<html> |
|
||||||
<head> |
|
||||||
<title>Settings</title> |
|
||||||
<style> |
|
||||||
body { font-family: Arial, sans-serif; } |
|
||||||
.form-grid { |
|
||||||
display: grid; |
|
||||||
grid-template-columns: 1fr 1fr; |
|
||||||
gap: 24px; |
|
||||||
max-width: 1100px; |
|
||||||
} |
|
||||||
.card { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 16px; |
|
||||||
} |
|
||||||
.card h2 { |
|
||||||
margin-top: 0; |
|
||||||
} |
|
||||||
input[type="text"], |
|
||||||
input[type="email"], |
|
||||||
input[type="password"], |
|
||||||
input[type="number"], |
|
||||||
textarea, |
|
||||||
select { |
|
||||||
width: 100%; |
|
||||||
box-sizing: border-box; |
|
||||||
margin-top: 4px; |
|
||||||
margin-bottom: 12px; |
|
||||||
padding: 8px; |
|
||||||
} |
|
||||||
textarea { min-height: 90px; } |
|
||||||
.checkbox-row { |
|
||||||
margin: 8px 0 14px 0; |
|
||||||
} |
|
||||||
.save-row { |
|
||||||
margin-top: 18px; |
|
||||||
} |
|
||||||
</style> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
|
|
||||||
<h1>Settings / Config</h1> |
|
||||||
|
|
||||||
<p><a href="/">Home</a></p> |
|
||||||
|
|
||||||
<form method="post"> |
|
||||||
<div class="form-grid"> |
|
||||||
<div class="card"> |
|
||||||
<h2>Business Identity</h2> |
|
||||||
|
|
||||||
Business Name<br> |
|
||||||
<input type="text" name="business_name" value="{{ settings.business_name }}"><br> |
|
||||||
|
|
||||||
Slogan / Tagline<br> |
|
||||||
<input type="text" name="business_tagline" value="{{ settings.business_tagline }}"><br> |
|
||||||
|
|
||||||
Business Email<br> |
|
||||||
<input type="email" name="business_email" value="{{ settings.business_email }}"><br> |
|
||||||
|
|
||||||
Business Phone<br> |
|
||||||
<input type="text" name="business_phone" value="{{ settings.business_phone }}"><br> |
|
||||||
|
|
||||||
Business Address<br> |
|
||||||
<textarea name="business_address">{{ settings.business_address }}</textarea><br> |
|
||||||
|
|
||||||
Website<br> |
|
||||||
<input type="text" name="business_website" value="{{ settings.business_website }}"><br> |
|
||||||
|
|
||||||
Business Number / Registration Number<br> |
|
||||||
<input type="text" name="business_number" value="{{ settings.business_number }}"><br> |
|
||||||
|
|
||||||
Default Currency<br> |
|
||||||
<select name="default_currency"> |
|
||||||
<option value="CAD" {% if settings.default_currency == 'CAD' %}selected{% endif %}>CAD</option> |
|
||||||
<option value="USD" {% if settings.default_currency == 'USD' %}selected{% endif %}>USD</option> |
|
||||||
<option value="ETHO" {% if settings.default_currency == 'ETHO' %}selected{% endif %}>ETHO</option> |
|
||||||
<option value="EGAZ" {% if settings.default_currency == 'EGAZ' %}selected{% endif %}>EGAZ</option> |
|
||||||
<option value="ALT" {% if settings.default_currency == 'ALT' %}selected{% endif %}>ALT</option> |
|
||||||
</select> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="card"> |
|
||||||
<h2>Tax Settings</h2> |
|
||||||
|
|
||||||
Local Country<br> |
|
||||||
<input type="text" name="local_country" value="{{ settings.local_country }}"><br> |
|
||||||
|
|
||||||
Tax Label<br> |
|
||||||
<input type="text" name="tax_label" value="{{ settings.tax_label }}"><br> |
|
||||||
|
|
||||||
Tax Rate (%)<br> |
|
||||||
<input type="number" step="0.01" name="tax_rate" value="{{ settings.tax_rate }}"><br> |
|
||||||
|
|
||||||
Tax Number<br> |
|
||||||
<input type="text" name="tax_number" value="{{ settings.tax_number }}"><br> |
|
||||||
|
|
||||||
<div class="checkbox-row"> |
|
||||||
<label> |
|
||||||
<input type="checkbox" name="apply_local_tax_only" value="1" {% if settings.apply_local_tax_only == '1' %}checked{% endif %}> |
|
||||||
Apply tax only to local clients |
|
||||||
</label> |
|
||||||
</div> |
|
||||||
|
|
||||||
Payment Terms<br> |
|
||||||
<textarea name="payment_terms">{{ settings.payment_terms }}</textarea><br> |
|
||||||
|
|
||||||
Invoice Footer<br> |
|
||||||
<textarea name="invoice_footer">{{ settings.invoice_footer }}</textarea><br> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="card"> |
|
||||||
<h2>Email / SMTP</h2> |
|
||||||
|
|
||||||
SMTP Host<br> |
|
||||||
<input type="text" name="smtp_host" value="{{ settings.smtp_host }}"><br> |
|
||||||
|
|
||||||
SMTP Port<br> |
|
||||||
<input type="number" name="smtp_port" value="{{ settings.smtp_port }}"><br> |
|
||||||
|
|
||||||
SMTP Username<br> |
|
||||||
<input type="text" name="smtp_user" value="{{ settings.smtp_user }}"><br> |
|
||||||
|
|
||||||
SMTP Password<br> |
|
||||||
<input type="password" name="smtp_pass" value="{{ settings.smtp_pass }}"><br> |
|
||||||
|
|
||||||
From Email<br> |
|
||||||
<input type="email" name="smtp_from_email" value="{{ settings.smtp_from_email }}"><br> |
|
||||||
|
|
||||||
From Name<br> |
|
||||||
<input type="text" name="smtp_from_name" value="{{ settings.smtp_from_name }}"><br> |
|
||||||
|
|
||||||
<div class="checkbox-row"> |
|
||||||
<label> |
|
||||||
<input type="checkbox" name="smtp_use_tls" value="1" {% if settings.smtp_use_tls == '1' %}checked{% endif %}> |
|
||||||
Use TLS |
|
||||||
</label> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="checkbox-row"> |
|
||||||
<label> |
|
||||||
<input type="checkbox" name="smtp_use_ssl" value="1" {% if settings.smtp_use_ssl == '1' %}checked{% endif %}> |
|
||||||
Use SSL |
|
||||||
</label> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="card"> |
|
||||||
<h2>Notes</h2> |
|
||||||
<p> |
|
||||||
These settings become the identity and delivery configuration for this installation. |
|
||||||
</p> |
|
||||||
<p> |
|
||||||
Email sending is not wired yet, but these SMTP settings are stored now so the next step can use them. |
|
||||||
</p> |
|
||||||
<p> |
|
||||||
Tax settings are also stored now so invoice and automation logic can use them later. |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="save-row"> |
|
||||||
<button type="submit">Save Settings</button> |
|
||||||
</div> |
|
||||||
</form> |
|
||||||
|
|
||||||
{% include "footer.html" %} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,60 +0,0 @@ |
|||||||
<!doctype html> |
|
||||||
<html> |
|
||||||
<head> |
|
||||||
<title>OTB Billing Dashboard</title> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
|
|
||||||
|
|
||||||
{% if app_settings.business_logo_url %} |
|
||||||
<div style="margin-bottom:15px;"> |
|
||||||
<img src="{{ app_settings.business_logo_url }}" style="height:60px;"> |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
<h1>{{ app_settings.business_name or 'OTB Billing' }} Dashboard</h1> |
|
||||||
{% if request.args.get('pkg_email') == '1' %} |
|
||||||
<div style="border:1px solid #166534;background:#dcfce7;padding:10px;margin-bottom:15px;max-width:900px;"> |
|
||||||
Accounting package emailed successfully. |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
{% if request.args.get('pkg_email_failed') == '1' %} |
|
||||||
<div style="border:1px solid #991b1b;background:#fee2e2;padding:10px;margin-bottom:15px;max-width:900px;"> |
|
||||||
Accounting package email failed. Check SMTP settings or server log. |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
{% if request.args.get('pkg_email_failed') == '1' %} |
|
||||||
<div style="border:1px solid #991b1b;background:#fee2e2;padding:10px;margin-bottom:15px;max-width:900px;"> |
|
||||||
Accounting package email failed. Check SMTP settings or server log. |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
<p><a href="/clients">Clients</a></p> |
|
||||||
<p><a href="/services">Services</a></p> |
|
||||||
<p><a href="/invoices">Invoices</a></p> |
|
||||||
<p><a href="/payments">Payments</a></p> |
|
||||||
<p><a href="/reports/revenue">Revenue Report</a></p> |
|
||||||
<p><a href="/reports/accounting-package.zip">Monthly Accounting Package</a></p> |
|
||||||
<form method="post" action="/reports/accounting-package/email" style="margin:0 0 16px 0;"><button type="submit">Email Accounting Package</button></form> |
|
||||||
<p><a href="/settings">Settings / Config</a></p> |
|
||||||
<p><a href="/dbtest">DB Test</a></p> |
|
||||||
|
|
||||||
<table border="1" cellpadding="10"> |
|
||||||
<tr> |
|
||||||
<th>Total Clients</th> |
|
||||||
<th>Active Services</th> |
|
||||||
<th>Outstanding Invoices</th> |
|
||||||
<th>Revenue Received (CAD)</th> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<td>{{ total_clients }}</td> |
|
||||||
<td>{{ active_services }}</td> |
|
||||||
<td>{{ outstanding_invoices }}</td> |
|
||||||
<td>{{ revenue_received|money('CAD') }}</td> |
|
||||||
</tr> |
|
||||||
</table> |
|
||||||
|
|
||||||
<p>Displayed times are shown in Eastern Time (Toronto).</p> |
|
||||||
|
|
||||||
{% include "footer.html" %} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -1,235 +0,0 @@ |
|||||||
<!doctype html> |
|
||||||
<html> |
|
||||||
<head> |
|
||||||
<title>Invoice {{ invoice.invoice_number }}</title> |
|
||||||
<style> |
|
||||||
body { |
|
||||||
font-family: Arial, sans-serif; |
|
||||||
margin: 30px; |
|
||||||
color: #111; |
|
||||||
} |
|
||||||
.top-links { |
|
||||||
margin-bottom: 20px; |
|
||||||
} |
|
||||||
.top-links a { |
|
||||||
margin-right: 15px; |
|
||||||
} |
|
||||||
.invoice-wrap { |
|
||||||
max-width: 900px; |
|
||||||
} |
|
||||||
.header-row { |
|
||||||
display: flex; |
|
||||||
justify-content: space-between; |
|
||||||
align-items: flex-start; |
|
||||||
margin-bottom: 25px; |
|
||||||
} |
|
||||||
.title-box h1 { |
|
||||||
margin: 0 0 8px 0; |
|
||||||
} |
|
||||||
.status-badge { |
|
||||||
display: inline-block; |
|
||||||
padding: 4px 10px; |
|
||||||
border-radius: 999px; |
|
||||||
font-size: 12px; |
|
||||||
font-weight: bold; |
|
||||||
text-transform: uppercase; |
|
||||||
} |
|
||||||
.status-draft { background: #e5e7eb; color: #111827; } |
|
||||||
.status-pending { background: #dbeafe; color: #1d4ed8; } |
|
||||||
.status-partial { background: #fef3c7; color: #92400e; } |
|
||||||
.status-paid { background: #dcfce7; color: #166534; } |
|
||||||
.status-overdue { background: #fee2e2; color: #991b1b; } |
|
||||||
.status-cancelled { background: #e5e7eb; color: #4b5563; } |
|
||||||
|
|
||||||
.info-grid { |
|
||||||
display: grid; |
|
||||||
grid-template-columns: 1fr 1fr; |
|
||||||
gap: 30px; |
|
||||||
margin-bottom: 25px; |
|
||||||
} |
|
||||||
.info-card { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 15px; |
|
||||||
} |
|
||||||
.info-card h3 { |
|
||||||
margin-top: 0; |
|
||||||
} |
|
||||||
.summary-table, |
|
||||||
.total-table { |
|
||||||
width: 100%; |
|
||||||
border-collapse: collapse; |
|
||||||
margin-bottom: 25px; |
|
||||||
} |
|
||||||
.summary-table th, |
|
||||||
.summary-table td, |
|
||||||
.total-table th, |
|
||||||
.total-table td { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 10px; |
|
||||||
text-align: left; |
|
||||||
} |
|
||||||
.total-table { |
|
||||||
max-width: 420px; |
|
||||||
margin-left: auto; |
|
||||||
} |
|
||||||
.notes-box { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 15px; |
|
||||||
white-space: pre-wrap; |
|
||||||
margin-top: 20px; |
|
||||||
} |
|
||||||
.print-only { |
|
||||||
display: none; |
|
||||||
} |
|
||||||
@media print { |
|
||||||
.top-links, |
|
||||||
.screen-only, |
|
||||||
footer { |
|
||||||
display: none !important; |
|
||||||
} |
|
||||||
.print-only { |
|
||||||
display: block; |
|
||||||
} |
|
||||||
body { |
|
||||||
margin: 0; |
|
||||||
} |
|
||||||
} |
|
||||||
</style> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
|
|
||||||
<div class="invoice-wrap"> |
|
||||||
{% if request.args.get('email_sent') == '1' %} |
|
||||||
<div style="border:1px solid #166534;background:#dcfce7;padding:10px;margin-bottom:15px;"> |
|
||||||
Invoice email sent successfully. |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
{% if request.args.get('email_failed') == '1' %} |
|
||||||
<div style="border:1px solid #991b1b;background:#fee2e2;padding:10px;margin-bottom:15px;"> |
|
||||||
Invoice email failed. Check SMTP settings or server log. |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
<div class="top-links screen-only"> |
|
||||||
<a href="/">Home</a> |
|
||||||
<a href="/invoices">Back to Invoices</a> |
|
||||||
<a href="/invoices/edit/{{ invoice.id }}">Edit Invoice</a> |
|
||||||
<a href="#" onclick="window.print(); return false;">Print</a> |
|
||||||
<a href="/invoices/pdf/{{ invoice.id }}">PDF</a> |
|
||||||
{% if invoice.email %} |
|
||||||
<form method="post" action="/invoices/email/{{ invoice.id }}" style="display:inline;"> |
|
||||||
<button type="submit">Send Email</button> |
|
||||||
</form> |
|
||||||
{% endif %} |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="header-row"> |
|
||||||
|
|
||||||
{% if settings.business_logo_url %} |
|
||||||
<img src="{{ settings.business_logo_url }}" style="height:70px;margin-bottom:10px;"> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
<div class="title-box"> |
|
||||||
<h1>Invoice {{ invoice.invoice_number }}</h1> |
|
||||||
<span class="status-badge status-{{ invoice.status }}">{{ invoice.status }}</span> |
|
||||||
</div> |
|
||||||
<div style="text-align:right;"> |
|
||||||
<strong>{{ settings.business_name or 'OTB Billing' }}</strong><br> |
|
||||||
{{ settings.business_tagline or '' }}<br> |
|
||||||
{% if settings.business_address %}{{ settings.business_address }}<br>{% endif %} |
|
||||||
{% if settings.business_email %}{{ settings.business_email }}<br>{% endif %} |
|
||||||
{% if settings.business_phone %}{{ settings.business_phone }}<br>{% endif %} |
|
||||||
{% if settings.business_website %}{{ settings.business_website }}{% endif %} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="info-grid"> |
|
||||||
<div class="info-card"> |
|
||||||
<h3>Bill To</h3> |
|
||||||
<strong>{{ invoice.company_name }}</strong><br> |
|
||||||
{% if invoice.contact_name %}{{ invoice.contact_name }}<br>{% endif %} |
|
||||||
{% if invoice.email %}{{ invoice.email }}<br>{% endif %} |
|
||||||
{% if invoice.phone %}{{ invoice.phone }}<br>{% endif %} |
|
||||||
Client Code: {{ invoice.client_code }} |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="info-card"> |
|
||||||
<h3>Invoice Details</h3> |
|
||||||
Invoice #: {{ invoice.invoice_number }}<br> |
|
||||||
Issued: {{ invoice.issued_at|localtime }}<br> |
|
||||||
Due: {{ invoice.due_at|localtime }}<br> |
|
||||||
{% if invoice.paid_at %}Paid: {{ invoice.paid_at|localtime }}<br>{% endif %} |
|
||||||
Currency: {{ invoice.currency_code }}<br> |
|
||||||
{% if settings.tax_number %}{{ settings.tax_label or 'Tax' }} Number: {{ settings.tax_number }}<br>{% endif %} |
|
||||||
{% if settings.business_number %}Business Number: {{ settings.business_number }}{% endif %} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<table class="summary-table"> |
|
||||||
<tr> |
|
||||||
<th>Service Code</th> |
|
||||||
<th>Service</th> |
|
||||||
<th>Description</th> |
|
||||||
<th>Total</th> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<td>{{ invoice.service_code or '-' }}</td> |
|
||||||
<td>{{ invoice.service_name or '-' }}</td> |
|
||||||
<td>{{ invoice.notes or '-' }}</td> |
|
||||||
<td>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
</table> |
|
||||||
|
|
||||||
<table class="total-table"> |
|
||||||
<tr> |
|
||||||
<th>Subtotal</th> |
|
||||||
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>{{ settings.tax_label or 'Tax' }}</th> |
|
||||||
<td>{{ invoice.tax_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>Total</th> |
|
||||||
<td><strong>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</strong></td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>Paid</th> |
|
||||||
<td>{{ invoice.amount_paid|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>Remaining</th> |
|
||||||
<td>{{ (invoice.total_amount - invoice.amount_paid)|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
</table> |
|
||||||
|
|
||||||
{% if latest_email_log %} |
|
||||||
<div class="notes-box"> |
|
||||||
<strong>Latest Email Activity</strong><br><br> |
|
||||||
Status: {{ latest_email_log.status }}<br> |
|
||||||
Recipient: {{ latest_email_log.recipient_email }}<br> |
|
||||||
Subject: {{ latest_email_log.subject }}<br> |
|
||||||
Sent At: {{ latest_email_log.sent_at|localtime }}<br> |
|
||||||
{% if latest_email_log.error_message %} |
|
||||||
Error: {{ latest_email_log.error_message }} |
|
||||||
{% endif %} |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
{% if settings.payment_terms %} |
|
||||||
<div class="notes-box"> |
|
||||||
<strong>Payment Terms</strong><br><br> |
|
||||||
{{ settings.payment_terms }} |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
{% if settings.invoice_footer %} |
|
||||||
<div class="notes-box"> |
|
||||||
<strong>Footer</strong><br><br> |
|
||||||
{{ settings.invoice_footer }} |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
</div> |
|
||||||
|
|
||||||
{% include "footer.html" %} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -1,91 +0,0 @@ |
|||||||
<!doctype html> |
|
||||||
<html> |
|
||||||
<head> |
|
||||||
<title>Revenue Report</title> |
|
||||||
<style> |
|
||||||
body { font-family: Arial, sans-serif; } |
|
||||||
.report-grid { |
|
||||||
display: grid; |
|
||||||
grid-template-columns: repeat(2, minmax(260px, 1fr)); |
|
||||||
gap: 18px; |
|
||||||
max-width: 900px; |
|
||||||
} |
|
||||||
.card { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 16px; |
|
||||||
} |
|
||||||
.card h2 { |
|
||||||
margin-top: 0; |
|
||||||
margin-bottom: 10px; |
|
||||||
} |
|
||||||
.value { |
|
||||||
font-size: 28px; |
|
||||||
font-weight: bold; |
|
||||||
} |
|
||||||
.action-links a { |
|
||||||
margin-right: 16px; |
|
||||||
} |
|
||||||
</style> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
|
|
||||||
<h1>Revenue Report</h1> |
|
||||||
{% if request.args.get('email_sent') == '1' %} |
|
||||||
<div style="border:1px solid #166534;background:#dcfce7;padding:10px;margin-bottom:15px;max-width:900px;"> |
|
||||||
Revenue report JSON emailed successfully. |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
{% if request.args.get('email_failed') == '1' %} |
|
||||||
<div style="border:1px solid #991b1b;background:#fee2e2;padding:10px;margin-bottom:15px;max-width:900px;"> |
|
||||||
Revenue report email failed. Check SMTP settings or server log. |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
{% if request.args.get('email_failed') == '1' %} |
|
||||||
<div style="border:1px solid #991b1b;background:#fee2e2;padding:10px;margin-bottom:15px;max-width:900px;"> |
|
||||||
Revenue report email failed. Check SMTP settings or server log. |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
<p><a href="/">Home</a></p> |
|
||||||
|
|
||||||
<div class="action-links"> |
|
||||||
<a href="/reports/revenue.json">Export JSON</a> |
|
||||||
<a href="/reports/revenue/print">Print Report Now</a> |
|
||||||
<form method="post" action="/reports/revenue/email" style="display:inline;"> |
|
||||||
<button type="submit">Email JSON Report</button> |
|
||||||
</form> |
|
||||||
</div> |
|
||||||
|
|
||||||
<p> |
|
||||||
Frequency: <strong>{{ report.frequency }}</strong><br> |
|
||||||
Period: <strong>{{ report.period_label }}</strong> |
|
||||||
</p> |
|
||||||
|
|
||||||
<div class="report-grid"> |
|
||||||
<div class="card"> |
|
||||||
<h2>Collected (CAD)</h2> |
|
||||||
<div class="value">{{ report.collected_cad|money('CAD') }}</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="card"> |
|
||||||
<h2>Invoices Issued</h2> |
|
||||||
<div class="value">{{ report.invoice_count }}</div> |
|
||||||
<div>{{ report.invoiced_total|money('CAD') }} CAD total</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="card"> |
|
||||||
<h2>Outstanding Invoices</h2> |
|
||||||
<div class="value">{{ report.outstanding_count }}</div> |
|
||||||
<div>{{ report.outstanding_balance|money('CAD') }} CAD outstanding</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="card"> |
|
||||||
<h2>Overdue Invoices</h2> |
|
||||||
<div class="value">{{ report.overdue_count }}</div> |
|
||||||
<div>{{ report.overdue_balance|money('CAD') }} CAD overdue</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
{% include "footer.html" %} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -1,202 +0,0 @@ |
|||||||
<!doctype html> |
|
||||||
<html> |
|
||||||
<head> |
|
||||||
<title>Settings</title> |
|
||||||
<style> |
|
||||||
body { font-family: Arial, sans-serif; } |
|
||||||
.form-grid { |
|
||||||
display: grid; |
|
||||||
grid-template-columns: 1fr 1fr; |
|
||||||
gap: 24px; |
|
||||||
max-width: 1100px; |
|
||||||
} |
|
||||||
.card { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 16px; |
|
||||||
} |
|
||||||
.card h2 { |
|
||||||
margin-top: 0; |
|
||||||
} |
|
||||||
input[type="text"], |
|
||||||
input[type="email"], |
|
||||||
input[type="password"], |
|
||||||
input[type="number"], |
|
||||||
textarea, |
|
||||||
select { |
|
||||||
width: 100%; |
|
||||||
box-sizing: border-box; |
|
||||||
margin-top: 4px; |
|
||||||
margin-bottom: 12px; |
|
||||||
padding: 8px; |
|
||||||
} |
|
||||||
textarea { min-height: 90px; } |
|
||||||
.checkbox-row { |
|
||||||
margin: 8px 0 14px 0; |
|
||||||
} |
|
||||||
.save-row { |
|
||||||
margin-top: 18px; |
|
||||||
} |
|
||||||
.logo-preview { |
|
||||||
margin: 10px 0 14px 0; |
|
||||||
} |
|
||||||
.logo-preview img { |
|
||||||
max-height: 70px; |
|
||||||
max-width: 220px; |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 6px; |
|
||||||
background: #fff; |
|
||||||
} |
|
||||||
small { |
|
||||||
color: #444; |
|
||||||
} |
|
||||||
</style> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
|
|
||||||
<h1>Settings / Config</h1> |
|
||||||
|
|
||||||
<p><a href="/">Home</a></p> |
|
||||||
|
|
||||||
<form method="post"> |
|
||||||
<div class="form-grid"> |
|
||||||
<div class="card"> |
|
||||||
<h2>Business Identity</h2> |
|
||||||
|
|
||||||
Business Name<br> |
|
||||||
<input type="text" name="business_name" value="{{ settings.business_name }}"><br> |
|
||||||
|
|
||||||
Business Logo URL<br> |
|
||||||
<input type="text" name="business_logo_url" value="{{ settings.business_logo_url }}"><br> |
|
||||||
<small>Example: /static/favicon.png or https://site.com/logo.png</small><br> |
|
||||||
|
|
||||||
{% if settings.business_logo_url %} |
|
||||||
<div class="logo-preview"> |
|
||||||
<img src="{{ settings.business_logo_url }}" alt="Business Logo Preview"> |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
Slogan / Tagline<br> |
|
||||||
<input type="text" name="business_tagline" value="{{ settings.business_tagline }}"><br> |
|
||||||
|
|
||||||
Business Email<br> |
|
||||||
<input type="email" name="business_email" value="{{ settings.business_email }}"><br> |
|
||||||
|
|
||||||
Business Phone<br> |
|
||||||
<input type="text" name="business_phone" value="{{ settings.business_phone }}"><br> |
|
||||||
|
|
||||||
Business Address<br> |
|
||||||
<textarea name="business_address">{{ settings.business_address }}</textarea><br> |
|
||||||
|
|
||||||
Website<br> |
|
||||||
<input type="text" name="business_website" value="{{ settings.business_website }}"><br> |
|
||||||
|
|
||||||
Business Number / Registration Number<br> |
|
||||||
<input type="text" name="business_number" value="{{ settings.business_number }}"><br> |
|
||||||
|
|
||||||
Default Currency<br> |
|
||||||
<select name="default_currency"> |
|
||||||
<option value="CAD" {% if settings.default_currency == 'CAD' %}selected{% endif %}>CAD</option> |
|
||||||
<option value="USD" {% if settings.default_currency == 'USD' %}selected{% endif %}>USD</option> |
|
||||||
<option value="ETHO" {% if settings.default_currency == 'ETHO' %}selected{% endif %}>ETHO</option> |
|
||||||
<option value="EGAZ" {% if settings.default_currency == 'EGAZ' %}selected{% endif %}>EGAZ</option> |
|
||||||
<option value="ALT" {% if settings.default_currency == 'ALT' %}selected{% endif %}>ALT</option> |
|
||||||
</select> |
|
||||||
|
|
||||||
Report Frequency<br> |
|
||||||
<select name="report_frequency"> |
|
||||||
<option value="monthly" {% if settings.report_frequency == 'monthly' %}selected{% endif %}>monthly</option> |
|
||||||
<option value="quarterly" {% if settings.report_frequency == 'quarterly' %}selected{% endif %}>quarterly</option> |
|
||||||
<option value="yearly" {% if settings.report_frequency == 'yearly' %}selected{% endif %}>yearly</option> |
|
||||||
</select> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="card"> |
|
||||||
<h2>Tax Settings</h2> |
|
||||||
|
|
||||||
Local Country<br> |
|
||||||
<input type="text" name="local_country" value="{{ settings.local_country }}"><br> |
|
||||||
|
|
||||||
Tax Label<br> |
|
||||||
<input type="text" name="tax_label" value="{{ settings.tax_label }}"><br> |
|
||||||
|
|
||||||
Tax Rate (%)<br> |
|
||||||
<input type="number" step="0.01" name="tax_rate" value="{{ settings.tax_rate }}"><br> |
|
||||||
|
|
||||||
Tax Number<br> |
|
||||||
<input type="text" name="tax_number" value="{{ settings.tax_number }}"><br> |
|
||||||
|
|
||||||
<div class="checkbox-row"> |
|
||||||
<label> |
|
||||||
<input type="checkbox" name="apply_local_tax_only" value="1" {% if settings.apply_local_tax_only == '1' %}checked{% endif %}> |
|
||||||
Apply tax only to local clients |
|
||||||
</label> |
|
||||||
</div> |
|
||||||
|
|
||||||
Payment Terms<br> |
|
||||||
<textarea name="payment_terms">{{ settings.payment_terms }}</textarea><br> |
|
||||||
|
|
||||||
Invoice Footer<br> |
|
||||||
<textarea name="invoice_footer">{{ settings.invoice_footer }}</textarea><br> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="card"> |
|
||||||
<h2>Advanced / Email / SMTP</h2> |
|
||||||
|
|
||||||
SMTP Host<br> |
|
||||||
<input type="text" name="smtp_host" value="{{ settings.smtp_host }}"><br> |
|
||||||
|
|
||||||
SMTP Port<br> |
|
||||||
<input type="number" name="smtp_port" value="{{ settings.smtp_port }}"><br> |
|
||||||
|
|
||||||
SMTP Username<br> |
|
||||||
<input type="text" name="smtp_user" value="{{ settings.smtp_user }}"><br> |
|
||||||
|
|
||||||
SMTP Password<br> |
|
||||||
<input type="password" name="smtp_pass" value="{{ settings.smtp_pass }}"><br> |
|
||||||
|
|
||||||
From Email<br> |
|
||||||
<input type="email" name="smtp_from_email" value="{{ settings.smtp_from_email }}"><br> |
|
||||||
|
|
||||||
From Name<br> |
|
||||||
<input type="text" name="smtp_from_name" value="{{ settings.smtp_from_name }}"><br> |
|
||||||
|
|
||||||
Report / Accounting Delivery Email<br> |
|
||||||
<input type="email" name="report_delivery_email" value="{{ settings.report_delivery_email }}"><br> |
|
||||||
|
|
||||||
<div class="checkbox-row"> |
|
||||||
<label> |
|
||||||
<input type="checkbox" name="smtp_use_tls" value="1" {% if settings.smtp_use_tls == '1' %}checked{% endif %}> |
|
||||||
Use TLS |
|
||||||
</label> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="checkbox-row"> |
|
||||||
<label> |
|
||||||
<input type="checkbox" name="smtp_use_ssl" value="1" {% if settings.smtp_use_ssl == '1' %}checked{% endif %}> |
|
||||||
Use SSL |
|
||||||
</label> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="card"> |
|
||||||
<h2>Notes</h2> |
|
||||||
<p> |
|
||||||
Branding, tax identity, and SMTP values are stored here for this installation. |
|
||||||
</p> |
|
||||||
<p> |
|
||||||
Logo can be a local static path like <strong>/static/favicon.png</strong> or a full external/IPFS URL. |
|
||||||
</p> |
|
||||||
<p> |
|
||||||
Email sending is not wired yet, but these SMTP settings are stored now so the next step can use them. |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="save-row"> |
|
||||||
<button type="submit">Save Settings</button> |
|
||||||
</div> |
|
||||||
</form> |
|
||||||
|
|
||||||
{% include "footer.html" %} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -1,49 +0,0 @@ |
|||||||
cd /home/def/otb_billing/backend || exit 1 |
|
||||||
set -e |
|
||||||
|
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)" |
|
||||||
cp app.py "app.py.fix-dead-pending.${STAMP}.bak" |
|
||||||
|
|
||||||
python3 <<'PY' |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
p = Path("app.py") |
|
||||||
text = p.read_text() |
|
||||||
|
|
||||||
needle = "if payment_id.isdigit():" |
|
||||||
|
|
||||||
if needle not in text: |
|
||||||
raise SystemExit("FAILED: anchor not found") |
|
||||||
|
|
||||||
inject = """ |
|
||||||
# === KILL DEAD PENDING PAYMENT (no txid + expired lock) === |
|
||||||
if pending_crypto_payment: |
|
||||||
try: |
|
||||||
txid = pending_crypto_payment.get("txid") |
|
||||||
lock_expired = pending_crypto_payment.get("lock_expired") |
|
||||||
|
|
||||||
if (not txid) and lock_expired: |
|
||||||
pending_crypto_payment = None |
|
||||||
payment_id = "" |
|
||||||
except Exception as e: |
|
||||||
print("[dead pending cleanup error]", e) |
|
||||||
""" |
|
||||||
|
|
||||||
text = text.replace(needle, inject + "\n" + needle, 1) |
|
||||||
|
|
||||||
p.write_text(text) |
|
||||||
print("OK: dead pending cleanup injected") |
|
||||||
PY |
|
||||||
|
|
||||||
echo "===== compile check =====" |
|
||||||
python3 -m py_compile app.py && echo "PY_COMPILE_OK" |
|
||||||
|
|
||||||
echo "===== restart service =====" |
|
||||||
sudo systemctl restart otb_billing |
|
||||||
|
|
||||||
echo "===== status =====" |
|
||||||
sudo systemctl status otb_billing --no-pager -l |
|
||||||
|
|
||||||
echo |
|
||||||
echo "DONE" |
|
||||||
echo "Now refresh your invoice page" |
|
||||||
@ -1,60 +0,0 @@ |
|||||||
cd /home/def/otb_billing/backend || exit 1 |
|
||||||
|
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)" |
|
||||||
cp app.py "app.py.retry-email.${STAMP}.bak" |
|
||||||
|
|
||||||
python3 <<'PY' |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
p = Path("app.py") |
|
||||||
text = p.read_text() |
|
||||||
|
|
||||||
old = ''' send_configured_email( |
|
||||||
to_email=invoice_email_row.get("email"), |
|
||||||
subject=subject, |
|
||||||
body=body, |
|
||||||
attachments=attachments, |
|
||||||
email_type="payment_received", |
|
||||||
invoice_id=invoice_id |
|
||||||
) |
|
||||||
return True |
|
||||||
except Exception: |
|
||||||
return False |
|
||||||
''' |
|
||||||
|
|
||||||
new = ''' import time |
|
||||||
|
|
||||||
for attempt in range(3): |
|
||||||
try: |
|
||||||
send_configured_email( |
|
||||||
to_email=invoice_email_row.get("email"), |
|
||||||
subject=subject, |
|
||||||
body=body, |
|
||||||
attachments=attachments, |
|
||||||
email_type="payment_received", |
|
||||||
invoice_id=invoice_id |
|
||||||
) |
|
||||||
return True |
|
||||||
except Exception as e: |
|
||||||
print(f"[email retry] invoice_id={invoice_id} attempt={attempt+1} error={type(e).__name__}: {e}") |
|
||||||
if attempt < 2: |
|
||||||
time.sleep(2) |
|
||||||
|
|
||||||
print(f"[send_payment_received_email] FAILED after retries invoice_id={invoice_id}") |
|
||||||
return False |
|
||||||
except Exception: |
|
||||||
return False |
|
||||||
''' |
|
||||||
|
|
||||||
if old not in text: |
|
||||||
raise SystemExit("FAILED: exact helper send block not found") |
|
||||||
|
|
||||||
text = text.replace(old, new, 1) |
|
||||||
p.write_text(text) |
|
||||||
print("OK: retry logic added to helper") |
|
||||||
PY |
|
||||||
|
|
||||||
python3 -m py_compile app.py && echo "PY_COMPILE_OK" || echo "PY_COMPILE_FAILED" |
|
||||||
sudo systemctl restart otb_billing |
|
||||||
sudo systemctl status otb_billing --no-pager -l |
|
||||||
|
|
||||||
@ -1,51 +0,0 @@ |
|||||||
cd /home/def/otb_billing/backend || exit 1 |
|
||||||
|
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)" |
|
||||||
cp app.py "app.py.retry-email.${STAMP}.bak" |
|
||||||
|
|
||||||
python3 <<'PY' |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
p = Path("app.py") |
|
||||||
text = p.read_text() |
|
||||||
|
|
||||||
old = """ send_configured_email( |
|
||||||
to_email=invoice_email_row.get("client_email"), |
|
||||||
subject=subject, |
|
||||||
html_body=html, |
|
||||||
attachments=attachments |
|
||||||
) |
|
||||||
return True |
|
||||||
except Exception: |
|
||||||
return False |
|
||||||
""" |
|
||||||
|
|
||||||
new = """ import time |
|
||||||
|
|
||||||
for attempt in range(3): |
|
||||||
try: |
|
||||||
send_configured_email( |
|
||||||
to_email=invoice_email_row.get("client_email"), |
|
||||||
subject=subject, |
|
||||||
html_body=html, |
|
||||||
attachments=attachments |
|
||||||
) |
|
||||||
return True |
|
||||||
except Exception as e: |
|
||||||
print(f"[email retry] attempt {attempt+1} failed: {e}") |
|
||||||
time.sleep(2) |
|
||||||
|
|
||||||
print(f"[send_payment_received_email] FAILED after retries invoice_id={invoice_id}") |
|
||||||
return False |
|
||||||
""" |
|
||||||
|
|
||||||
if old not in text: |
|
||||||
raise SystemExit("FAILED: send email block not found") |
|
||||||
|
|
||||||
text = text.replace(old, new, 1) |
|
||||||
p.write_text(text) |
|
||||||
print("OK: retry logic added") |
|
||||||
PY |
|
||||||
|
|
||||||
python3 -m py_compile app.py && echo "PY_COMPILE_OK" || echo "PY_COMPILE_FAILED" |
|
||||||
sudo systemctl restart otb_billing |
|
||||||
@ -1,63 +0,0 @@ |
|||||||
cd /home/def/otb_billing || exit 1 |
|
||||||
set -e |
|
||||||
|
|
||||||
NEWVER="v0.5.1" |
|
||||||
STAMP="$(date '+%Y-%m-%d %H:%M:%S')" |
|
||||||
ZIPNAME="otb_billing-${NEWVER}.zip" |
|
||||||
|
|
||||||
echo "===== git status =====" |
|
||||||
git status --short || true |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== update VERSION =====" |
|
||||||
echo "${NEWVER}" > VERSION |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== backup README =====" |
|
||||||
cp README.md "README.md.bak.${NEWVER}" |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== prepend README entry =====" |
|
||||||
python3 <<'PY' |
|
||||||
from pathlib import Path |
|
||||||
from datetime import datetime |
|
||||||
|
|
||||||
p = Path("README.md") |
|
||||||
old = p.read_text() |
|
||||||
|
|
||||||
entry = f"""## {datetime.now().strftime('%Y-%m-%d')} — v0.5.1 |
|
||||||
|
|
||||||
- Fixed crypto payment email auto-send path |
|
||||||
- Fixed payment-received emails to attach the real invoice PDF |
|
||||||
- Switched helper PDF generation to use the working invoice PDF route |
|
||||||
- Added explorer link into the payment-received email body |
|
||||||
- Improved invoice PDF payment section with time, TXID, wallet, and rate display |
|
||||||
- Cleaned helper error handling back to safe non-debug behavior |
|
||||||
|
|
||||||
""" |
|
||||||
|
|
||||||
p.write_text(entry + "\n" + old) |
|
||||||
print("README updated") |
|
||||||
PY |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== git add =====" |
|
||||||
git add . |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== git commit =====" |
|
||||||
git commit -m "v0.5.1 - crypto email/pdf fixes and payment layout cleanup" |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== git push =====" |
|
||||||
git push |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== build full zip backup =====" |
|
||||||
cd /home/def || exit 1 |
|
||||||
rm -f "${ZIPNAME}" |
|
||||||
zip -r "${ZIPNAME}" otb_billing >/dev/null |
|
||||||
|
|
||||||
echo |
|
||||||
echo "===== done =====" |
|
||||||
echo "ZIP: /home/def/${ZIPNAME}" |
|
||||||
@ -1,51 +0,0 @@ |
|||||||
cd /home/def/otb_billing || exit 1 |
|
||||||
set -e |
|
||||||
|
|
||||||
NEWVER="v0.5.1" |
|
||||||
STAMP="$(date '+%Y-%m-%d %H:%M:%S')" |
|
||||||
ZIPNAME="otb_billing-${NEWVER}.zip" |
|
||||||
|
|
||||||
echo "===== git status =====" |
|
||||||
git status --short || true |
|
||||||
|
|
||||||
echo "===== update VERSION =====" |
|
||||||
echo "${NEWVER}" > VERSION |
|
||||||
|
|
||||||
echo "===== update README.md =====" |
|
||||||
cp README.md "README.md.bak.${NEWVER}" |
|
||||||
|
|
||||||
python3 <<'PY' |
|
||||||
from pathlib import Path |
|
||||||
from datetime import datetime |
|
||||||
|
|
||||||
p = Path("README.md") |
|
||||||
text = p.read_text() |
|
||||||
|
|
||||||
entry = f"""## {datetime.now().strftime('%Y-%m-%d')} — v0.5.1 |
|
||||||
|
|
||||||
- Fixed crypto payment email auto-send failure |
|
||||||
- Replaced internal PDF generator call with route-based PDF fetch |
|
||||||
- Restored PDF attachments in payment emails |
|
||||||
- Improved Payments Applied layout in invoice PDF (multi-line details + rate display) |
|
||||||
- Stabilized send_payment_received_email() (removed debug raise, safe failure handling) |
|
||||||
|
|
||||||
""" |
|
||||||
|
|
||||||
p.write_text(entry + "\n" + text) |
|
||||||
print("OK: README updated") |
|
||||||
PY |
|
||||||
|
|
||||||
echo "===== git add =====" |
|
||||||
git add . |
|
||||||
|
|
||||||
echo "===== git commit =====" |
|
||||||
git commit -m "v0.5.1 - crypto email fix, PDF attachment fix, payment layout improvements" |
|
||||||
|
|
||||||
echo "===== git push =====" |
|
||||||
git push |
|
||||||
|
|
||||||
echo "===== build zip =====" |
|
||||||
cd /home/def || exit 1 |
|
||||||
zip -r "${ZIPNAME}" otb_billing >/dev/null |
|
||||||
|
|
||||||
echo "ZIP CREATED: /home/def/${ZIPNAME}" |
|
||||||
@ -1,256 +0,0 @@ |
|||||||
<!doctype html> |
|
||||||
<html> |
|
||||||
<head> |
|
||||||
<title>Invoice {{ invoice.invoice_number }}</title> |
|
||||||
<style> |
|
||||||
body { |
|
||||||
font-family: Arial, sans-serif; |
|
||||||
margin: 30px; |
|
||||||
color: #111; |
|
||||||
} |
|
||||||
.top-links { |
|
||||||
margin-bottom: 20px; |
|
||||||
} |
|
||||||
.top-links a { |
|
||||||
margin-right: 15px; |
|
||||||
} |
|
||||||
.invoice-wrap { |
|
||||||
max-width: 900px; |
|
||||||
} |
|
||||||
.header-row { |
|
||||||
display: flex; |
|
||||||
justify-content: space-between; |
|
||||||
align-items: flex-start; |
|
||||||
margin-bottom: 25px; |
|
||||||
} |
|
||||||
.title-box h1 { |
|
||||||
margin: 0 0 8px 0; |
|
||||||
} |
|
||||||
.status-badge { |
|
||||||
display: inline-block; |
|
||||||
padding: 4px 10px; |
|
||||||
border-radius: 999px; |
|
||||||
font-size: 12px; |
|
||||||
font-weight: bold; |
|
||||||
text-transform: uppercase; |
|
||||||
} |
|
||||||
.status-draft { background: #e5e7eb; color: #111827; } |
|
||||||
.status-pending { background: #dbeafe; color: #1d4ed8; } |
|
||||||
.status-partial { background: #fef3c7; color: #92400e; } |
|
||||||
.status-paid { background: #dcfce7; color: #166534; } |
|
||||||
.status-overdue { background: #fee2e2; color: #991b1b; } |
|
||||||
.status-cancelled { background: #e5e7eb; color: #4b5563; } |
|
||||||
|
|
||||||
.info-grid { |
|
||||||
display: grid; |
|
||||||
grid-template-columns: 1fr 1fr; |
|
||||||
gap: 30px; |
|
||||||
margin-bottom: 25px; |
|
||||||
} |
|
||||||
.info-card { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 15px; |
|
||||||
} |
|
||||||
.info-card h3 { |
|
||||||
margin-top: 0; |
|
||||||
} |
|
||||||
.summary-table, |
|
||||||
.total-table { |
|
||||||
width: 100%; |
|
||||||
border-collapse: collapse; |
|
||||||
margin-bottom: 25px; |
|
||||||
} |
|
||||||
.summary-table th, |
|
||||||
.summary-table td, |
|
||||||
.total-table th, |
|
||||||
.total-table td { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 10px; |
|
||||||
text-align: left; |
|
||||||
} |
|
||||||
.total-table { |
|
||||||
max-width: 420px; |
|
||||||
margin-left: auto; |
|
||||||
} |
|
||||||
.notes-box { |
|
||||||
border: 1px solid #ccc; |
|
||||||
padding: 15px; |
|
||||||
white-space: pre-wrap; |
|
||||||
margin-top: 20px; |
|
||||||
} |
|
||||||
.print-only { |
|
||||||
display: none; |
|
||||||
} |
|
||||||
@media print { |
|
||||||
.top-links, |
|
||||||
.screen-only, |
|
||||||
footer { |
|
||||||
display: none !important; |
|
||||||
} |
|
||||||
.print-only { |
|
||||||
display: block; |
|
||||||
} |
|
||||||
body { |
|
||||||
margin: 0; |
|
||||||
} |
|
||||||
} |
|
||||||
</style> |
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png"> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
|
|
||||||
<div class="invoice-wrap"> |
|
||||||
{% if request.args.get('email_sent') == '1' %} |
|
||||||
<div style="border:1px solid #166534;background:#dcfce7;padding:10px;margin-bottom:15px;"> |
|
||||||
Invoice email sent successfully. |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
{% if request.args.get('email_failed') == '1' %} |
|
||||||
<div style="border:1px solid #991b1b;background:#fee2e2;padding:10px;margin-bottom:15px;"> |
|
||||||
Invoice email failed. Check SMTP settings or server log. |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
<div class="top-links screen-only"> |
|
||||||
<a href="/">Home</a> |
|
||||||
<a href="/invoices">Back to Invoices</a> |
|
||||||
<a href="/invoices/edit/{{ invoice.id }}">Edit Invoice</a> |
|
||||||
<a href="#" onclick="window.print(); return false;">Print</a> |
|
||||||
<a href="/invoices/pdf/{{ invoice.id }}">PDF</a> |
|
||||||
{% if invoice.email %} |
|
||||||
<form method="post" action="/invoices/email/{{ invoice.id }}" style="display:inline;"> |
|
||||||
<button type="submit">Send Email</button> |
|
||||||
</form> |
|
||||||
{% endif %} |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="header-row"> |
|
||||||
|
|
||||||
{% if settings.business_logo_url %} |
|
||||||
<img src="{{ settings.business_logo_url }}" style="height:70px;margin-bottom:10px;"> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
<div class="title-box"> |
|
||||||
<h1>Invoice {{ invoice.invoice_number }}</h1> |
|
||||||
<span class="status-badge status-{{ invoice.status }}">{{ invoice.status }}</span> |
|
||||||
</div> |
|
||||||
<div style="text-align:right;"> |
|
||||||
<strong>{{ settings.business_name or 'OTB Billing' }}</strong><br> |
|
||||||
{{ settings.business_tagline or '' }}<br> |
|
||||||
{% if settings.business_address %}{{ settings.business_address }}<br>{% endif %} |
|
||||||
{% if settings.business_email %}{{ settings.business_email }}<br>{% endif %} |
|
||||||
{% if settings.business_phone %}{{ settings.business_phone }}<br>{% endif %} |
|
||||||
{% if settings.business_website %}{{ settings.business_website }}{% endif %} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="info-grid"> |
|
||||||
<div class="info-card"> |
|
||||||
<h3>Bill To</h3> |
|
||||||
<strong>{{ invoice.company_name }}</strong><br> |
|
||||||
{% if invoice.contact_name %}{{ invoice.contact_name }}<br>{% endif %} |
|
||||||
{% if invoice.email %}{{ invoice.email }}<br>{% endif %} |
|
||||||
{% if invoice.phone %}{{ invoice.phone }}<br>{% endif %} |
|
||||||
Client Code: {{ invoice.client_code }} |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="info-card"> |
|
||||||
<h3>Invoice Details</h3> |
|
||||||
Invoice #: {{ invoice.invoice_number }}<br> |
|
||||||
Issued: {{ invoice.issued_at|localtime }}<br> |
|
||||||
Due: {{ invoice.due_at|localtime }}<br> |
|
||||||
{% if invoice.paid_at %}Paid: {{ invoice.paid_at|localtime }}<br>{% endif %} |
|
||||||
Currency: {{ invoice.currency_code }}<br> |
|
||||||
{% if settings.tax_number %}{{ settings.tax_label or 'Tax' }} Number: {{ settings.tax_number }}<br>{% endif %} |
|
||||||
{% if settings.business_number %}Business Number: {{ settings.business_number }}{% endif %} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<table class="summary-table"> |
|
||||||
<tr> |
|
||||||
<th>Service Code</th> |
|
||||||
<th>Service</th> |
|
||||||
<th>Description</th> |
|
||||||
<th>Total</th> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<td>{{ invoice.service_code or '-' }}</td> |
|
||||||
<td>{{ invoice.service_name or '-' }}</td> |
|
||||||
<td>{{ invoice.notes or '-' }}</td> |
|
||||||
<td>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
</table> |
|
||||||
|
|
||||||
<table class="total-table"> |
|
||||||
<tr> |
|
||||||
<th>Subtotal</th> |
|
||||||
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>{{ settings.tax_label or 'Tax' }}</th> |
|
||||||
<td>{{ invoice.tax_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>Total</th> |
|
||||||
<td><strong>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</strong></td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>Paid</th> |
|
||||||
<td>{{ invoice.amount_paid|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
<tr> |
|
||||||
<th>Remaining</th> |
|
||||||
<td>{{ (invoice.total_amount - invoice.amount_paid)|money(invoice.currency_code) }} {{ invoice.currency_code }}</td> |
|
||||||
</tr> |
|
||||||
</table> |
|
||||||
|
|
||||||
{% if latest_email_log %} |
|
||||||
<div class="notes-box"> |
|
||||||
<strong>Latest Email Activity</strong><br><br> |
|
||||||
Status: {{ latest_email_log.status }}<br> |
|
||||||
Recipient: {{ latest_email_log.recipient_email }}<br> |
|
||||||
Subject: {{ latest_email_log.subject }}<br> |
|
||||||
Sent At: {{ latest_email_log.sent_at|localtime }}<br> |
|
||||||
{% if latest_email_log.error_message %} |
|
||||||
Error: {{ latest_email_log.error_message }} |
|
||||||
{% endif %} |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
{% if settings.payment_terms %} |
|
||||||
<div class="notes-box"> |
|
||||||
<strong>Payment Terms</strong><br><br> |
|
||||||
{{ settings.payment_terms }} |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
{% if settings.invoice_footer %} |
|
||||||
<div class="notes-box"> |
|
||||||
<strong>Footer</strong><br><br> |
|
||||||
{{ settings.invoice_footer }} |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
</div> |
|
||||||
|
|
||||||
{% include "footer.html" %} |
|
||||||
|
|
||||||
<hr> |
|
||||||
|
|
||||||
<h3>Payment Instructions</h3> |
|
||||||
|
|
||||||
<p><strong>Interac e-Transfer</strong><br> |
|
||||||
Send payment to:<br> |
|
||||||
payment@outsidethebox.top<br> |
|
||||||
Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }} |
|
||||||
</p> |
|
||||||
|
|
||||||
<p><strong>Credit Card (Square)</strong><br> |
|
||||||
Contact us for a secure Square payment link.</p> |
|
||||||
|
|
||||||
<p> |
|
||||||
If you have questions please contact |
|
||||||
<a href="mailto:support@outsidethebox.top">support@outsidethebox.top</a> |
|
||||||
</p> |
|
||||||
|
|
||||||
|
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -1,187 +0,0 @@ |
|||||||
<!DOCTYPE html> |
|
||||||
<html lang="en"> |
|
||||||
<head> |
|
||||||
<meta charset="UTF-8"> |
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
||||||
<title>Invoice Detail - OutsideTheBox</title> |
|
||||||
<link rel="stylesheet" href="/static/css/style.css"> |
|
||||||
<style> |
|
||||||
.portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } |
|
||||||
.portal-top { |
|
||||||
display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; |
|
||||||
margin-bottom: 1rem; |
|
||||||
} |
|
||||||
.portal-actions a { |
|
||||||
margin-left: 0.75rem; |
|
||||||
text-decoration: underline; |
|
||||||
} |
|
||||||
.detail-grid { |
|
||||||
display:grid; |
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
|
||||||
gap:1rem; |
|
||||||
margin: 1rem 0 1.25rem 0; |
|
||||||
} |
|
||||||
.detail-card { |
|
||||||
border: 1px solid rgba(255,255,255,0.16); |
|
||||||
border-radius: 14px; |
|
||||||
padding: 1rem; |
|
||||||
background: rgba(255,255,255,0.03); |
|
||||||
} |
|
||||||
.detail-card h3 { |
|
||||||
margin-top: 0; |
|
||||||
margin-bottom: 0.4rem; |
|
||||||
} |
|
||||||
table.portal-table { |
|
||||||
width: 100%; |
|
||||||
border-collapse: collapse; |
|
||||||
margin-top: 1rem; |
|
||||||
} |
|
||||||
table.portal-table th, table.portal-table td { |
|
||||||
padding: 0.8rem; |
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.12); |
|
||||||
text-align: left; |
|
||||||
} |
|
||||||
table.portal-table th { |
|
||||||
background: #e9eef7; |
|
||||||
color: #10203f; |
|
||||||
} |
|
||||||
.invoice-actions { |
|
||||||
margin-top: 1rem; |
|
||||||
} |
|
||||||
.invoice-actions a { |
|
||||||
margin-right: 1rem; |
|
||||||
text-decoration: underline; |
|
||||||
} |
|
||||||
.status-badge { |
|
||||||
display: inline-block; |
|
||||||
padding: 0.18rem 0.55rem; |
|
||||||
border-radius: 999px; |
|
||||||
font-size: 0.86rem; |
|
||||||
font-weight: 700; |
|
||||||
} |
|
||||||
.status-paid { |
|
||||||
background: rgba(34, 197, 94, 0.18); |
|
||||||
color: #4ade80; |
|
||||||
} |
|
||||||
.status-pending { |
|
||||||
background: rgba(245, 158, 11, 0.20); |
|
||||||
color: #fbbf24; |
|
||||||
} |
|
||||||
.status-overdue { |
|
||||||
background: rgba(239, 68, 68, 0.18); |
|
||||||
color: #f87171; |
|
||||||
} |
|
||||||
.status-other { |
|
||||||
background: rgba(148, 163, 184, 0.20); |
|
||||||
color: #cbd5e1; |
|
||||||
} |
|
||||||
</style> |
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png"> |
|
||||||
</head> |
|
||||||
<body> |
|
||||||
<div class="portal-wrap"> |
|
||||||
<div class="portal-top"> |
|
||||||
<div> |
|
||||||
<h1>Invoice Detail</h1> |
|
||||||
<p>{{ client.company_name or client.contact_name or client.email }}</p> |
|
||||||
</div> |
|
||||||
<div class="portal-actions"> |
|
||||||
<a href="/portal/dashboard">Back to Dashboard</a> |
|
||||||
<a href="https://outsidethebox.top/">Home</a> |
|
||||||
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact Support</a> |
|
||||||
<a href="/portal/logout">Logout</a> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="detail-grid"> |
|
||||||
<div class="detail-card"> |
|
||||||
<h3>Invoice</h3> |
|
||||||
<div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div> |
|
||||||
</div> |
|
||||||
<div class="detail-card"> |
|
||||||
<h3>Status</h3> |
|
||||||
{% set s = (invoice.status or "")|lower %} |
|
||||||
{% if s == "paid" %} |
|
||||||
<span class="status-badge status-paid">{{ invoice.status }}</span> |
|
||||||
{% elif s == "pending" %} |
|
||||||
<span class="status-badge status-pending">{{ invoice.status }}</span> |
|
||||||
{% elif s == "overdue" %} |
|
||||||
<span class="status-badge status-overdue">{{ invoice.status }}</span> |
|
||||||
{% else %} |
|
||||||
<span class="status-badge status-other">{{ invoice.status }}</span> |
|
||||||
{% endif %} |
|
||||||
</div> |
|
||||||
<div class="detail-card"> |
|
||||||
<h3>Created</h3> |
|
||||||
<div>{{ invoice.created_at }}</div> |
|
||||||
</div> |
|
||||||
<div class="detail-card"> |
|
||||||
<h3>Total</h3> |
|
||||||
<div>{{ invoice.total_amount }}</div> |
|
||||||
</div> |
|
||||||
<div class="detail-card"> |
|
||||||
<h3>Paid</h3> |
|
||||||
<div>{{ invoice.amount_paid }}</div> |
|
||||||
</div> |
|
||||||
<div class="detail-card"> |
|
||||||
<h3>Outstanding</h3> |
|
||||||
<div>{{ invoice.outstanding }}</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<h2>Invoice Items</h2> |
|
||||||
<table class="portal-table"> |
|
||||||
<thead> |
|
||||||
<tr> |
|
||||||
<th>Description</th> |
|
||||||
<th>Qty</th> |
|
||||||
<th>Unit Price</th> |
|
||||||
<th>Line Total</th> |
|
||||||
</tr> |
|
||||||
</thead> |
|
||||||
<tbody> |
|
||||||
{% for item in items %} |
|
||||||
<tr> |
|
||||||
<td>{{ item.description }}</td> |
|
||||||
<td>{{ item.quantity }}</td> |
|
||||||
<td>{{ item.unit_price }}</td> |
|
||||||
<td>{{ item.line_total }}</td> |
|
||||||
</tr> |
|
||||||
{% else %} |
|
||||||
<tr> |
|
||||||
<td colspan="4">No invoice line items found.</td> |
|
||||||
</tr> |
|
||||||
{% endfor %} |
|
||||||
</tbody> |
|
||||||
</table> |
|
||||||
|
|
||||||
{% if pdf_url %} |
|
||||||
<div class="invoice-actions"> |
|
||||||
<a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a> |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
</div> |
|
||||||
|
|
||||||
{% include "footer.html" %} |
|
||||||
|
|
||||||
<hr> |
|
||||||
|
|
||||||
<h3>Payment Instructions</h3> |
|
||||||
|
|
||||||
<p><strong>Interac e-Transfer</strong><br> |
|
||||||
Send payment to:<br> |
|
||||||
payment@outsidethebox.top<br> |
|
||||||
Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }} |
|
||||||
</p> |
|
||||||
|
|
||||||
<p><strong>Credit Card (Square)</strong><br> |
|
||||||
Contact us for a secure Square payment link.</p> |
|
||||||
|
|
||||||
<p> |
|
||||||
If you have questions please contact |
|
||||||
<a href="mailto:support@outsidethebox.top">support@outsidethebox.top</a> |
|
||||||
</p> |
|
||||||
|
|
||||||
|
|
||||||
</body> |
|
||||||
</html> |
|
||||||
Loading…
Reference in new issue