From c63af4c6142ea61e3254679194cc4c893ee8d305 Mon Sep 17 00:00:00 2001 From: def Date: Fri, 29 May 2026 04:26:57 +0000 Subject: [PATCH] Bump to v2.0.9 portal credit payments --- PROJECT_STATE.md | 16 +++ README.md | 16 +++ VERSION | 2 +- backend/app.py | 124 ++++++++++++++++++ .../20260529_v2_0_9_credit_payment_method.sql | 13 ++ templates/portal_invoice_detail.html | 2 +- 6 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 sql/migrations/20260529_v2_0_9_credit_payment_method.sql diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index c65934c..f60d23d 100644 --- a/PROJECT_STATE.md +++ b/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//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. diff --git a/README.md b/README.md index 424fedb..264270d 100644 --- a/README.md +++ b/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//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. diff --git a/VERSION b/VERSION index 923fd4d..c03bb3d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.0.8 +v2.0.9 diff --git a/backend/app.py b/backend/app.py index a2ea46e..92b02e1 100644 --- a/backend/app.py +++ b/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//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() diff --git a/sql/migrations/20260529_v2_0_9_credit_payment_method.sql b/sql/migrations/20260529_v2_0_9_credit_payment_method.sql new file mode 100644 index 0000000..a71a8e3 --- /dev/null +++ b/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; diff --git a/templates/portal_invoice_detail.html b/templates/portal_invoice_detail.html index d6d7b30..6a00c2d 100644 --- a/templates/portal_invoice_detail.html +++ b/templates/portal_invoice_detail.html @@ -4,7 +4,7 @@ -{% include "includes/site_nav.html" %}

Invoice Detail

{{ client.company_name or client.contact_name or client.email }}

{% if (invoice.status or "")|lower == "paid" %}
✓ This invoice has been paid. Thank you!
{% endif %} {% if crypto_error %}
{{ crypto_error }}
{% endif %}

Invoice

{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}

Status

{% set s = (invoice.status or "")|lower %} {% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} processing {% elif s == "paid" %}{{ invoice.status }} {% elif s == "pending" %}{{ invoice.status }} {% elif s == "overdue" %}{{ invoice.status }} {% else %}{{ invoice.status }}{% endif %}

Created

{{ invoice.created_at }}

Total

{{ invoice.total_amount }}

Paid

{{ invoice.amount_paid }}

Outstanding

{{ invoice.outstanding }}

Invoice Items

{% for item in items %} {% else %} {% endfor %}
DescriptionQtyUnit PriceLine Total
{{ item.description }}{{ item.quantity }}{{ item.unit_price }}{{ item.line_total }}
No invoice line items found.

Invoice Summary

{% if invoice.tax_amount and invoice.tax_amount|float > 0 %}{% endif %}
Subtotal{{ invoice.subtotal_amount }}
HST 13%{{ invoice.tax_amount }}
Total Amount{{ invoice.total_amount }}
Paid{{ invoice.amount_paid }}
Outstanding{{ invoice.outstanding }}
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %}

Pay Now

Interac e-Transfer
Send payment to:
payment@outsidethebox.top
Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}

Credit Card (Square)

Pay with Credit Card
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %}

Crypto Quote Snapshot

Quoted At: {{ invoice.oracle_quote.quoted_at or "—" }}
Source Status: {{ invoice.oracle_quote.source_status or "—" }}
Frozen Amount: {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}
{% if pending_crypto_payment %}
Your quote is protected after acceptance.
{% else %}
Select a crypto asset to accept the quote.
{% endif %}
{% if pending_crypto_payment and pending_crypto_payment.txid %}
--:--
Watching transaction / waiting for confirmation
{% elif pending_crypto_payment %}
--:--
Quote protected while you open wallet
{% else %}
--:--
This price times out:
{% endif %}
{% if pending_crypto_payment and selected_crypto_option %}

{{ selected_crypto_option.label }} Payment Instructions

Send exactly: {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}
Destination wallet:
{{ pending_crypto_payment.wallet_address }}
Reference / Invoice:
{{ pending_crypto_payment.reference }} {% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %}
Open in MetaMask Mobile

Fastest way to pay

1. Click Open MetaMask / Rabby if your wallet is installed in this browser.

2. If that does not open your wallet, click Open in MetaMask Mobile.

3. If needed, use Copy Payment Details and send manually.

You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet.
{% elif pending_crypto_payment.txid %}
Transaction Hash:
{{ pending_crypto_payment.txid }}
Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.
{% elif pending_crypto_payment.lock_expired %}
price has expired - please refresh your quote to update
{% endif %}
{% else %}
{% for q in crypto_options %} {% endfor %}
AssetQuoted AmountCAD PriceStatusAction
{{ q.label }} {% if q.recommended %}recommended{% endif %} {% if q.wallet_capable %}wallet{% endif %} {{ q.display_amount or "—" }} {% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %} {% if q.available %}live{% else %}{{ q.reason or "unavailable" }}{% endif %}
{% endif %}
{% else %}

