Browse Source

Bump to v2.0.9 portal credit payments

main
def 3 weeks ago
parent
commit
c63af4c614
  1. 16
      PROJECT_STATE.md
  2. 16
      README.md
  3. 2
      VERSION
  4. 124
      backend/app.py
  5. 13
      sql/migrations/20260529_v2_0_9_credit_payment_method.sql
  6. 2
      templates/portal_invoice_detail.html

16
PROJECT_STATE.md

@ -1,3 +1,19 @@
## v2.0.9 portal account-credit payments - 2026-05-29 UTC
- Added portal “Use available credit” payment option for unpaid CAD invoices.
- Added `/portal/invoice/<invoice_id>/apply-credit` route.
- Added first-class `credit` value to `payments.payment_method`.
- Applying credit now writes a confirmed `payments` row with `payment_method='credit'`.
- Applying credit now writes a matching `credit_ledger` debit using `entry_type='invoice_deduction'`.
- Invoice totals/status are recalculated through the existing `recalc_invoice_totals()` workflow.
- Portal invoice detail now shows credit success/error messages.
- Portal payment history and admin invoice payment history now show Credit payments.
- Added SQL migration record: `sql/migrations/20260529_v2_0_9_credit_payment_method.sql`.
Verified:
- INV-0045 / invoice id 52 was paid using $11.30 CAD account credit.
- Credit ledger balance dropped from $25.00 CAD to $13.70 CAD.
## v2.0.8 multi-line invoice creation - 2026-05-29 UTC
- Added multi-line invoice item creation on the admin Create Invoice page.

16
README.md

@ -1,3 +1,19 @@
## v2.0.9 portal account-credit payments - 2026-05-29 UTC
- Added portal “Use available credit” payment option for unpaid CAD invoices.
- Added `/portal/invoice/<invoice_id>/apply-credit` route.
- Added first-class `credit` value to `payments.payment_method`.
- Applying credit now writes a confirmed `payments` row with `payment_method='credit'`.
- Applying credit now writes a matching `credit_ledger` debit using `entry_type='invoice_deduction'`.
- Invoice totals/status are recalculated through the existing `recalc_invoice_totals()` workflow.
- Portal invoice detail now shows credit success/error messages.
- Portal payment history and admin invoice payment history now show Credit payments.
- Added SQL migration record: `sql/migrations/20260529_v2_0_9_credit_payment_method.sql`.
Verified:
- INV-0045 / invoice id 52 was paid using $11.30 CAD account credit.
- Credit ledger balance dropped from $25.00 CAD to $13.70 CAD.
## v2.0.8 multi-line invoice creation - 2026-05-29 UTC
- Added multi-line invoice item creation on the admin Create Invoice page.

2
VERSION

@ -1 +1 @@
v2.0.8
v2.0.9

124
backend/app.py

