|
|
|
|
@ -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"}: |
|
|
|
|
|