Browse Source

Bump to v2.0.5 invoice subtotal tax workflow

main
def 3 weeks ago
parent
commit
b05b3b4e8a
  1. 39
      PROJECT_STATE.md
  2. 41
      README.md
  3. 2
      VERSION
  4. 163
      backend/app.py
  5. 107
      templates/invoices/new.html
  6. 8
      templates/invoices/print_batch.html
  7. 8
      templates/invoices/view.html
  8. 2
      templates/portal_invoice_detail.html

39
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

41
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

2
VERSION

@ -1 +1 @@
v2.0.1
v2.0.5

163
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/<int:invoice_id>")
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"}:

107
templates/invoices/new.html

@ -13,7 +13,6 @@
This invoice number will be generated automatically when the invoice is created.
</p>
{% if errors %}
<div style="border:1px solid red; padding:10px; margin-bottom:15px;">
<strong>Please fix the following:</strong>
@ -53,7 +52,7 @@ Service *<br>
<p>
Currency *<br>
<select name="currency_code" required>
<select name="currency_code" id="currency_code" required>
<option value="CAD" {% if form_data.get('currency_code', 'CAD') == 'CAD' %}selected{% endif %}>CAD</option>
<option value="ETHO" {% if form_data.get('currency_code') == 'ETHO' %}selected{% endif %}>ETHO</option>
<option value="EGAZ" {% if form_data.get('currency_code') == 'EGAZ' %}selected{% endif %}>EGAZ</option>
@ -62,18 +61,70 @@ Currency *<br>
</p>
<p>
Total Amount *<br>
<input type="number" step="0.00000001" min="0.00000001" name="total_amount" value="{{ form_data.get('total_amount', '') }}" required>
Description *<br>
<textarea name="description" rows="5" cols="80" required>{{ form_data.get('description', '') }}</textarea>
</p>
<p>
Due Date *<br>
<input type="date" name="due_at" value="{{ form_data.get('due_at', '') }}" required>
Qty *<br>
<input
type="number"
step="0.0001"
min="0.0001"
name="quantity"
id="quantity"
value="{{ form_data.get('quantity', '1') }}"
required
>
</p>
<p>
Unit Cost *<br>
<input
type="number"
step="0.00000001"
min="0.00000001"
name="unit_amount"
id="unit_amount"
value="{{ form_data.get('unit_amount', '') }}"
required
>
</p>
<p>
<label>
<input
type="checkbox"
name="apply_tax"
id="apply_tax"
value="1"
{% if form_data.get('apply_tax') == '1' %}checked{% endif %}
>
Apply 13% HST
</label>
</p>
<div style="border:1px solid #ccc; padding:10px; max-width:420px; margin:15px 0;">
<strong>Invoice Summary</strong><br><br>
<table>
<tr>
<th style="text-align:left; padding-right:20px;">Subtotal</th>
<td id="summary_subtotal">0.00 CAD</td>
</tr>
<tr id="summary_tax_row" style="display:none;">
<th style="text-align:left; padding-right:20px;">HST 13%</th>
<td id="summary_tax">0.00 CAD</td>
</tr>
<tr>
<th style="text-align:left; padding-right:20px;">Total Amount</th>
<td><strong id="summary_total">0.00 CAD</strong></td>
</tr>
</table>
</div>
<p>
Notes<br>
<textarea name="notes">{{ form_data.get('notes', '') }}</textarea>
Due Date *<br>
<input type="date" name="due_at" value="{{ form_data.get('due_at', '') }}" required>
</p>
<p>
@ -82,6 +133,46 @@ Notes<br>
</form>
<script>
(function () {
const qtyInput = document.getElementById("quantity");
const unitInput = document.getElementById("unit_amount");
const taxBox = document.getElementById("apply_tax");
const currencySelect = document.getElementById("currency_code");
const subtotalEl = document.getElementById("summary_subtotal");
const taxRow = document.getElementById("summary_tax_row");
const taxEl = document.getElementById("summary_tax");
const totalEl = document.getElementById("summary_total");
function money(value) {
const currency = currencySelect.value || "CAD";
const decimals = currency === "CAD" ? 2 : 8;
const n = Number(value || 0);
return n.toFixed(decimals) + " " + currency;
}
function updateSummary() {
const qty = Number(qtyInput.value || 0);
const unit = Number(unitInput.value || 0);
const subtotal = qty * unit;
const applyTax = taxBox.checked;
const tax = applyTax ? subtotal * 0.13 : 0;
const total = subtotal + tax;
subtotalEl.textContent = money(subtotal);
taxEl.textContent = money(tax);
totalEl.textContent = money(total);
taxRow.style.display = applyTax ? "table-row" : "none";
}
qtyInput.addEventListener("input", updateSummary);
unitInput.addEventListener("input", updateSummary);
taxBox.addEventListener("change", updateSummary);
currencySelect.addEventListener("change", updateSummary);
updateSummary();
})();
</script>
</body>
</html>
{% include "footer.html" %}

8
templates/invoices/print_batch.html

@ -169,13 +169,13 @@ body {
<th>Service Code</th>
<th>Service</th>
<th>Description</th>
<th>Total</th>
<th>Amount</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>
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
</table>
@ -184,10 +184,12 @@ body {
<th>Subtotal</th>
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
{% if invoice.tax_amount and invoice.tax_amount|float > 0 %}
<tr>
<th>{{ settings.tax_label or 'Tax' }}</th>
<th>{{ settings.tax_label or 'HST' }} 13%</th>
<td>{{ invoice.tax_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
{% endif %}
<tr>
<th>Total</th>
<td><strong>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</strong></td>

8
templates/invoices/view.html

@ -170,13 +170,13 @@ body {
<th>Service Code</th>
<th>Service</th>
<th>Description</th>
<th>Total</th>
<th>Amount</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>
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
</table>
@ -185,10 +185,12 @@ body {
<th>Subtotal</th>
<td>{{ invoice.subtotal_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
{% if invoice.tax_amount and invoice.tax_amount|float > 0 %}
<tr>
<th>{{ settings.tax_label or 'Tax' }}</th>
<th>{{ settings.tax_label or 'HST' }} 13%</th>
<td>{{ invoice.tax_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</td>
</tr>
{% endif %}
<tr>
<th>Total</th>
<td><strong>{{ invoice.total_amount|money(invoice.currency_code) }} {{ invoice.currency_code }}</strong></td>

2
templates/portal_invoice_detail.html

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