From b05b3b4e8a4e7c1ed914fdedd10124c66c300ad8 Mon Sep 17 00:00:00 2001
From: def
Date: Fri, 29 May 2026 01:02:08 +0000
Subject: [PATCH] Bump to v2.0.5 invoice subtotal tax workflow
---
PROJECT_STATE.md | 39 +++++++
README.md | 41 ++++++-
VERSION | 2 +-
backend/app.py | 163 ++++++++++++++++++++++-----
templates/invoices/new.html | 107 ++++++++++++++++--
templates/invoices/print_batch.html | 8 +-
templates/invoices/view.html | 8 +-
templates/portal_invoice_detail.html | 2 +-
8 files changed, 323 insertions(+), 47 deletions(-)
diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index f6304a9..7286b2c 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -1,3 +1,42 @@
+## v2.0.5 final invoice workflow - 2026-05-29 UTC
+
+- Create Invoice now supports a real invoice line description, quantity, unit cost, and optional 13% HST checkbox.
+- Invoice subtotal is calculated as quantity × unit cost.
+- Tax is calculated server-side only when Apply 13% HST is checked.
+- Invoice totals now store subtotal_amount, tax_amount, and total_amount correctly.
+- Portal invoice detail now displays Subtotal, HST 13% when applicable, Total Amount, Paid, and Outstanding.
+- Invoice item descriptions support long invoice text through invoice_items.description as TEXT.
+- Existing invoices are preserved.
+
+## v2.0.5 portal invoice summary fix - 2026-05-29 UTC
+
+- Portal invoice detail now selects subtotal_amount and tax_amount from invoices.
+- Portal invoice summary now displays Subtotal, HST 13% when tax exists, Total Amount, Paid, and Outstanding correctly.
+- Reconciliation refresh path also preserves formatted subtotal and tax values.
+
+## v2.0.5 follow-up - 2026-05-29 UTC
+
+- Added Qty to Create Invoice.
+- Unit Cost now combines with Qty to calculate invoice line subtotal.
+- Portal invoice detail now shows invoice subtotal, HST 13% when present, total amount, paid, and outstanding under the invoice items table.
+
+## v2.0.5 - 2026-05-29 UTC
+
+- Updated Create Invoice flow to use Cost / Subtotal plus optional 13% HST checkbox.
+- Final Total Amount is now calculated from subtotal + tax server-side.
+- Invoice line description is entered as a proper multi-line description and stored in invoice_items.
+- Invoice view/print output now shows line amount as subtotal and hides the tax row when tax is zero.
+- Preserves existing invoices and uses existing subtotal_amount, tax_amount, and total_amount columns.
+
+## v2.0.2 - 2026-05-24 19:40 UTC
+
+- Added Square / Revenue Health panel to /health before crypto balance panels.
+- Revenue panel now uses confirmed rows from the payments table, not invoice guesses.
+- Shows Square confirmed payment count + CAD total for today, this month, and this year.
+- Shows all confirmed payment count + CAD total for today, this month, and this year.
+- Added Receivables Aging panel with current, 1-30, 31-60, 61-90, and 90+ day unpaid buckets.
+- Renamed health cards from Operations Bal / Treasury Bal to Operations Balance / Treasury Balance.
+
# PROJECT_STATE - OTB Billing
## v2.0.1 - 2026-05-18
diff --git a/README.md b/README.md
index 660c28a..68ce9fc 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,43 @@
-# OTB Billing - v2.0.1
+## v2.0.5 final invoice workflow - 2026-05-29 UTC
+
+- Create Invoice now supports a real invoice line description, quantity, unit cost, and optional 13% HST checkbox.
+- Invoice subtotal is calculated as quantity × unit cost.
+- Tax is calculated server-side only when Apply 13% HST is checked.
+- Invoice totals now store subtotal_amount, tax_amount, and total_amount correctly.
+- Portal invoice detail now displays Subtotal, HST 13% when applicable, Total Amount, Paid, and Outstanding.
+- Invoice item descriptions support long invoice text through invoice_items.description as TEXT.
+- Existing invoices are preserved.
+
+## v2.0.5 portal invoice summary fix - 2026-05-29 UTC
+
+- Portal invoice detail now selects subtotal_amount and tax_amount from invoices.
+- Portal invoice summary now displays Subtotal, HST 13% when tax exists, Total Amount, Paid, and Outstanding correctly.
+- Reconciliation refresh path also preserves formatted subtotal and tax values.
+
+## v2.0.5 follow-up - 2026-05-29 UTC
+
+- Added Qty to Create Invoice.
+- Unit Cost now combines with Qty to calculate invoice line subtotal.
+- Portal invoice detail now shows invoice subtotal, HST 13% when present, total amount, paid, and outstanding under the invoice items table.
+
+## v2.0.5 - 2026-05-29 UTC
+
+- Updated Create Invoice flow to use Cost / Subtotal plus optional 13% HST checkbox.
+- Final Total Amount is now calculated from subtotal + tax server-side.
+- Invoice line description is entered as a proper multi-line description and stored in invoice_items.
+- Invoice view/print output now shows line amount as subtotal and hides the tax row when tax is zero.
+- Preserves existing invoices and uses existing subtotal_amount, tax_amount, and total_amount columns.
+
+## v2.0.2 - 2026-05-24 19:40 UTC
+
+- Added Square / Revenue Health panel to /health before crypto balance panels.
+- Revenue panel now uses confirmed rows from the payments table, not invoice guesses.
+- Shows Square confirmed payment count + CAD total for today, this month, and this year.
+- Shows all confirmed payment count + CAD total for today, this month, and this year.
+- Added Receivables Aging panel with current, 1-30, 31-60, 61-90, and 90+ day unpaid buckets.
+- Renamed health cards from Operations Bal / Treasury Bal to Operations Balance / Treasury Balance.
+
+# OTB Billing - v2.0.2
Build date: 2026-05-18
diff --git a/VERSION b/VERSION
index 0ac852d..c2f6de9 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-v2.0.1
+v2.0.5
diff --git a/backend/app.py b/backend/app.py
index 88f6af3..fa40946 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -341,6 +341,30 @@ def get_invoice_crypto_options(invoice):
"blockExplorerUrls": ["https://explorer.ethoprotocol.com"]
},
},
+ "EGAZ": {
+ "symbol": "EGAZ",
+ "chain": "etica",
+ "label": "EGAZ (Etica Gas)",
+ "payment_currency": "EGAZ",
+ "wallet_address": CRYPTO_EVM_PAYMENT_ADDRESS,
+ "wallet_capable": True,
+ "asset_type": "native",
+ "chain_id": 61803,
+ "decimals": 18,
+ "token_contract": None,
+ "rpc_urls": [RPC_ETICA_URL, RPC_ETICA_URL_2],
+ "chain_add_params": {
+ "chainId": "0xf16b",
+ "chainName": "Etica",
+ "nativeCurrency": {
+ "name": "Etica Gas",
+ "symbol": "EGAZ",
+ "decimals": 18
+ },
+ "rpcUrls": [RPC_ETICA_URL, RPC_ETICA_URL_2],
+ "blockExplorerUrls": ["https://explorer.etica-stats.org"]
+ },
+ },
"ETI": {
"symbol": "ETI",
"chain": "etica",
@@ -540,6 +564,15 @@ def get_processing_crypto_option(payment_row):
"token_contract": None,
"display_amount": amount_text,
},
+ "EGAZ": {
+ "symbol": "EGAZ",
+ "chain": "etica",
+ "wallet_address": wallet_address,
+ "asset_type": "native",
+ "decimals": 18,
+ "token_contract": None,
+ "display_amount": amount_text,
+ },
"ETI": {
"symbol": "ETI",
"chain": "etica",
@@ -550,8 +583,10 @@ def get_processing_crypto_option(payment_row):
"display_amount": amount_text,
},
}
+
return mapping.get(currency)
+
def reconcile_pending_crypto_payment(payment_row):
tx_hash = str(payment_row.get("txid") or "").strip()
if not tx_hash or not tx_hash.startswith("0x"):
@@ -835,6 +870,15 @@ def get_processing_crypto_option(payment_row):
"token_contract": None,
"display_amount": amount_text,
},
+ "EGAZ": {
+ "symbol": "EGAZ",
+ "chain": "etica",
+ "wallet_address": wallet_address,
+ "asset_type": "native",
+ "decimals": 18,
+ "token_contract": None,
+ "display_amount": amount_text,
+ },
"ETI": {
"symbol": "ETI",
"chain": "etica",
@@ -848,6 +892,7 @@ def get_processing_crypto_option(payment_row):
return mapping.get(currency)
+
def append_payment_note(existing_notes, extra_line):
base = (existing_notes or "").rstrip()
if not base:
@@ -945,6 +990,7 @@ def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url):
{
"ETH": "ethereum",
"ETHO": "etho",
+ "EGAZ": "etica",
"ETI": "etica",
"USDC": "arbitrum",
}.get(str(row.get("payment_currency") or "").upper()),
@@ -968,6 +1014,7 @@ def mark_crypto_payment_confirmed(payment_id, invoice_id, rpc_url):
({
"ETH": "ethereum",
"ETHO": "etho",
+ "EGAZ": "etica",
"ETI": "etica",
"USDC": "arbitrum",
}.get(str(row.get("payment_currency") or "").upper())),
@@ -3736,6 +3783,34 @@ def invoices():
return render_template("invoices/list.html", invoices=invoices, filters=filters, clients=clients)
+
+def generate_next_invoice_number(cursor, prefix="INV"):
+ """
+ Generate the next unique invoice number like INV-0012.
+ Uses the invoices table instead of a hardcoded default.
+ """
+ cursor.execute("""
+ SELECT COALESCE(
+ MAX(CAST(SUBSTRING(invoice_number, 5) AS UNSIGNED)),
+ 0
+ ) AS max_num
+ FROM invoices
+ WHERE invoice_number REGEXP '^INV-[0-9]+$'
+ """)
+ row = cursor.fetchone() or {}
+ try:
+ next_num = int(row.get("max_num") or 0) + 1
+ except Exception:
+ next_num = 1
+
+ while True:
+ candidate = f"{prefix}-{next_num:04d}"
+ cursor.execute("SELECT id FROM invoices WHERE invoice_number = %s LIMIT 1", (candidate,))
+ if not cursor.fetchone():
+ return candidate
+ next_num += 1
+
+
@app.route("/invoices/new", methods=["GET", "POST"])
def new_invoice():
gate = admin_required()
@@ -3746,12 +3821,16 @@ def new_invoice():
cursor = conn.cursor(dictionary=True)
if request.method == "POST":
+ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
+
client_id = request.form.get("client_id", "").strip()
service_id = request.form.get("service_id", "").strip()
currency_code = request.form.get("currency_code", "").strip()
- total_amount = request.form.get("total_amount", "").strip()
+ quantity_raw = request.form.get("quantity", "1").strip()
+ unit_amount_raw = request.form.get("unit_amount", "").strip()
due_at = request.form.get("due_at", "").strip()
- notes = request.form.get("notes", "").strip()
+ line_description = request.form.get("description", "").strip()
+ apply_tax = request.form.get("apply_tax") == "1"
errors = []
@@ -3761,18 +3840,41 @@ def new_invoice():
errors.append("Service is required.")
if not currency_code:
errors.append("Currency is required.")
- if not total_amount:
- errors.append("Total amount is required.")
+ if not quantity_raw:
+ errors.append("Quantity is required.")
+ if not unit_amount_raw:
+ errors.append("Unit cost is required.")
if not due_at:
errors.append("Due date is required.")
+ if not line_description:
+ errors.append("Description is required.")
+
+ quantity = None
+ unit_amount = None
+ subtotal_amount = None
+ tax_amount = Decimal("0.00000000")
+ total_amount = None
if not errors:
try:
- amount_value = float(total_amount)
- if amount_value <= 0:
- errors.append("Total amount must be greater than zero.")
- except ValueError:
- errors.append("Total amount must be a valid number.")
+ quantity = Decimal(quantity_raw).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
+ if quantity <= 0:
+ errors.append("Quantity must be greater than zero.")
+ except (InvalidOperation, ValueError):
+ errors.append("Quantity must be a valid number.")
+
+ try:
+ unit_amount = Decimal(unit_amount_raw).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
+ if unit_amount <= 0:
+ errors.append("Unit cost must be greater than zero.")
+ except (InvalidOperation, ValueError):
+ errors.append("Unit cost must be a valid number.")
+
+ if not errors:
+ subtotal_amount = (quantity * unit_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
+ if apply_tax:
+ tax_amount = (subtotal_amount * Decimal("0.13")).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
+ total_amount = (subtotal_amount + tax_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
if errors:
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name")
@@ -3787,9 +3889,11 @@ def new_invoice():
"client_id": client_id,
"service_id": service_id,
"currency_code": currency_code,
- "total_amount": total_amount,
+ "quantity": quantity_raw,
+ "unit_amount": unit_amount_raw,
"due_at": due_at,
- "notes": notes,
+ "description": line_description,
+ "apply_tax": "1" if apply_tax else "",
}
return render_template(
@@ -3800,23 +3904,15 @@ def new_invoice():
form_data=form_data,
)
- invoice_number = generate_invoice_number()
-
- cursor.execute("SELECT service_name FROM services WHERE id = %s", (service_id,))
- service_row = cursor.fetchone()
- service_name = (service_row or {}).get("service_name") or "Service"
-
- line_description = service_name
- if notes:
- line_description = f"{service_name} - {notes}"
-
- oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, total_amount)
+ oracle_snapshot = fetch_oracle_quote_snapshot(currency_code, str(total_amount))
oracle_snapshot_json = json.dumps(oracle_snapshot, ensure_ascii=False) if oracle_snapshot else None
quote_expires_at = normalize_oracle_datetime((oracle_snapshot or {}).get("expires_at"))
quote_fiat_amount = total_amount if oracle_snapshot else None
quote_fiat_currency = currency_code if oracle_snapshot else None
insert_cursor = conn.cursor()
+ invoice_number = generate_next_invoice_number(insert_cursor)
+
insert_cursor.execute("""
INSERT INTO invoices
(
@@ -3826,6 +3922,7 @@ def new_invoice():
currency_code,
total_amount,
subtotal_amount,
+ tax_amount,
issued_at,
due_at,
status,
@@ -3835,16 +3932,17 @@ def new_invoice():
quote_expires_at,
oracle_snapshot
)
- VALUES (%s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, UTC_TIMESTAMP(), %s, 'pending', %s, %s, %s, %s, %s)
""", (
client_id,
service_id,
invoice_number,
currency_code,
total_amount,
- total_amount,
+ subtotal_amount,
+ tax_amount,
due_at,
- notes,
+ line_description,
quote_fiat_amount,
quote_fiat_currency,
quote_expires_at,
@@ -3866,12 +3964,13 @@ def new_invoice():
currency_code,
service_id
)
- VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s)
+ VALUES (%s, 1, 'service', %s, %s, %s, %s, %s, %s)
""", (
invoice_id,
line_description,
- total_amount,
- total_amount,
+ quantity,
+ unit_amount,
+ subtotal_amount,
currency_code,
service_id
))
@@ -3900,7 +3999,6 @@ def new_invoice():
-
@app.route("/invoices/pdf/")
def invoice_pdf(invoice_id):
conn = get_db_connection()
@@ -5568,7 +5666,8 @@ def portal_invoice_detail(invoice_id):
cursor = conn.cursor(dictionary=True)
cursor.execute("""
- SELECT id, client_id, invoice_number, status, created_at, total_amount, amount_paid,
+ SELECT id, client_id, invoice_number, status, created_at,
+ subtotal_amount, tax_amount, total_amount, amount_paid,
quote_fiat_amount, quote_fiat_currency, quote_expires_at, oracle_snapshot
FROM invoices
WHERE id = %s AND client_id = %s
@@ -5594,6 +5693,8 @@ def portal_invoice_detail(invoice_id):
outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0")
invoice["outstanding"] = _fmt_money(outstanding)
+ invoice["subtotal_amount"] = _fmt_money(invoice.get("subtotal_amount"))
+ invoice["tax_amount"] = _fmt_money(invoice.get("tax_amount"))
invoice["total_amount"] = _fmt_money(invoice.get("total_amount"))
invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid"))
invoice["created_at"] = fmt_local(invoice.get("created_at"))
@@ -5803,6 +5904,8 @@ def portal_invoice_detail(invoice_id):
outstanding_raw = to_decimal(invoice.get("total_amount")) - to_decimal(invoice.get("amount_paid"))
outstanding = outstanding_raw if outstanding_raw > to_decimal("0") else to_decimal("0")
invoice["outstanding"] = _fmt_money(outstanding)
+ invoice["subtotal_amount"] = _fmt_money(invoice.get("subtotal_amount"))
+ invoice["tax_amount"] = _fmt_money(invoice.get("tax_amount"))
invoice["total_amount"] = _fmt_money(invoice.get("total_amount"))
invoice["amount_paid"] = _fmt_money(invoice.get("amount_paid"))
elif reconcile_result.get("state") in {"timeout", "failed_receipt"}:
diff --git a/templates/invoices/new.html b/templates/invoices/new.html
index c2d46d4..173655c 100644
--- a/templates/invoices/new.html
+++ b/templates/invoices/new.html
@@ -13,7 +13,6 @@
This invoice number will be generated automatically when the invoice is created.
-
{% if errors %}