|
|
|
|
@ -4487,6 +4487,9 @@ def edit_invoice(invoice_id):
|
|
|
|
|
gate = admin_required() |
|
|
|
|
if gate: |
|
|
|
|
return gate |
|
|
|
|
|
|
|
|
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP |
|
|
|
|
|
|
|
|
|
conn = get_db_connection() |
|
|
|
|
cursor = conn.cursor(dictionary=True) |
|
|
|
|
|
|
|
|
|
@ -4504,6 +4507,51 @@ def edit_invoice(invoice_id):
|
|
|
|
|
|
|
|
|
|
locked = invoice["payment_count"] > 0 or float(invoice["amount_paid"]) > 0 |
|
|
|
|
|
|
|
|
|
def _load_clients_services(): |
|
|
|
|
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") |
|
|
|
|
clients = cursor.fetchall() |
|
|
|
|
|
|
|
|
|
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") |
|
|
|
|
services = cursor.fetchall() |
|
|
|
|
|
|
|
|
|
return clients, services |
|
|
|
|
|
|
|
|
|
def _load_invoice_items(): |
|
|
|
|
cursor.execute(""" |
|
|
|
|
SELECT line_number, description, quantity, unit_amount, line_total, currency_code, service_id |
|
|
|
|
FROM invoice_items |
|
|
|
|
WHERE invoice_id = %s |
|
|
|
|
ORDER BY line_number ASC, id ASC |
|
|
|
|
""", (invoice_id,)) |
|
|
|
|
rows = cursor.fetchall() |
|
|
|
|
|
|
|
|
|
if not rows: |
|
|
|
|
rows = [{ |
|
|
|
|
"line_number": 1, |
|
|
|
|
"description": invoice.get("notes") or "", |
|
|
|
|
"quantity": Decimal("1.0000"), |
|
|
|
|
"unit_amount": invoice.get("subtotal_amount") or invoice.get("total_amount") or Decimal("0"), |
|
|
|
|
"line_total": invoice.get("subtotal_amount") or invoice.get("total_amount") or Decimal("0"), |
|
|
|
|
"currency_code": invoice.get("currency_code", "CAD"), |
|
|
|
|
"service_id": invoice.get("service_id"), |
|
|
|
|
}] |
|
|
|
|
|
|
|
|
|
return rows |
|
|
|
|
|
|
|
|
|
def _render(errors, line_items=None): |
|
|
|
|
clients, services = _load_clients_services() |
|
|
|
|
if line_items is None: |
|
|
|
|
line_items = _load_invoice_items() |
|
|
|
|
return render_template( |
|
|
|
|
"invoices/edit.html", |
|
|
|
|
invoice=invoice, |
|
|
|
|
clients=clients, |
|
|
|
|
services=services, |
|
|
|
|
errors=errors, |
|
|
|
|
locked=locked, |
|
|
|
|
invoice_items=line_items, |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
if request.method == "POST": |
|
|
|
|
due_at = request.form.get("due_at", "").strip() |
|
|
|
|
notes = request.form.get("notes", "").strip() |
|
|
|
|
@ -4522,15 +4570,29 @@ def edit_invoice(invoice_id):
|
|
|
|
|
)) |
|
|
|
|
conn.commit() |
|
|
|
|
conn.close() |
|
|
|
|
return redirect("/invoices") |
|
|
|
|
return redirect(f"/invoices/view/{invoice_id}") |
|
|
|
|
|
|
|
|
|
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() |
|
|
|
|
currency_code = request.form.get("currency_code", "").strip() or "CAD" |
|
|
|
|
status = request.form.get("status", "").strip() |
|
|
|
|
tax_amount_raw = request.form.get("tax_amount", "0").strip() or "0" |
|
|
|
|
|
|
|
|
|
descriptions = request.form.getlist("item_description[]") |
|
|
|
|
quantities = request.form.getlist("item_quantity[]") |
|
|
|
|
unit_amounts = request.form.getlist("item_unit_amount[]") |
|
|
|
|
|
|
|
|
|
max_len = max(len(descriptions), len(quantities), len(unit_amounts), 1) |
|
|
|
|
while len(descriptions) < max_len: |
|
|
|
|
descriptions.append("") |
|
|
|
|
while len(quantities) < max_len: |
|
|
|
|
quantities.append("") |
|
|
|
|
while len(unit_amounts) < max_len: |
|
|
|
|
unit_amounts.append("") |
|
|
|
|
|
|
|
|
|
errors = [] |
|
|
|
|
line_items = [] |
|
|
|
|
subtotal_amount = Decimal("0.00000000") |
|
|
|
|
|
|
|
|
|
if not client_id: |
|
|
|
|
errors.append("Client is required.") |
|
|
|
|
@ -4538,8 +4600,6 @@ def edit_invoice(invoice_id):
|
|
|
|
|
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 due_at: |
|
|
|
|
errors.append("Due date is required.") |
|
|
|
|
if not status: |
|
|
|
|
@ -4549,38 +4609,86 @@ def edit_invoice(invoice_id):
|
|
|
|
|
if status and status not in manual_statuses: |
|
|
|
|
errors.append("Manual invoice status must be draft, pending, or cancelled.") |
|
|
|
|
|
|
|
|
|
if not errors: |
|
|
|
|
try: |
|
|
|
|
amount_value = float(total_amount) |
|
|
|
|
if amount_value < 0: |
|
|
|
|
errors.append("Total amount cannot be negative.") |
|
|
|
|
except ValueError: |
|
|
|
|
errors.append("Total amount must be a valid number.") |
|
|
|
|
for idx in range(max_len): |
|
|
|
|
desc = (descriptions[idx] or "").strip() |
|
|
|
|
qty_raw = (quantities[idx] or "").strip() |
|
|
|
|
unit_raw = (unit_amounts[idx] or "").strip() |
|
|
|
|
|
|
|
|
|
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") |
|
|
|
|
clients = cursor.fetchall() |
|
|
|
|
if not desc and not qty_raw and not unit_raw: |
|
|
|
|
continue |
|
|
|
|
|
|
|
|
|
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") |
|
|
|
|
services = cursor.fetchall() |
|
|
|
|
line_no = len(line_items) + 1 |
|
|
|
|
|
|
|
|
|
if errors: |
|
|
|
|
invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] |
|
|
|
|
invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] |
|
|
|
|
invoice["currency_code"] = currency_code or invoice["currency_code"] |
|
|
|
|
invoice["total_amount"] = total_amount or invoice["total_amount"] |
|
|
|
|
invoice["due_at"] = due_at or invoice["due_at"] |
|
|
|
|
invoice["status"] = status or invoice["status"] |
|
|
|
|
invoice["notes"] = notes |
|
|
|
|
conn.close() |
|
|
|
|
return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=errors, locked=locked) |
|
|
|
|
if not desc: |
|
|
|
|
errors.append(f"Line {line_no}: description is required.") |
|
|
|
|
if not qty_raw: |
|
|
|
|
errors.append(f"Line {line_no}: quantity is required.") |
|
|
|
|
if not unit_raw: |
|
|
|
|
errors.append(f"Line {line_no}: unit cost is required.") |
|
|
|
|
|
|
|
|
|
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" |
|
|
|
|
quantity = None |
|
|
|
|
unit_amount = None |
|
|
|
|
line_total = Decimal("0.00000000") |
|
|
|
|
|
|
|
|
|
if qty_raw: |
|
|
|
|
try: |
|
|
|
|
quantity = Decimal(qty_raw).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) |
|
|
|
|
if quantity <= 0: |
|
|
|
|
errors.append(f"Line {line_no}: quantity must be greater than zero.") |
|
|
|
|
except (InvalidOperation, ValueError): |
|
|
|
|
errors.append(f"Line {line_no}: quantity must be a valid number.") |
|
|
|
|
|
|
|
|
|
line_description = service_name |
|
|
|
|
if notes: |
|
|
|
|
line_description = f"{service_name} - {notes}" |
|
|
|
|
if unit_raw: |
|
|
|
|
try: |
|
|
|
|
unit_amount = Decimal(unit_raw).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) |
|
|
|
|
if unit_amount < 0: |
|
|
|
|
errors.append(f"Line {line_no}: unit cost cannot be negative.") |
|
|
|
|
except (InvalidOperation, ValueError): |
|
|
|
|
errors.append(f"Line {line_no}: unit cost must be a valid number.") |
|
|
|
|
|
|
|
|
|
if quantity is not None and unit_amount is not None: |
|
|
|
|
line_total = (quantity * unit_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) |
|
|
|
|
|
|
|
|
|
line_items.append({ |
|
|
|
|
"line_number": line_no, |
|
|
|
|
"description": desc, |
|
|
|
|
"quantity": str(quantity) if quantity is not None else qty_raw, |
|
|
|
|
"unit_amount": str(unit_amount) if unit_amount is not None else unit_raw, |
|
|
|
|
"line_total": str(line_total), |
|
|
|
|
"currency_code": currency_code, |
|
|
|
|
"service_id": service_id, |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
subtotal_amount += line_total |
|
|
|
|
|
|
|
|
|
if not line_items: |
|
|
|
|
errors.append("At least one invoice line is required.") |
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
tax_amount = Decimal(tax_amount_raw).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) |
|
|
|
|
if tax_amount < 0: |
|
|
|
|
errors.append("Tax amount cannot be negative.") |
|
|
|
|
except (InvalidOperation, ValueError): |
|
|
|
|
tax_amount = Decimal("0.00000000") |
|
|
|
|
errors.append("Tax amount must be a valid number.") |
|
|
|
|
|
|
|
|
|
subtotal_amount = subtotal_amount.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) |
|
|
|
|
total_amount = (subtotal_amount + tax_amount).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) |
|
|
|
|
|
|
|
|
|
invoice["client_id"] = int(client_id) if client_id else invoice["client_id"] |
|
|
|
|
invoice["service_id"] = int(service_id) if service_id else invoice["service_id"] |
|
|
|
|
invoice["currency_code"] = currency_code or invoice["currency_code"] |
|
|
|
|
invoice["subtotal_amount"] = subtotal_amount |
|
|
|
|
invoice["tax_amount"] = tax_amount |
|
|
|
|
invoice["total_amount"] = total_amount |
|
|
|
|
invoice["due_at"] = due_at or invoice["due_at"] |
|
|
|
|
invoice["status"] = status or invoice["status"] |
|
|
|
|
invoice["notes"] = notes |
|
|
|
|
|
|
|
|
|
if errors: |
|
|
|
|
rendered = _render(errors, line_items=line_items) |
|
|
|
|
conn.close() |
|
|
|
|
return rendered |
|
|
|
|
|
|
|
|
|
update_cursor = conn.cursor() |
|
|
|
|
update_cursor.execute(""" |
|
|
|
|
@ -4588,8 +4696,9 @@ def edit_invoice(invoice_id):
|
|
|
|
|
SET client_id = %s, |
|
|
|
|
service_id = %s, |
|
|
|
|
currency_code = %s, |
|
|
|
|
total_amount = %s, |
|
|
|
|
subtotal_amount = %s, |
|
|
|
|
tax_amount = %s, |
|
|
|
|
total_amount = %s, |
|
|
|
|
due_at = %s, |
|
|
|
|
status = %s, |
|
|
|
|
notes = %s |
|
|
|
|
@ -4598,8 +4707,9 @@ def edit_invoice(invoice_id):
|
|
|
|
|
client_id, |
|
|
|
|
service_id, |
|
|
|
|
currency_code, |
|
|
|
|
total_amount, |
|
|
|
|
total_amount, |
|
|
|
|
str(subtotal_amount), |
|
|
|
|
str(tax_amount), |
|
|
|
|
str(total_amount), |
|
|
|
|
due_at, |
|
|
|
|
status, |
|
|
|
|
notes or None, |
|
|
|
|
@ -4607,46 +4717,40 @@ def edit_invoice(invoice_id):
|
|
|
|
|
)) |
|
|
|
|
|
|
|
|
|
update_cursor.execute("DELETE FROM invoice_items WHERE invoice_id = %s", (invoice_id,)) |
|
|
|
|
update_cursor.execute(""" |
|
|
|
|
INSERT INTO invoice_items |
|
|
|
|
( |
|
|
|
|
|
|
|
|
|
for item in line_items: |
|
|
|
|
update_cursor.execute(""" |
|
|
|
|
INSERT INTO invoice_items |
|
|
|
|
( |
|
|
|
|
invoice_id, |
|
|
|
|
line_number, |
|
|
|
|
item_type, |
|
|
|
|
description, |
|
|
|
|
quantity, |
|
|
|
|
unit_amount, |
|
|
|
|
line_total, |
|
|
|
|
currency_code, |
|
|
|
|
service_id |
|
|
|
|
) |
|
|
|
|
VALUES (%s, %s, 'service', %s, %s, %s, %s, %s, %s) |
|
|
|
|
""", ( |
|
|
|
|
invoice_id, |
|
|
|
|
line_number, |
|
|
|
|
item_type, |
|
|
|
|
description, |
|
|
|
|
quantity, |
|
|
|
|
unit_amount, |
|
|
|
|
line_total, |
|
|
|
|
item["line_number"], |
|
|
|
|
item["description"], |
|
|
|
|
item["quantity"], |
|
|
|
|
item["unit_amount"], |
|
|
|
|
item["line_total"], |
|
|
|
|
currency_code, |
|
|
|
|
service_id |
|
|
|
|
) |
|
|
|
|
VALUES (%s, 1, 'service', %s, 1.0000, %s, %s, %s, %s) |
|
|
|
|
""", ( |
|
|
|
|
invoice_id, |
|
|
|
|
line_description, |
|
|
|
|
total_amount, |
|
|
|
|
total_amount, |
|
|
|
|
currency_code, |
|
|
|
|
service_id |
|
|
|
|
)) |
|
|
|
|
service_id, |
|
|
|
|
)) |
|
|
|
|
|
|
|
|
|
conn.commit() |
|
|
|
|
conn.close() |
|
|
|
|
return redirect("/invoices") |
|
|
|
|
|
|
|
|
|
clients = [] |
|
|
|
|
services = [] |
|
|
|
|
|
|
|
|
|
if not locked: |
|
|
|
|
cursor.execute("SELECT id, client_code, company_name FROM clients ORDER BY company_name") |
|
|
|
|
clients = cursor.fetchall() |
|
|
|
|
|
|
|
|
|
cursor.execute("SELECT id, service_code, service_name FROM services ORDER BY service_name") |
|
|
|
|
services = cursor.fetchall() |
|
|
|
|
return redirect(f"/invoices/view/{invoice_id}") |
|
|
|
|
|
|
|
|
|
rendered = _render([]) |
|
|
|
|
conn.close() |
|
|
|
|
return render_template("invoices/edit.html", invoice=invoice, clients=clients, services=services, errors=[], locked=locked) |
|
|
|
|
|
|
|
|
|
return rendered |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route("/payments/export.csv") |
|
|
|
|
|