No crypto quote snapshot is available for this invoice yet.

{% endif %}
{% endif %} {% if invoice_payments %}

Payments Applied

{% for p in invoice_payments %} {% endfor %}
Method Amount Status Received Reference / TXID
{{ p.payment_method_label }} {{ p.payment_amount_display }} {{ p.payment_currency }} {{ p.payment_status }} {{ p.received_at_local }} {% if p.txid %} {{ p.txid }} {% elif p.reference %} {{ p.reference }} {% else %} - {% endif %} {% if p.wallet_address %}
{{ p.wallet_address }}{% endif %}
{% endif %} {% if pdf_url %} {% endif %} +{% include "includes/site_nav.html" %}

Invoice Detail

{{ client.company_name or client.contact_name or client.email }}

{% if (invoice.status or "")|lower == "paid" %}
✓ This invoice has been paid. Thank you!
{% endif %} {% if crypto_error %}
{{ crypto_error }}
{% endif %} {% if credit_applied %}
✓ Applied ${{ credit_applied }} CAD from your available credit.
{% endif %} {% if credit_error %}
Unable to apply account credit: {{ credit_error }}
{% endif %}

Invoice

{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}

Status

{% set s = (invoice.status or "")|lower %} {% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} processing {% elif s == "paid" %}{{ invoice.status }} {% elif s == "pending" %}{{ invoice.status }} {% elif s == "overdue" %}{{ invoice.status }} {% else %}{{ invoice.status }}{% endif %}

Created

{{ invoice.created_at }}

Total

{{ invoice.total_amount }}

Paid

{{ invoice.amount_paid }}

Outstanding

{{ invoice.outstanding }}

Invoice Items

{% for item in items %} {% else %} {% endfor %}
DescriptionQtyUnit PriceLine Total
{{ item.description }}{{ item.quantity }}{{ item.unit_price }}{{ item.line_total }}
No invoice line items found.

Invoice Summary

{% if invoice.tax_amount and invoice.tax_amount|float > 0 %}{% endif %}
Subtotal{{ invoice.subtotal_amount }}
HST 13%{{ invoice.tax_amount }}
Total Amount{{ invoice.total_amount }}
Paid{{ invoice.amount_paid }}
Outstanding{{ invoice.outstanding }}
{% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %}

Pay Now

Use available account credit

Available credit: ${{ client_credit_balance }} CAD

This will apply up to the invoice outstanding balance and leave a credit/payment audit trail.

Interac e-Transfer
Send payment to:
payment@outsidethebox.top
Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}

Credit Card (Square)

Pay with Credit Card
{% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %}

Crypto Quote Snapshot

Quoted At: {{ invoice.oracle_quote.quoted_at or "—" }}
Source Status: {{ invoice.oracle_quote.source_status or "—" }}
Frozen Amount: {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}
{% if pending_crypto_payment %}
Your quote is protected after acceptance.
{% else %}
Select a crypto asset to accept the quote.
{% endif %}
{% if pending_crypto_payment and pending_crypto_payment.txid %}
--:--
Watching transaction / waiting for confirmation
{% elif pending_crypto_payment %}
--:--
Quote protected while you open wallet
{% else %}
--:--
This price times out:
{% endif %}
{% if pending_crypto_payment and selected_crypto_option %}

{{ selected_crypto_option.label }} Payment Instructions

Send exactly: {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}
Destination wallet:
{{ pending_crypto_payment.wallet_address }}
Reference / Invoice:
{{ pending_crypto_payment.reference }} {% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %}
Open in MetaMask Mobile

Fastest way to pay

1. Click Open MetaMask / Rabby if your wallet is installed in this browser.

2. If that does not open your wallet, click Open in MetaMask Mobile.

3. If needed, use Copy Payment Details and send manually.

You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet.
{% elif pending_crypto_payment.txid %}
Transaction Hash:
{{ pending_crypto_payment.txid }}
Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.
{% elif pending_crypto_payment.lock_expired %}
price has expired - please refresh your quote to update
{% endif %}
{% else %}
{% for q in crypto_options %} {% endfor %}
AssetQuoted AmountCAD PriceStatusAction
{{ q.label }} {% if q.recommended %}recommended{% endif %} {% if q.wallet_capable %}wallet{% endif %} {{ q.display_amount or "—" }} {% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %} {% if q.available %}live{% else %}{{ q.reason or "unavailable" }}{% endif %}
{% endif %}
{% else %}

No crypto quote snapshot is available for this invoice yet.

{% endif %}
{% endif %} {% if invoice_payments %}

Payments Applied

{% for p in invoice_payments %} {% endfor %}
Method Amount Status Received Reference / TXID
{{ p.payment_method_label }} {{ p.payment_amount_display }} {{ p.payment_currency }} {{ p.payment_status }} {{ p.received_at_local }} {% if p.txid %} {{ p.txid }} {% elif p.reference %} {{ p.reference }} {% else %} - {% endif %} {% if p.wallet_address %}
{{ p.wallet_address }}{% endif %}
{% endif %} {% if pdf_url %} {% endif %}