@ -144,6 +144,8 @@ def payment_method_label(method, currency=None):
return "e-Transfer"
if method_key == "cash":
return "Cash"
if method_key == "credit":
return "Credit"
if method_key == "other":
if currency_key in {"ETH", "ETHO", "ETI", "USDC", "EGAZ", "ALT", "CAD"}:
return currency_key
@ -5554,6 +5556,8 @@ def portal_dashboard():
return "e-Transfer"
if method_key == "cash":
return "Cash"
if method_key == "credit":
return "Credit"
if method_key == "crypto_etho":
return "ETHO"
if method_key == "crypto_egaz":
@ -6152,6 +6156,11 @@ def portal_invoice_detail(invoice_id):
pdf_url = f"/invoices/pdf/{invoice_id}"
invoice_payments = get_invoice_payments(invoice_id)
client_credit_balance_dec = get_client_credit_balance(client["id"])
client_credit_balance = _fmt_money(client_credit_balance_dec)
credit_applied = (request.args.get("credit_applied") or "").strip()
credit_error = (request.args.get("credit_error") or "").strip()
conn.close()
@ -6163,6 +6172,10 @@ def portal_invoice_detail(invoice_id):
pdf_url=pdf_url,
pay_mode=pay_mode,
crypto_error=crypto_error,
credit_applied=credit_applied,
credit_error=credit_error,
client_credit_balance=client_credit_balance,
client_credit_balance_dec=client_credit_balance_dec,
crypto_options=crypto_options,
selected_crypto_option=selected_crypto_option,
pending_crypto_payment=pending_crypto_payment,
@ -6173,6 +6186,117 @@ def portal_invoice_detail(invoice_id):
@app.route("/portal/invoice/<int:invoice_id>/apply-credit", methods=["POST"])
def portal_apply_credit(invoice_id):
client = _portal_current_client()
if not client:
return redirect("/portal")
if portal_terms_required(client):
return redirect("/portal/terms")
from decimal import Decimal, ROUND_HALF_UP
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT id, client_id, invoice_number, status, total_amount, amount_paid, currency_code
FROM invoices
WHERE id = %s AND client_id = %s
LIMIT 1
""", (invoice_id, client["id"]))
invoice = cursor.fetchone()
if not invoice:
conn.close()
return redirect("/portal/dashboard")
if (invoice.get("status") or "").lower() in {"paid", "cancelled"}:
conn.close()
return redirect(f"/portal/invoice/{invoice_id}?credit_error=closed")
if str(invoice.get("currency_code") or "CAD").upper() != "CAD":
conn.close()
return redirect(f"/portal/invoice/{invoice_id}?credit_error=currency")
outstanding = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
if outstanding <= 0:
conn.close()
return redirect(f"/portal/invoice/{invoice_id}?credit_error=no-balance")
cursor.execute("""
SELECT COALESCE(SUM(amount), 0) AS balance
FROM credit_ledger
WHERE client_id = %s
AND currency_code = 'CAD'
""", (client["id"],))
credit_row = cursor.fetchone() or {}
credit_balance = to_decimal(credit_row.get("balance"))
if credit_balance <= 0:
conn.close()
return redirect(f"/portal/invoice/{invoice_id}?credit_error=no-credit")
apply_amount = credit_balance if credit_balance < outstanding else outstanding
apply_amount = Decimal(str(apply_amount)).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
cursor.execute("""
INSERT INTO credit_ledger
(
client_id,
entry_type,
amount,
currency_code,
reference_type,
reference_id,
notes
)
VALUES (%s, 'invoice_deduction', %s, 'CAD', 'invoice', %s, %s)
""", (
client["id"],
str(-apply_amount),
invoice_id,
f"Applied client credit to invoice {invoice.get('invoice_number') or invoice_id}",
))
cursor.execute("""
INSERT INTO payments
(
invoice_id,
client_id,
payment_method,
payment_currency,
payment_amount,
cad_value_at_payment,
expected_amount_cad,
received_amount_cad,
reference,
payment_status,
review_status,
received_at,
notes
)
VALUES (%s, %s, 'credit', 'CAD', %s, %s, %s, %s, %s, 'confirmed', 'approved', UTC_TIMESTAMP(), %s)
""", (
invoice_id,
client["id"],
str(apply_amount),
str(apply_amount),
str(apply_amount),
str(apply_amount),
f"Applied client credit to {invoice.get('invoice_number') or invoice_id}",
"portal_credit_apply",
))
conn.commit()
conn.close()
recalc_invoice_totals(invoice_id)
return redirect(f"/portal/invoice/{invoice_id}?credit_applied={apply_amount}")
@app.route("/portal/terms", methods=["GET", "POST"])
def portal_terms():
client = _portal_current_client()

13
sql/migrations/20260529_v2_0_9_credit_payment_method.sql

@ -0,0 +1,13 @@
-- OTB Billing v2.0.9
-- Adds real account-credit payments as a first-class payment method.
ALTER TABLE payments
MODIFY payment_method ENUM(
'square',
'etransfer',
'crypto_etho',
'crypto_egaz',
'crypto_alt',
'cash',
'credit',
'other'
) NOT NULL;

2
templates/portal_invoice_detail.html

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save