New 'Earnings' interface for viewing/tracking earnings across reporting periods (e.g for BAS)
This commit is contained in:
parent
3361537a59
commit
ebdf29cdbd
10 changed files with 1561 additions and 4 deletions
|
|
@ -1,3 +1,7 @@
|
||||||
|
# 0.10.0
|
||||||
|
|
||||||
|
* New 'Earnings' interface for viewing/tracking earnings across reporting periods (e.g for BAS)
|
||||||
|
|
||||||
# 0.9.0
|
# 0.9.0
|
||||||
|
|
||||||
* Add 'Projects' interface for unified time/invoice/docs view.
|
* Add 'Projects' interface for unified time/invoice/docs view.
|
||||||
|
|
|
||||||
302
bouquin/db.py
302
bouquin/db.py
|
|
@ -100,6 +100,7 @@ class DBConfig:
|
||||||
reminders_webhook_secret: str = (None,)
|
reminders_webhook_secret: str = (None,)
|
||||||
documents: bool = True
|
documents: bool = True
|
||||||
invoicing: bool = False
|
invoicing: bool = False
|
||||||
|
reporting_currency: str = "AUD"
|
||||||
locale: str = "en"
|
locale: str = "en"
|
||||||
font_size: int = 11
|
font_size: int = 11
|
||||||
|
|
||||||
|
|
@ -120,6 +121,9 @@ class DBManager:
|
||||||
"detail_mode",
|
"detail_mode",
|
||||||
"paid_at",
|
"paid_at",
|
||||||
"payment_note",
|
"payment_note",
|
||||||
|
"reporting_currency",
|
||||||
|
"reporting_total_cents",
|
||||||
|
"reporting_note",
|
||||||
"document_id",
|
"document_id",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -369,6 +373,9 @@ class DBManager:
|
||||||
detail_mode TEXT NOT NULL, -- 'detailed' | 'summary'
|
detail_mode TEXT NOT NULL, -- 'detailed' | 'summary'
|
||||||
paid_at TEXT,
|
paid_at TEXT,
|
||||||
payment_note TEXT,
|
payment_note TEXT,
|
||||||
|
reporting_currency TEXT,
|
||||||
|
reporting_total_cents INTEGER,
|
||||||
|
reporting_note TEXT,
|
||||||
document_id INTEGER,
|
document_id INTEGER,
|
||||||
created_at TEXT NOT NULL DEFAULT (
|
created_at TEXT NOT NULL DEFAULT (
|
||||||
strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
|
@ -401,6 +408,25 @@ class DBManager:
|
||||||
REFERENCES time_log(id) ON DELETE RESTRICT,
|
REFERENCES time_log(id) ON DELETE RESTRICT,
|
||||||
PRIMARY KEY (invoice_id, time_log_id)
|
PRIMARY KEY (invoice_id, time_log_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_payments (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
invoice_id INTEGER NOT NULL
|
||||||
|
REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
received_at TEXT NOT NULL, -- yyyy-MM-dd
|
||||||
|
invoice_amount_cents INTEGER NOT NULL,
|
||||||
|
reporting_currency TEXT NOT NULL,
|
||||||
|
reporting_amount_cents INTEGER NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_invoice_payments_invoice
|
||||||
|
ON invoice_payments(invoice_id, received_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_invoice_payments_received
|
||||||
|
ON invoice_payments(received_at);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
self._ensure_column(
|
self._ensure_column(
|
||||||
|
|
@ -408,6 +434,21 @@ class DBManager:
|
||||||
"created_at",
|
"created_at",
|
||||||
"created_at TEXT",
|
"created_at TEXT",
|
||||||
)
|
)
|
||||||
|
self._ensure_column(
|
||||||
|
"invoices",
|
||||||
|
"reporting_currency",
|
||||||
|
"reporting_currency TEXT",
|
||||||
|
)
|
||||||
|
self._ensure_column(
|
||||||
|
"invoices",
|
||||||
|
"reporting_total_cents",
|
||||||
|
"reporting_total_cents INTEGER",
|
||||||
|
)
|
||||||
|
self._ensure_column(
|
||||||
|
"invoices",
|
||||||
|
"reporting_note",
|
||||||
|
"reporting_note TEXT",
|
||||||
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def _ensure_column(self, table: str, column: str, definition: str) -> None:
|
def _ensure_column(self, table: str, column: str, definition: str) -> None:
|
||||||
|
|
@ -2924,6 +2965,252 @@ class DBManager:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
def get_invoice_with_project(self, invoice_id: int):
|
||||||
|
return self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
i.*,
|
||||||
|
p.name AS project_name
|
||||||
|
FROM invoices AS i
|
||||||
|
LEFT JOIN projects AS p ON p.id = i.project_id
|
||||||
|
WHERE i.id = ?
|
||||||
|
""",
|
||||||
|
(invoice_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
def set_invoice_reporting_value(
|
||||||
|
self,
|
||||||
|
invoice_id: int,
|
||||||
|
reporting_currency: str,
|
||||||
|
reporting_total_cents: int,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Store the invoice-date value used by invoice-basis earnings reports."""
|
||||||
|
invoice = self.get_invoice_with_project(invoice_id)
|
||||||
|
if invoice is None:
|
||||||
|
raise ValueError("Invoice does not exist.")
|
||||||
|
reporting_currency = reporting_currency.strip().upper()
|
||||||
|
if not reporting_currency:
|
||||||
|
raise ValueError("A reporting currency is required.")
|
||||||
|
if reporting_total_cents <= 0:
|
||||||
|
raise ValueError("The reporting total must be greater than zero.")
|
||||||
|
with self.conn:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE invoices
|
||||||
|
SET reporting_currency = ?,
|
||||||
|
reporting_total_cents = ?,
|
||||||
|
reporting_note = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
reporting_currency,
|
||||||
|
int(reporting_total_cents),
|
||||||
|
note.strip() if note and note.strip() else None,
|
||||||
|
invoice_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clear_invoice_reporting_value(self, invoice_id: int) -> None:
|
||||||
|
with self.conn:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE invoices
|
||||||
|
SET reporting_currency = NULL,
|
||||||
|
reporting_total_cents = NULL,
|
||||||
|
reporting_note = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(invoice_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_invoices_for_earnings_range(self, start_date_iso: str, end_date_iso: str):
|
||||||
|
"""Return invoices by issue date for invoice-basis earnings reporting."""
|
||||||
|
return self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
i.id AS invoice_id,
|
||||||
|
i.issue_date,
|
||||||
|
i.invoice_number,
|
||||||
|
i.currency,
|
||||||
|
i.tax_label,
|
||||||
|
i.tax_rate_percent,
|
||||||
|
i.subtotal_cents,
|
||||||
|
i.tax_cents,
|
||||||
|
i.total_cents,
|
||||||
|
i.reporting_currency,
|
||||||
|
i.reporting_total_cents,
|
||||||
|
i.reporting_note,
|
||||||
|
p.name AS project_name,
|
||||||
|
pb.client_company
|
||||||
|
FROM invoices AS i
|
||||||
|
LEFT JOIN projects AS p ON p.id = i.project_id
|
||||||
|
LEFT JOIN project_billing AS pb ON pb.project_id = i.project_id
|
||||||
|
WHERE i.issue_date BETWEEN ? AND ?
|
||||||
|
ORDER BY i.issue_date, LOWER(p.name), i.invoice_number
|
||||||
|
""",
|
||||||
|
(start_date_iso, end_date_iso),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def get_invoice_payments(self, invoice_id: int):
|
||||||
|
return self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM invoice_payments
|
||||||
|
WHERE invoice_id = ?
|
||||||
|
ORDER BY received_at, id
|
||||||
|
""",
|
||||||
|
(invoice_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def get_invoice_payment_applied_cents(self, invoice_id: int) -> int:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(SUM(invoice_amount_cents), 0) AS total
|
||||||
|
FROM invoice_payments
|
||||||
|
WHERE invoice_id = ?
|
||||||
|
""",
|
||||||
|
(invoice_id,),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["total"] or 0)
|
||||||
|
|
||||||
|
def _sync_invoice_paid_at_from_payments(self, invoice_id: int) -> None:
|
||||||
|
invoice = self.get_invoice_with_project(invoice_id)
|
||||||
|
if invoice is None:
|
||||||
|
return
|
||||||
|
paid = self.get_invoice_payment_applied_cents(invoice_id)
|
||||||
|
total = int(invoice["total_cents"] or 0)
|
||||||
|
if total > 0 and paid >= total:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT MAX(received_at) AS paid_at
|
||||||
|
FROM invoice_payments
|
||||||
|
WHERE invoice_id = ?
|
||||||
|
""",
|
||||||
|
(invoice_id,),
|
||||||
|
).fetchone()
|
||||||
|
paid_at = row["paid_at"] if row else None
|
||||||
|
else:
|
||||||
|
paid_at = None
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE invoices SET paid_at = ? WHERE id = ?",
|
||||||
|
(paid_at, invoice_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_invoice_payment(
|
||||||
|
self,
|
||||||
|
invoice_id: int,
|
||||||
|
received_at: str,
|
||||||
|
invoice_amount_cents: int,
|
||||||
|
reporting_currency: str,
|
||||||
|
reporting_amount_cents: int,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
invoice = self.get_invoice_with_project(invoice_id)
|
||||||
|
if invoice is None:
|
||||||
|
raise ValueError("Invoice does not exist.")
|
||||||
|
if invoice_amount_cents <= 0 or reporting_amount_cents <= 0:
|
||||||
|
raise ValueError("Payment amounts must be greater than zero.")
|
||||||
|
reporting_currency = reporting_currency.strip().upper()
|
||||||
|
if not reporting_currency:
|
||||||
|
raise ValueError("A reporting currency is required.")
|
||||||
|
try:
|
||||||
|
_dt.date.fromisoformat(received_at)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Payment date must use YYYY-MM-DD format.") from exc
|
||||||
|
|
||||||
|
total = int(invoice["total_cents"] or 0)
|
||||||
|
already_applied = self.get_invoice_payment_applied_cents(invoice_id)
|
||||||
|
if already_applied + invoice_amount_cents > total:
|
||||||
|
raise ValueError("Payment amount exceeds the outstanding invoice balance.")
|
||||||
|
|
||||||
|
with self.conn:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO invoice_payments (
|
||||||
|
invoice_id,
|
||||||
|
received_at,
|
||||||
|
invoice_amount_cents,
|
||||||
|
reporting_currency,
|
||||||
|
reporting_amount_cents,
|
||||||
|
note
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
invoice_id,
|
||||||
|
received_at,
|
||||||
|
invoice_amount_cents,
|
||||||
|
reporting_currency,
|
||||||
|
reporting_amount_cents,
|
||||||
|
note,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
payment_id = int(cur.lastrowid)
|
||||||
|
self._sync_invoice_paid_at_from_payments(invoice_id)
|
||||||
|
return payment_id
|
||||||
|
|
||||||
|
def delete_invoice_payment(self, payment_id: int) -> None:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT invoice_id FROM invoice_payments WHERE id = ?",
|
||||||
|
(payment_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
invoice_id = int(row["invoice_id"])
|
||||||
|
with self.conn:
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM invoice_payments WHERE id = ?", (payment_id,)
|
||||||
|
)
|
||||||
|
self._sync_invoice_paid_at_from_payments(invoice_id)
|
||||||
|
|
||||||
|
def get_payments_for_range(self, start_date_iso: str, end_date_iso: str):
|
||||||
|
return self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
ip.id AS payment_id,
|
||||||
|
ip.received_at,
|
||||||
|
ip.invoice_amount_cents,
|
||||||
|
ip.reporting_currency,
|
||||||
|
ip.reporting_amount_cents,
|
||||||
|
ip.note,
|
||||||
|
i.id AS invoice_id,
|
||||||
|
i.invoice_number,
|
||||||
|
i.currency,
|
||||||
|
i.tax_label,
|
||||||
|
i.tax_rate_percent,
|
||||||
|
i.tax_cents,
|
||||||
|
i.total_cents,
|
||||||
|
p.name AS project_name,
|
||||||
|
pb.client_company
|
||||||
|
FROM invoice_payments AS ip
|
||||||
|
JOIN invoices AS i ON i.id = ip.invoice_id
|
||||||
|
LEFT JOIN projects AS p ON p.id = i.project_id
|
||||||
|
LEFT JOIN project_billing AS pb ON pb.project_id = i.project_id
|
||||||
|
WHERE ip.received_at BETWEEN ? AND ?
|
||||||
|
ORDER BY ip.received_at, LOWER(p.name), i.invoice_number, ip.id
|
||||||
|
""",
|
||||||
|
(start_date_iso, end_date_iso),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
def get_paid_invoices_without_payments(
|
||||||
|
self, start_date_iso: str, end_date_iso: str
|
||||||
|
):
|
||||||
|
return self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT i.*, p.name AS project_name
|
||||||
|
FROM invoices AS i
|
||||||
|
LEFT JOIN projects AS p ON p.id = i.project_id
|
||||||
|
WHERE i.paid_at BETWEEN ? AND ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM invoice_payments AS ip
|
||||||
|
WHERE ip.invoice_id = i.id
|
||||||
|
)
|
||||||
|
ORDER BY i.paid_at, LOWER(p.name), i.invoice_number
|
||||||
|
""",
|
||||||
|
(start_date_iso, end_date_iso),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
def _validate_invoice_field(self, field: str) -> str:
|
def _validate_invoice_field(self, field: str) -> str:
|
||||||
if field not in self._INVOICE_COLUMN_ALLOWLIST:
|
if field not in self._INVOICE_COLUMN_ALLOWLIST:
|
||||||
raise ValueError(f"Invalid invoice field name: {field!r}")
|
raise ValueError(f"Invalid invoice field name: {field!r}")
|
||||||
|
|
@ -2952,6 +3239,21 @@ class DBManager:
|
||||||
invoice_id,
|
invoice_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# A foreign-currency reporting valuation is tied to the invoice's
|
||||||
|
# issue date, currency and gross total. If one of those changes,
|
||||||
|
# require the user to value the invoice again rather than silently
|
||||||
|
# retaining stale tax-reporting data.
|
||||||
|
if field in {"issue_date", "currency", "total_cents"}:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE invoices
|
||||||
|
SET reporting_currency = NULL,
|
||||||
|
reporting_total_cents = NULL,
|
||||||
|
reporting_note = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(invoice_id,),
|
||||||
|
)
|
||||||
|
|
||||||
def update_invoice_number(self, invoice_id: int, invoice_number: str) -> None:
|
def update_invoice_number(self, invoice_id: int, invoice_number: str) -> None:
|
||||||
with self.conn:
|
with self.conn:
|
||||||
|
|
|
||||||
920
bouquin/earnings.py
Normal file
920
bouquin/earnings.py
Normal file
|
|
@ -0,0 +1,920 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QDate, QRectF, Qt, Signal
|
||||||
|
from PySide6.QtGui import QPainter, QPen
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QComboBox,
|
||||||
|
QDateEdit,
|
||||||
|
QDialog,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QFileDialog,
|
||||||
|
QFormLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import strings
|
||||||
|
from .db import DBManager
|
||||||
|
from .settings import load_db_config
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MonthlyEarnings:
|
||||||
|
month: str
|
||||||
|
sales_ex_tax_cents: int = 0
|
||||||
|
tax_cents: int = 0
|
||||||
|
sales_inc_tax_cents: int = 0
|
||||||
|
entry_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _month_key(date_iso: str) -> str:
|
||||||
|
return date_iso[:7]
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_months(start_iso: str, end_iso: str):
|
||||||
|
start = date.fromisoformat(start_iso)
|
||||||
|
end = date.fromisoformat(end_iso)
|
||||||
|
year, month = start.year, start.month
|
||||||
|
while (year, month) <= (end.year, end.month):
|
||||||
|
yield f"{year:04d}-{month:02d}"
|
||||||
|
if month == 12:
|
||||||
|
year += 1
|
||||||
|
month = 1
|
||||||
|
else:
|
||||||
|
month += 1
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_payments_by_month(rows, start_iso: str, end_iso: str):
|
||||||
|
"""Aggregate structured payment rows into reporting-currency monthly totals.
|
||||||
|
|
||||||
|
Each invoice currently has a single invoice-wide tax rate, so a part-payment's
|
||||||
|
tax component is the same proportion of the reporting-currency receipt as the
|
||||||
|
invoice tax is of the invoice total.
|
||||||
|
"""
|
||||||
|
totals = OrderedDict(
|
||||||
|
(month, [0, 0, 0, 0]) for month in _iter_months(start_iso, end_iso)
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
gross = int(row["reporting_amount_cents"] or 0)
|
||||||
|
invoice_total = int(row["total_cents"] or 0)
|
||||||
|
invoice_tax = int(row["tax_cents"] or 0)
|
||||||
|
tax = int(round(gross * invoice_tax / invoice_total)) if invoice_total else 0
|
||||||
|
net = gross - tax
|
||||||
|
bucket = totals.setdefault(_month_key(row["received_at"]), [0, 0, 0, 0])
|
||||||
|
bucket[0] += net
|
||||||
|
bucket[1] += tax
|
||||||
|
bucket[2] += gross
|
||||||
|
bucket[3] += 1
|
||||||
|
|
||||||
|
return [
|
||||||
|
MonthlyEarnings(
|
||||||
|
month=month,
|
||||||
|
sales_ex_tax_cents=values[0],
|
||||||
|
tax_cents=values[1],
|
||||||
|
sales_inc_tax_cents=values[2],
|
||||||
|
entry_count=values[3],
|
||||||
|
)
|
||||||
|
for month, values in totals.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def invoice_reporting_amount_cents(row, reporting_currency: str) -> int | None:
|
||||||
|
"""Return an invoice's gross value in the requested reporting currency.
|
||||||
|
|
||||||
|
Same-currency invoices are exact and need no extra valuation. Foreign-currency
|
||||||
|
invoices require an explicit invoice-date reporting value so a later bank
|
||||||
|
receipt is never silently reused as the tax/reporting value.
|
||||||
|
"""
|
||||||
|
currency = str(row["currency"] or "").strip().upper()
|
||||||
|
requested = reporting_currency.strip().upper()
|
||||||
|
if currency == requested:
|
||||||
|
return int(row["total_cents"] or 0)
|
||||||
|
|
||||||
|
stored_currency = str(row["reporting_currency"] or "").strip().upper()
|
||||||
|
stored_total = row["reporting_total_cents"]
|
||||||
|
if stored_currency == requested and stored_total is not None:
|
||||||
|
return int(stored_total)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_invoices_by_month(
|
||||||
|
rows, reporting_currency: str, start_iso: str, end_iso: str
|
||||||
|
):
|
||||||
|
"""Aggregate invoices by issue month in one reporting currency."""
|
||||||
|
totals = OrderedDict(
|
||||||
|
(month, [0, 0, 0, 0]) for month in _iter_months(start_iso, end_iso)
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
gross = invoice_reporting_amount_cents(row, reporting_currency)
|
||||||
|
if gross is None:
|
||||||
|
continue
|
||||||
|
invoice_total = int(row["total_cents"] or 0)
|
||||||
|
invoice_tax = int(row["tax_cents"] or 0)
|
||||||
|
tax = int(round(gross * invoice_tax / invoice_total)) if invoice_total else 0
|
||||||
|
net = gross - tax
|
||||||
|
bucket = totals.setdefault(_month_key(row["issue_date"]), [0, 0, 0, 0])
|
||||||
|
bucket[0] += net
|
||||||
|
bucket[1] += tax
|
||||||
|
bucket[2] += gross
|
||||||
|
bucket[3] += 1
|
||||||
|
|
||||||
|
return [
|
||||||
|
MonthlyEarnings(
|
||||||
|
month=month,
|
||||||
|
sales_ex_tax_cents=values[0],
|
||||||
|
tax_cents=values[1],
|
||||||
|
sales_inc_tax_cents=values[2],
|
||||||
|
entry_count=values[3],
|
||||||
|
)
|
||||||
|
for month, values in totals.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceReportingValueDialog(QDialog):
|
||||||
|
"""Record the invoice-date value of a foreign-currency invoice."""
|
||||||
|
|
||||||
|
valueChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, db: DBManager, invoice_id: int, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._db = db
|
||||||
|
self._invoice_id = int(invoice_id)
|
||||||
|
self.cfg = load_db_config()
|
||||||
|
self._invoice = self._db.get_invoice_with_project(self._invoice_id)
|
||||||
|
if self._invoice is None:
|
||||||
|
raise ValueError(f"Invoice {invoice_id} does not exist")
|
||||||
|
|
||||||
|
self.setWindowTitle(
|
||||||
|
strings._("invoice_reporting_value_title").format(
|
||||||
|
invoice=self._invoice["invoice_number"] or "?",
|
||||||
|
project=self._invoice["project_name"] or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.resize(600, 330)
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
|
summary = QLabel(
|
||||||
|
strings._("invoice_reporting_value_summary").format(
|
||||||
|
issue_date=self._invoice["issue_date"] or "",
|
||||||
|
total=int(self._invoice["total_cents"] or 0) / 100.0,
|
||||||
|
currency=self._invoice["currency"] or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
summary.setWordWrap(True)
|
||||||
|
root.addWidget(summary)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
self.reporting_currency = QLineEdit(
|
||||||
|
self._invoice["reporting_currency"] or self.cfg.reporting_currency or "AUD"
|
||||||
|
)
|
||||||
|
self.reporting_currency.setMaxLength(8)
|
||||||
|
form.addRow(strings._("reporting_currency") + ":", self.reporting_currency)
|
||||||
|
|
||||||
|
self.reporting_total = QDoubleSpinBox()
|
||||||
|
self.reporting_total.setDecimals(2)
|
||||||
|
self.reporting_total.setMaximum(999999999.99)
|
||||||
|
if self._invoice["reporting_total_cents"] is not None:
|
||||||
|
self.reporting_total.setValue(
|
||||||
|
int(self._invoice["reporting_total_cents"]) / 100.0
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
str(self._invoice["currency"] or "").upper()
|
||||||
|
== str(self.reporting_currency.text() or "").upper()
|
||||||
|
):
|
||||||
|
self.reporting_total.setValue(
|
||||||
|
int(self._invoice["total_cents"] or 0) / 100.0
|
||||||
|
)
|
||||||
|
form.addRow(strings._("invoice_reporting_total") + ":", self.reporting_total)
|
||||||
|
|
||||||
|
self.note = QTextEdit()
|
||||||
|
self.note.setMaximumHeight(90)
|
||||||
|
self.note.setPlainText(self._invoice["reporting_note"] or "")
|
||||||
|
form.addRow(strings._("invoice_reporting_note") + ":", self.note)
|
||||||
|
root.addLayout(form)
|
||||||
|
|
||||||
|
help_label = QLabel(strings._("invoice_reporting_value_help"))
|
||||||
|
help_label.setWordWrap(True)
|
||||||
|
root.addWidget(help_label)
|
||||||
|
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
clear_btn = QPushButton(strings._("invoice_reporting_value_clear"))
|
||||||
|
clear_btn.clicked.connect(self._clear)
|
||||||
|
buttons.addWidget(clear_btn)
|
||||||
|
buttons.addStretch(1)
|
||||||
|
save_btn = QPushButton(strings._("save"))
|
||||||
|
save_btn.clicked.connect(self._save)
|
||||||
|
buttons.addWidget(save_btn)
|
||||||
|
close_btn = QPushButton(strings._("close"))
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
buttons.addWidget(close_btn)
|
||||||
|
root.addLayout(buttons)
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
currency = self.reporting_currency.text().strip().upper()
|
||||||
|
amount_cents = int(round(self.reporting_total.value() * 100))
|
||||||
|
if not currency or amount_cents <= 0:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, strings._("error"), strings._("invoice_reporting_value_required")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._db.set_invoice_reporting_value(
|
||||||
|
self._invoice_id,
|
||||||
|
currency,
|
||||||
|
amount_cents,
|
||||||
|
self.note.toPlainText(),
|
||||||
|
)
|
||||||
|
self.reporting_currency.setText(currency)
|
||||||
|
self.valueChanged.emit()
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def _clear(self) -> None:
|
||||||
|
self._db.clear_invoice_reporting_value(self._invoice_id)
|
||||||
|
self.valueChanged.emit()
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
|
||||||
|
class EarningsChart(QWidget):
|
||||||
|
"""Small stacked monthly sales chart: ex-tax sales plus tax."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._rows: list[MonthlyEarnings] = []
|
||||||
|
self.setMinimumHeight(190)
|
||||||
|
|
||||||
|
def set_rows(self, rows: list[MonthlyEarnings]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event): # noqa: N802 - Qt API
|
||||||
|
_ = event
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
palette = self.palette()
|
||||||
|
painter.fillRect(self.rect(), palette.base())
|
||||||
|
|
||||||
|
if not self._rows:
|
||||||
|
painter.setPen(palette.text().color())
|
||||||
|
painter.drawText(
|
||||||
|
self.rect(), Qt.AlignmentFlag.AlignCenter, strings._("earnings_no_data")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
left, top, right, bottom = 56, 12, 16, 36
|
||||||
|
plot_w = max(1, self.width() - left - right)
|
||||||
|
plot_h = max(1, self.height() - top - bottom)
|
||||||
|
max_gross = max((r.sales_inc_tax_cents for r in self._rows), default=0)
|
||||||
|
if max_gross <= 0:
|
||||||
|
painter.setPen(palette.text().color())
|
||||||
|
painter.drawText(
|
||||||
|
self.rect(), Qt.AlignmentFlag.AlignCenter, strings._("earnings_no_data")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
axis_pen = QPen(palette.mid().color())
|
||||||
|
painter.setPen(axis_pen)
|
||||||
|
painter.drawLine(left, top, left, top + plot_h)
|
||||||
|
painter.drawLine(left, top + plot_h, left + plot_w, top + plot_h)
|
||||||
|
|
||||||
|
count = max(1, len(self._rows))
|
||||||
|
slot = plot_w / count
|
||||||
|
bar_w = max(8.0, min(54.0, slot * 0.58))
|
||||||
|
net_color = palette.highlight().color()
|
||||||
|
tax_color = palette.mid().color()
|
||||||
|
|
||||||
|
for idx, row in enumerate(self._rows):
|
||||||
|
x = left + slot * idx + (slot - bar_w) / 2
|
||||||
|
gross_h = (row.sales_inc_tax_cents / max_gross) * plot_h
|
||||||
|
tax_h = (row.tax_cents / max_gross) * plot_h
|
||||||
|
net_h = max(0.0, gross_h - tax_h)
|
||||||
|
base_y = top + plot_h
|
||||||
|
|
||||||
|
painter.fillRect(QRectF(x, base_y - net_h, bar_w, net_h), net_color)
|
||||||
|
if tax_h > 0:
|
||||||
|
painter.fillRect(QRectF(x, base_y - gross_h, bar_w, tax_h), tax_color)
|
||||||
|
|
||||||
|
painter.setPen(palette.text().color())
|
||||||
|
label = row.month[5:7] + "/" + row.month[2:4]
|
||||||
|
painter.drawText(
|
||||||
|
QRectF(left + slot * idx, base_y + 4, slot, 24),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop,
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
|
||||||
|
painter.setPen(palette.text().color())
|
||||||
|
painter.drawText(4, top + 12, f"{max_gross / 100.0:,.0f}")
|
||||||
|
painter.drawText(4, top + plot_h, "0")
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentsDialog(QDialog):
|
||||||
|
"""Record and manage structured receipts for one invoice."""
|
||||||
|
|
||||||
|
paymentsChanged = Signal()
|
||||||
|
|
||||||
|
COL_DATE = 0
|
||||||
|
COL_INVOICE_AMOUNT = 1
|
||||||
|
COL_REPORTING_AMOUNT = 2
|
||||||
|
COL_RATE = 3
|
||||||
|
COL_NOTE = 4
|
||||||
|
|
||||||
|
def __init__(self, db: DBManager, invoice_id: int, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._db = db
|
||||||
|
self._invoice_id = int(invoice_id)
|
||||||
|
self.cfg = load_db_config()
|
||||||
|
self._invoice = self._db.get_invoice_with_project(self._invoice_id)
|
||||||
|
if self._invoice is None:
|
||||||
|
raise ValueError(f"Invoice {invoice_id} does not exist")
|
||||||
|
|
||||||
|
title = strings._("invoice_payments_title").format(
|
||||||
|
invoice=self._invoice["invoice_number"] or "?",
|
||||||
|
project=self._invoice["project_name"] or "",
|
||||||
|
)
|
||||||
|
self.setWindowTitle(title)
|
||||||
|
self.resize(820, 520)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
summary = QLabel(
|
||||||
|
strings._("invoice_payments_summary").format(
|
||||||
|
total=(int(self._invoice["total_cents"] or 0) / 100.0),
|
||||||
|
currency=self._invoice["currency"] or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
root.addWidget(summary)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
self.received_at = QDateEdit(QDate.currentDate())
|
||||||
|
self.received_at.setCalendarPopup(True)
|
||||||
|
self.received_at.setDisplayFormat("yyyy-MM-dd")
|
||||||
|
if self._invoice["paid_at"]:
|
||||||
|
qd = QDate.fromString(str(self._invoice["paid_at"]), "yyyy-MM-dd")
|
||||||
|
if qd.isValid():
|
||||||
|
self.received_at.setDate(qd)
|
||||||
|
form.addRow(strings._("invoice_payment_received_on") + ":", self.received_at)
|
||||||
|
|
||||||
|
self.invoice_amount = QDoubleSpinBox()
|
||||||
|
self.invoice_amount.setDecimals(2)
|
||||||
|
self.invoice_amount.setMaximum(999999999.99)
|
||||||
|
self.invoice_amount.setSuffix(f" {self._invoice['currency'] or ''}")
|
||||||
|
form.addRow(
|
||||||
|
strings._("invoice_payment_applied_amount") + ":", self.invoice_amount
|
||||||
|
)
|
||||||
|
|
||||||
|
self.reporting_currency = QLineEdit(self.cfg.reporting_currency or "AUD")
|
||||||
|
self.reporting_currency.setMaxLength(8)
|
||||||
|
form.addRow(strings._("reporting_currency") + ":", self.reporting_currency)
|
||||||
|
|
||||||
|
self.reporting_amount = QDoubleSpinBox()
|
||||||
|
self.reporting_amount.setDecimals(2)
|
||||||
|
self.reporting_amount.setMaximum(999999999.99)
|
||||||
|
form.addRow(
|
||||||
|
strings._("invoice_payment_reporting_amount") + ":", self.reporting_amount
|
||||||
|
)
|
||||||
|
|
||||||
|
self.note = QTextEdit()
|
||||||
|
self.note.setMaximumHeight(72)
|
||||||
|
form.addRow(strings._("invoice_payment_note") + ":", self.note)
|
||||||
|
root.addLayout(form)
|
||||||
|
|
||||||
|
self.help_label = QLabel(strings._("invoice_payment_reporting_help"))
|
||||||
|
self.help_label.setWordWrap(True)
|
||||||
|
root.addWidget(self.help_label)
|
||||||
|
|
||||||
|
add_row = QHBoxLayout()
|
||||||
|
self.outstanding_label = QLabel("")
|
||||||
|
add_row.addWidget(self.outstanding_label)
|
||||||
|
add_row.addStretch(1)
|
||||||
|
add_btn = QPushButton(strings._("invoice_payment_record"))
|
||||||
|
add_btn.clicked.connect(self._record_payment)
|
||||||
|
add_row.addWidget(add_btn)
|
||||||
|
root.addLayout(add_row)
|
||||||
|
|
||||||
|
self.table = QTableWidget()
|
||||||
|
self.table.setColumnCount(5)
|
||||||
|
self.table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
strings._("invoice_payment_received_on"),
|
||||||
|
strings._("invoice_payment_applied_amount"),
|
||||||
|
strings._("invoice_payment_reporting_amount"),
|
||||||
|
strings._("invoice_payment_exchange_rate"),
|
||||||
|
strings._("invoice_payment_note"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
|
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
header = self.table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(
|
||||||
|
self.COL_DATE, QHeaderView.ResizeMode.ResizeToContents
|
||||||
|
)
|
||||||
|
header.setSectionResizeMode(
|
||||||
|
self.COL_INVOICE_AMOUNT, QHeaderView.ResizeMode.ResizeToContents
|
||||||
|
)
|
||||||
|
header.setSectionResizeMode(
|
||||||
|
self.COL_REPORTING_AMOUNT, QHeaderView.ResizeMode.ResizeToContents
|
||||||
|
)
|
||||||
|
header.setSectionResizeMode(
|
||||||
|
self.COL_RATE, QHeaderView.ResizeMode.ResizeToContents
|
||||||
|
)
|
||||||
|
header.setSectionResizeMode(self.COL_NOTE, QHeaderView.ResizeMode.Stretch)
|
||||||
|
root.addWidget(self.table, 1)
|
||||||
|
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
delete_btn = QPushButton(strings._("delete"))
|
||||||
|
delete_btn.clicked.connect(self._delete_payment)
|
||||||
|
btn_row.addWidget(delete_btn)
|
||||||
|
btn_row.addStretch(1)
|
||||||
|
close_btn = QPushButton(strings._("close"))
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
btn_row.addWidget(close_btn)
|
||||||
|
root.addLayout(btn_row)
|
||||||
|
|
||||||
|
self.invoice_amount.valueChanged.connect(self._copy_same_currency_amount)
|
||||||
|
self.reporting_currency.textChanged.connect(self._copy_same_currency_amount)
|
||||||
|
self._reload()
|
||||||
|
|
||||||
|
def _outstanding_cents(self) -> int:
|
||||||
|
total = int(self._invoice["total_cents"] or 0)
|
||||||
|
applied = self._db.get_invoice_payment_applied_cents(self._invoice_id)
|
||||||
|
return max(0, total - applied)
|
||||||
|
|
||||||
|
def _copy_same_currency_amount(self, *_args) -> None:
|
||||||
|
if (
|
||||||
|
self.reporting_currency.text().strip().upper()
|
||||||
|
== str(self._invoice["currency"] or "").upper()
|
||||||
|
):
|
||||||
|
self.reporting_amount.setValue(self.invoice_amount.value())
|
||||||
|
|
||||||
|
def _reload(self) -> None:
|
||||||
|
rows = self._db.get_invoice_payments(self._invoice_id)
|
||||||
|
self.table.setRowCount(len(rows))
|
||||||
|
invoice_currency = str(self._invoice["currency"] or "")
|
||||||
|
for idx, row in enumerate(rows):
|
||||||
|
date_item = QTableWidgetItem(row["received_at"] or "")
|
||||||
|
date_item.setData(Qt.ItemDataRole.UserRole, int(row["id"]))
|
||||||
|
self.table.setItem(idx, self.COL_DATE, date_item)
|
||||||
|
applied = int(row["invoice_amount_cents"] or 0) / 100.0
|
||||||
|
reporting = int(row["reporting_amount_cents"] or 0) / 100.0
|
||||||
|
report_currency = row["reporting_currency"] or ""
|
||||||
|
self.table.setItem(
|
||||||
|
idx,
|
||||||
|
self.COL_INVOICE_AMOUNT,
|
||||||
|
QTableWidgetItem(f"{applied:,.2f} {invoice_currency}"),
|
||||||
|
)
|
||||||
|
self.table.setItem(
|
||||||
|
idx,
|
||||||
|
self.COL_REPORTING_AMOUNT,
|
||||||
|
QTableWidgetItem(f"{reporting:,.2f} {report_currency}"),
|
||||||
|
)
|
||||||
|
rate = reporting / applied if applied else 0.0
|
||||||
|
self.table.setItem(
|
||||||
|
idx, self.COL_RATE, QTableWidgetItem(f"{rate:.6f}" if rate else "")
|
||||||
|
)
|
||||||
|
self.table.setItem(idx, self.COL_NOTE, QTableWidgetItem(row["note"] or ""))
|
||||||
|
|
||||||
|
outstanding = self._outstanding_cents()
|
||||||
|
self.outstanding_label.setText(
|
||||||
|
strings._("invoice_payment_outstanding").format(
|
||||||
|
amount=outstanding / 100.0,
|
||||||
|
currency=invoice_currency,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.invoice_amount.setMaximum(max(0.0, outstanding / 100.0))
|
||||||
|
self.invoice_amount.setValue(outstanding / 100.0)
|
||||||
|
self._copy_same_currency_amount()
|
||||||
|
|
||||||
|
def _record_payment(self) -> None:
|
||||||
|
applied_cents = int(round(self.invoice_amount.value() * 100))
|
||||||
|
reporting_cents = int(round(self.reporting_amount.value() * 100))
|
||||||
|
reporting_currency = self.reporting_currency.text().strip().upper()
|
||||||
|
if applied_cents <= 0 or reporting_cents <= 0 or not reporting_currency:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
strings._("error"),
|
||||||
|
strings._("invoice_payment_amount_required"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._db.add_invoice_payment(
|
||||||
|
invoice_id=self._invoice_id,
|
||||||
|
received_at=self.received_at.date().toString("yyyy-MM-dd"),
|
||||||
|
invoice_amount_cents=applied_cents,
|
||||||
|
reporting_currency=reporting_currency,
|
||||||
|
reporting_amount_cents=reporting_cents,
|
||||||
|
note=self.note.toPlainText().strip() or None,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
QMessageBox.warning(self, strings._("error"), str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.note.clear()
|
||||||
|
self.paymentsChanged.emit()
|
||||||
|
self._invoice = self._db.get_invoice_with_project(self._invoice_id)
|
||||||
|
self._reload()
|
||||||
|
|
||||||
|
def _delete_payment(self) -> None:
|
||||||
|
row = self.table.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
return
|
||||||
|
item = self.table.item(row, self.COL_DATE)
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
payment_id = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if payment_id is None:
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
QMessageBox.question(
|
||||||
|
self,
|
||||||
|
strings._("delete"),
|
||||||
|
strings._("invoice_payment_delete_confirm"),
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
!= QMessageBox.StandardButton.Yes
|
||||||
|
):
|
||||||
|
return
|
||||||
|
self._db.delete_invoice_payment(int(payment_id))
|
||||||
|
self.paymentsChanged.emit()
|
||||||
|
self._invoice = self._db.get_invoice_with_project(self._invoice_id)
|
||||||
|
self._reload()
|
||||||
|
|
||||||
|
|
||||||
|
class EarningsReportDialog(QDialog):
|
||||||
|
"""Earnings report using either invoice-date or payment-date recognition."""
|
||||||
|
|
||||||
|
COL_MONTH = 0
|
||||||
|
COL_NET = 1
|
||||||
|
COL_TAX = 2
|
||||||
|
COL_GROSS = 3
|
||||||
|
COL_COUNT = 4
|
||||||
|
|
||||||
|
def __init__(self, db: DBManager, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._db = db
|
||||||
|
self.cfg = load_db_config()
|
||||||
|
self._detail_rows = []
|
||||||
|
self._monthly_rows: list[MonthlyEarnings] = []
|
||||||
|
|
||||||
|
self.setWindowTitle(strings._("earnings_report"))
|
||||||
|
self.resize(1040, 740)
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
self.reporting_currency = QLineEdit(self.cfg.reporting_currency or "AUD")
|
||||||
|
self.reporting_currency.setMaxLength(8)
|
||||||
|
form.addRow(strings._("reporting_currency") + ":", self.reporting_currency)
|
||||||
|
|
||||||
|
self.basis_combo = QComboBox()
|
||||||
|
self.basis_combo.addItem(strings._("earnings_basis_invoice"), "invoice")
|
||||||
|
self.basis_combo.addItem(strings._("earnings_basis_payment"), "payment")
|
||||||
|
form.addRow(strings._("earnings_basis") + ":", self.basis_combo)
|
||||||
|
|
||||||
|
today = QDate.currentDate()
|
||||||
|
quarter_start_month = ((today.month() - 1) // 3) * 3 + 1
|
||||||
|
quarter_start = QDate(today.year(), quarter_start_month, 1)
|
||||||
|
self.from_date = QDateEdit(quarter_start)
|
||||||
|
self.from_date.setCalendarPopup(True)
|
||||||
|
self.from_date.setDisplayFormat("yyyy-MM-dd")
|
||||||
|
self.to_date = QDateEdit(today)
|
||||||
|
self.to_date.setCalendarPopup(True)
|
||||||
|
self.to_date.setDisplayFormat("yyyy-MM-dd")
|
||||||
|
|
||||||
|
self.range_preset = QComboBox()
|
||||||
|
self.range_preset.addItem(strings._("earnings_this_quarter"), "this_quarter")
|
||||||
|
self.range_preset.addItem(
|
||||||
|
strings._("earnings_previous_quarter"), "previous_quarter"
|
||||||
|
)
|
||||||
|
self.range_preset.addItem(strings._("this_year"), "this_year")
|
||||||
|
self.range_preset.addItem(strings._("custom_range"), "custom")
|
||||||
|
self.range_preset.currentIndexChanged.connect(self._on_preset_changed)
|
||||||
|
range_row = QHBoxLayout()
|
||||||
|
range_row.addWidget(self.range_preset)
|
||||||
|
range_row.addWidget(self.from_date)
|
||||||
|
range_row.addWidget(QLabel("—"))
|
||||||
|
range_row.addWidget(self.to_date)
|
||||||
|
form.addRow(strings._("date_range") + ":", range_row)
|
||||||
|
root.addLayout(form)
|
||||||
|
|
||||||
|
self.help_label = QLabel("")
|
||||||
|
self.help_label.setWordWrap(True)
|
||||||
|
root.addWidget(self.help_label)
|
||||||
|
|
||||||
|
run_row = QHBoxLayout()
|
||||||
|
run_row.addStretch(1)
|
||||||
|
run_btn = QPushButton(strings._("run_report"))
|
||||||
|
run_btn.clicked.connect(self._run_report)
|
||||||
|
run_row.addWidget(run_btn)
|
||||||
|
export_btn = QPushButton(strings._("export_csv"))
|
||||||
|
export_btn.clicked.connect(self._export_csv)
|
||||||
|
run_row.addWidget(export_btn)
|
||||||
|
root.addLayout(run_row)
|
||||||
|
|
||||||
|
self.chart = EarningsChart()
|
||||||
|
root.addWidget(self.chart)
|
||||||
|
|
||||||
|
self.summary_label = QLabel("")
|
||||||
|
self.summary_label.setWordWrap(True)
|
||||||
|
root.addWidget(self.summary_label)
|
||||||
|
|
||||||
|
self.warning_label = QLabel("")
|
||||||
|
self.warning_label.setWordWrap(True)
|
||||||
|
root.addWidget(self.warning_label)
|
||||||
|
|
||||||
|
self.table = QTableWidget()
|
||||||
|
self.table.setColumnCount(5)
|
||||||
|
self.table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
strings._("earnings_month"),
|
||||||
|
strings._("earnings_sales_ex_tax"),
|
||||||
|
strings._("earnings_tax"),
|
||||||
|
strings._("earnings_sales_inc_tax"),
|
||||||
|
strings._("earnings_invoices"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
header = self.table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(self.COL_MONTH, QHeaderView.ResizeMode.Stretch)
|
||||||
|
for col in (self.COL_NET, self.COL_TAX, self.COL_GROSS, self.COL_COUNT):
|
||||||
|
header.setSectionResizeMode(col, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
root.addWidget(self.table, 1)
|
||||||
|
|
||||||
|
self.details = QTableWidget()
|
||||||
|
self.details.setColumnCount(9)
|
||||||
|
self.details.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
root.addWidget(self.details, 1)
|
||||||
|
|
||||||
|
close_row = QHBoxLayout()
|
||||||
|
close_row.addStretch(1)
|
||||||
|
close_btn = QPushButton(strings._("close"))
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
close_row.addWidget(close_btn)
|
||||||
|
root.addLayout(close_row)
|
||||||
|
|
||||||
|
self.basis_combo.currentIndexChanged.connect(self._run_report)
|
||||||
|
self._run_report()
|
||||||
|
|
||||||
|
def _on_preset_changed(self, _index: int) -> None:
|
||||||
|
preset = self.range_preset.currentData()
|
||||||
|
today = QDate.currentDate()
|
||||||
|
qstart_month = ((today.month() - 1) // 3) * 3 + 1
|
||||||
|
qstart = QDate(today.year(), qstart_month, 1)
|
||||||
|
if preset == "this_quarter":
|
||||||
|
start, end = qstart, today
|
||||||
|
elif preset == "previous_quarter":
|
||||||
|
prev_end = qstart.addDays(-1)
|
||||||
|
prev_start_month = ((prev_end.month() - 1) // 3) * 3 + 1
|
||||||
|
start = QDate(prev_end.year(), prev_start_month, 1)
|
||||||
|
end = prev_end
|
||||||
|
elif preset == "this_year":
|
||||||
|
start, end = QDate(today.year(), 1, 1), today
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
self.from_date.setDate(start)
|
||||||
|
self.to_date.setDate(end)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tax_from_reporting_gross(row, gross: int) -> int:
|
||||||
|
invoice_total = int(row["total_cents"] or 0)
|
||||||
|
invoice_tax = int(row["tax_cents"] or 0)
|
||||||
|
return int(round(gross * invoice_tax / invoice_total)) if invoice_total else 0
|
||||||
|
|
||||||
|
def _configure_headers(self, basis: str) -> None:
|
||||||
|
if basis == "invoice":
|
||||||
|
self.help_label.setText(strings._("earnings_report_help_invoice"))
|
||||||
|
self.table.horizontalHeaderItem(self.COL_COUNT).setText(
|
||||||
|
strings._("earnings_invoices")
|
||||||
|
)
|
||||||
|
labels = [
|
||||||
|
strings._("invoice_issue_date"),
|
||||||
|
strings._("invoice_client_company"),
|
||||||
|
strings._("project"),
|
||||||
|
strings._("invoice_number"),
|
||||||
|
strings._("invoice_currency"),
|
||||||
|
strings._("invoice_total"),
|
||||||
|
strings._("invoice_reporting_total"),
|
||||||
|
strings._("earnings_tax"),
|
||||||
|
strings._("invoice_reporting_note"),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
self.help_label.setText(strings._("earnings_report_help_payment"))
|
||||||
|
self.table.horizontalHeaderItem(self.COL_COUNT).setText(
|
||||||
|
strings._("earnings_payments")
|
||||||
|
)
|
||||||
|
labels = [
|
||||||
|
strings._("invoice_payment_received_on"),
|
||||||
|
strings._("invoice_client_company"),
|
||||||
|
strings._("project"),
|
||||||
|
strings._("invoice_number"),
|
||||||
|
strings._("invoice_currency"),
|
||||||
|
strings._("invoice_payment_applied_amount"),
|
||||||
|
strings._("invoice_payment_reporting_amount"),
|
||||||
|
strings._("earnings_tax"),
|
||||||
|
strings._("invoice_payment_note"),
|
||||||
|
]
|
||||||
|
self.details.setHorizontalHeaderLabels(labels)
|
||||||
|
dheader = self.details.horizontalHeader()
|
||||||
|
for col in range(8):
|
||||||
|
dheader.setSectionResizeMode(col, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
dheader.setSectionResizeMode(8, QHeaderView.ResizeMode.Stretch)
|
||||||
|
|
||||||
|
def _run_report(self, _index: int | None = None) -> None:
|
||||||
|
start = self.from_date.date().toString("yyyy-MM-dd")
|
||||||
|
end = self.to_date.date().toString("yyyy-MM-dd")
|
||||||
|
if end < start:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, strings._("error"), strings._("earnings_invalid_range")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
currency = self.reporting_currency.text().strip().upper()
|
||||||
|
if not currency:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, strings._("error"), strings._("earnings_currency_required")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.reporting_currency.setText(currency)
|
||||||
|
|
||||||
|
basis = str(self.basis_combo.currentData() or "invoice")
|
||||||
|
self._configure_headers(basis)
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if basis == "invoice":
|
||||||
|
all_rows = self._db.get_invoices_for_earnings_range(start, end)
|
||||||
|
self._detail_rows = [
|
||||||
|
row
|
||||||
|
for row in all_rows
|
||||||
|
if invoice_reporting_amount_cents(row, currency) is not None
|
||||||
|
]
|
||||||
|
missing = len(all_rows) - len(self._detail_rows)
|
||||||
|
self._monthly_rows = aggregate_invoices_by_month(
|
||||||
|
self._detail_rows, currency, start, end
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
warnings.append(
|
||||||
|
strings._("earnings_missing_invoice_values").format(
|
||||||
|
count=missing, currency=currency
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
all_rows = self._db.get_payments_for_range(start, end)
|
||||||
|
self._detail_rows = [
|
||||||
|
row
|
||||||
|
for row in all_rows
|
||||||
|
if str(row["reporting_currency"] or "").upper() == currency
|
||||||
|
]
|
||||||
|
skipped_currency = len(all_rows) - len(self._detail_rows)
|
||||||
|
self._monthly_rows = aggregate_payments_by_month(
|
||||||
|
self._detail_rows, start, end
|
||||||
|
)
|
||||||
|
legacy = self._db.get_paid_invoices_without_payments(start, end)
|
||||||
|
if legacy:
|
||||||
|
warnings.append(
|
||||||
|
strings._("earnings_unstructured_warning").format(count=len(legacy))
|
||||||
|
)
|
||||||
|
if skipped_currency:
|
||||||
|
warnings.append(
|
||||||
|
strings._("earnings_other_currency_warning").format(
|
||||||
|
count=skipped_currency, currency=currency
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.table.setRowCount(len(self._monthly_rows))
|
||||||
|
for idx, row in enumerate(self._monthly_rows):
|
||||||
|
self.table.setItem(idx, self.COL_MONTH, QTableWidgetItem(row.month))
|
||||||
|
self.table.setItem(
|
||||||
|
idx,
|
||||||
|
self.COL_NET,
|
||||||
|
QTableWidgetItem(f"{row.sales_ex_tax_cents / 100.0:,.2f} {currency}"),
|
||||||
|
)
|
||||||
|
self.table.setItem(
|
||||||
|
idx,
|
||||||
|
self.COL_TAX,
|
||||||
|
QTableWidgetItem(f"{row.tax_cents / 100.0:,.2f} {currency}"),
|
||||||
|
)
|
||||||
|
self.table.setItem(
|
||||||
|
idx,
|
||||||
|
self.COL_GROSS,
|
||||||
|
QTableWidgetItem(f"{row.sales_inc_tax_cents / 100.0:,.2f} {currency}"),
|
||||||
|
)
|
||||||
|
self.table.setItem(
|
||||||
|
idx, self.COL_COUNT, QTableWidgetItem(str(row.entry_count))
|
||||||
|
)
|
||||||
|
|
||||||
|
total_net = sum(row.sales_ex_tax_cents for row in self._monthly_rows)
|
||||||
|
total_tax = sum(row.tax_cents for row in self._monthly_rows)
|
||||||
|
total_gross = sum(row.sales_inc_tax_cents for row in self._monthly_rows)
|
||||||
|
self.summary_label.setText(
|
||||||
|
strings._("earnings_totals").format(
|
||||||
|
ex_tax=f"{total_net / 100.0:,.2f}",
|
||||||
|
tax=f"{total_tax / 100.0:,.2f}",
|
||||||
|
inc_tax=f"{total_gross / 100.0:,.2f}",
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.chart.set_rows(self._monthly_rows)
|
||||||
|
self.warning_label.setText(" ".join(warnings))
|
||||||
|
|
||||||
|
self.details.setRowCount(len(self._detail_rows))
|
||||||
|
for idx, row in enumerate(self._detail_rows):
|
||||||
|
if basis == "invoice":
|
||||||
|
gross = invoice_reporting_amount_cents(row, currency)
|
||||||
|
if gross is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Invoice included in earnings report without a "
|
||||||
|
f"reporting amount: {row['invoice_number']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
tax = self._tax_from_reporting_gross(row, gross)
|
||||||
|
values = [
|
||||||
|
row["issue_date"] or "",
|
||||||
|
row["client_company"] or "",
|
||||||
|
row["project_name"] or "",
|
||||||
|
row["invoice_number"] or "",
|
||||||
|
row["currency"] or "",
|
||||||
|
f"{int(row['total_cents'] or 0) / 100.0:,.2f} {row['currency'] or ''}",
|
||||||
|
f"{gross / 100.0:,.2f} {currency}",
|
||||||
|
f"{tax / 100.0:,.2f} {currency}",
|
||||||
|
row["reporting_note"] or "",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
gross = int(row["reporting_amount_cents"] or 0)
|
||||||
|
tax = self._tax_from_reporting_gross(row, gross)
|
||||||
|
values = [
|
||||||
|
row["received_at"] or "",
|
||||||
|
row["client_company"] or "",
|
||||||
|
row["project_name"] or "",
|
||||||
|
row["invoice_number"] or "",
|
||||||
|
row["currency"] or "",
|
||||||
|
f"{int(row['invoice_amount_cents'] or 0) / 100.0:,.2f} {row['currency'] or ''}",
|
||||||
|
f"{gross / 100.0:,.2f} {currency}",
|
||||||
|
f"{tax / 100.0:,.2f} {currency}",
|
||||||
|
row["note"] or "",
|
||||||
|
]
|
||||||
|
for col, value in enumerate(values):
|
||||||
|
self.details.setItem(idx, col, QTableWidgetItem(str(value)))
|
||||||
|
|
||||||
|
def _export_csv(self) -> None:
|
||||||
|
if not self._monthly_rows:
|
||||||
|
QMessageBox.information(
|
||||||
|
self, strings._("earnings_report"), strings._("earnings_no_data")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
filename, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
strings._("export_csv"),
|
||||||
|
"earnings.csv",
|
||||||
|
"CSV Files (*.csv);;All Files (*)",
|
||||||
|
)
|
||||||
|
if not filename:
|
||||||
|
return
|
||||||
|
path = Path(filename)
|
||||||
|
if path.suffix.lower() != ".csv":
|
||||||
|
path = path.with_suffix(".csv")
|
||||||
|
currency = self.reporting_currency.text().strip().upper()
|
||||||
|
basis = str(self.basis_combo.currentData() or "invoice")
|
||||||
|
count_label = (
|
||||||
|
strings._("earnings_invoices")
|
||||||
|
if basis == "invoice"
|
||||||
|
else strings._("earnings_payments")
|
||||||
|
)
|
||||||
|
with path.open("w", newline="", encoding="utf-8") as fh:
|
||||||
|
writer = csv.writer(fh)
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
strings._("earnings_month"),
|
||||||
|
strings._("earnings_sales_ex_tax"),
|
||||||
|
strings._("earnings_tax"),
|
||||||
|
strings._("earnings_sales_inc_tax"),
|
||||||
|
count_label,
|
||||||
|
strings._("reporting_currency"),
|
||||||
|
strings._("earnings_basis"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for row in self._monthly_rows:
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
row.month,
|
||||||
|
f"{row.sales_ex_tax_cents / 100.0:.2f}",
|
||||||
|
f"{row.tax_cents / 100.0:.2f}",
|
||||||
|
f"{row.sales_inc_tax_cents / 100.0:.2f}",
|
||||||
|
row.entry_count,
|
||||||
|
currency,
|
||||||
|
basis,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -1071,6 +1071,18 @@ class InvoicesDialog(QDialog):
|
||||||
delete_btn.clicked.connect(self._on_delete_clicked)
|
delete_btn.clicked.connect(self._on_delete_clicked)
|
||||||
btn_row.addWidget(delete_btn)
|
btn_row.addWidget(delete_btn)
|
||||||
|
|
||||||
|
reporting_value_btn = QPushButton(strings._("invoice_reporting_value"))
|
||||||
|
reporting_value_btn.clicked.connect(self._on_reporting_value_clicked)
|
||||||
|
btn_row.addWidget(reporting_value_btn)
|
||||||
|
|
||||||
|
payments_btn = QPushButton(strings._("invoice_payments"))
|
||||||
|
payments_btn.clicked.connect(self._on_payments_clicked)
|
||||||
|
btn_row.addWidget(payments_btn)
|
||||||
|
|
||||||
|
earnings_btn = QPushButton(strings._("earnings_report"))
|
||||||
|
earnings_btn.clicked.connect(self._on_earnings_clicked)
|
||||||
|
btn_row.addWidget(earnings_btn)
|
||||||
|
|
||||||
close_btn = QPushButton(strings._("close"))
|
close_btn = QPushButton(strings._("close"))
|
||||||
close_btn.clicked.connect(self.accept)
|
close_btn.clicked.connect(self.accept)
|
||||||
btn_row.addWidget(close_btn)
|
btn_row.addWidget(close_btn)
|
||||||
|
|
@ -1148,14 +1160,21 @@ class InvoicesDialog(QDialog):
|
||||||
self.project_combo.blockSignals(True)
|
self.project_combo.blockSignals(True)
|
||||||
try:
|
try:
|
||||||
self.project_combo.clear()
|
self.project_combo.clear()
|
||||||
for proj_id, name in self._db.list_projects():
|
projects = self._db.list_projects()
|
||||||
|
if projects:
|
||||||
|
self.project_combo.addItem(strings._("all_projects"), None)
|
||||||
|
for proj_id, name in projects:
|
||||||
self.project_combo.addItem(name, proj_id)
|
self.project_combo.addItem(name, proj_id)
|
||||||
finally:
|
finally:
|
||||||
self.project_combo.blockSignals(False)
|
self.project_combo.blockSignals(False)
|
||||||
|
|
||||||
def _select_initial_project(self, project_id: int | None) -> None:
|
def _select_initial_project(self, project_id: int | None) -> None:
|
||||||
if project_id is None:
|
if project_id is None:
|
||||||
if self.project_combo.count() > 0:
|
# Keep the historical default of selecting the first real project,
|
||||||
|
# while still exposing an explicit All projects view.
|
||||||
|
if self.project_combo.count() > 1:
|
||||||
|
self.project_combo.setCurrentIndex(1)
|
||||||
|
elif self.project_combo.count() > 0:
|
||||||
self.project_combo.setCurrentIndex(0)
|
self.project_combo.setCurrentIndex(0)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -1163,7 +1182,68 @@ class InvoicesDialog(QDialog):
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
self.project_combo.setCurrentIndex(idx)
|
self.project_combo.setCurrentIndex(idx)
|
||||||
elif self.project_combo.count() > 0:
|
elif self.project_combo.count() > 0:
|
||||||
self.project_combo.setCurrentIndex(0)
|
self.project_combo.setCurrentIndex(
|
||||||
|
1 if self.project_combo.count() > 1 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def _selected_invoice(self) -> tuple[int, int] | None:
|
||||||
|
row = self.table.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
sel = self.table.selectionModel().selectedRows()
|
||||||
|
if sel:
|
||||||
|
row = sel[0].row()
|
||||||
|
if row < 0:
|
||||||
|
return None
|
||||||
|
item = self.table.item(row, self.COL_NUMBER)
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
invoice_id = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if invoice_id is None:
|
||||||
|
return None
|
||||||
|
return row, int(invoice_id)
|
||||||
|
|
||||||
|
def _on_reporting_value_clicked(self) -> None:
|
||||||
|
selected = self._selected_invoice()
|
||||||
|
if selected is None:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
strings._("invoice_reporting_value"),
|
||||||
|
strings._("invoice_required"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_row, invoice_id = selected
|
||||||
|
|
||||||
|
from .earnings import InvoiceReportingValueDialog
|
||||||
|
|
||||||
|
dlg = InvoiceReportingValueDialog(self._db, invoice_id, self)
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
|
def _on_payments_clicked(self) -> None:
|
||||||
|
selected = self._selected_invoice()
|
||||||
|
if selected is None:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
strings._("invoice_payments"),
|
||||||
|
strings._("invoice_required"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
row, invoice_id = selected
|
||||||
|
|
||||||
|
from .earnings import PaymentsDialog
|
||||||
|
|
||||||
|
dlg = PaymentsDialog(self._db, invoice_id, self)
|
||||||
|
dlg.paymentsChanged.connect(self.remindersChanged.emit)
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
|
invoice = self._db.get_invoice_with_project(invoice_id)
|
||||||
|
if invoice is not None and invoice["paid_at"] and self.cfg.reminders:
|
||||||
|
self._remove_invoice_due_reminder(row, invoice_id)
|
||||||
|
self._reload_invoices()
|
||||||
|
|
||||||
|
def _on_earnings_clicked(self) -> None:
|
||||||
|
from .earnings import EarningsReportDialog
|
||||||
|
|
||||||
|
EarningsReportDialog(self._db, self).exec()
|
||||||
|
|
||||||
def _current_project(self) -> int | None:
|
def _current_project(self) -> int | None:
|
||||||
idx = self.project_combo.currentIndex()
|
idx = self.project_combo.currentIndex()
|
||||||
|
|
@ -1371,6 +1451,15 @@ class InvoicesDialog(QDialog):
|
||||||
|
|
||||||
# ---- Dates: issue, due, paid_at (YYYY-MM-DD) ------------------------
|
# ---- Dates: issue, due, paid_at (YYYY-MM-DD) ------------------------
|
||||||
if col in (self.COL_ISSUE_DATE, self.COL_DUE_DATE, self.COL_PAID_AT):
|
if col in (self.COL_ISSUE_DATE, self.COL_DUE_DATE, self.COL_PAID_AT):
|
||||||
|
if col == self.COL_PAID_AT and self._db.get_invoice_payments(inv_id):
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
strings._("invoice_payments"),
|
||||||
|
strings._("invoice_paid_managed_by_payments"),
|
||||||
|
)
|
||||||
|
_reset_from_db("paid_at", lambda v: v or "")
|
||||||
|
return
|
||||||
|
|
||||||
new_date: QDate | None = None
|
new_date: QDate | None = None
|
||||||
if text:
|
if text:
|
||||||
new_date = QDate.fromString(text, "yyyy-MM-dd")
|
new_date = QDate.fromString(text, "yyyy-MM-dd")
|
||||||
|
|
|
||||||
|
|
@ -486,5 +486,49 @@
|
||||||
"project_changelog_type_invoice": "Invoice",
|
"project_changelog_type_invoice": "Invoice",
|
||||||
"project_changelog_type_bucket": "Bucket",
|
"project_changelog_type_bucket": "Bucket",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
"details": "Details"
|
"details": "Details",
|
||||||
|
"reporting_currency": "Reporting currency",
|
||||||
|
"invoice_payments": "Payments…",
|
||||||
|
"invoice_payments_title": "Payments — {invoice} ({project})",
|
||||||
|
"invoice_payments_summary": "Invoice total: {total:,.2f} {currency}",
|
||||||
|
"invoice_payment_received_on": "Received on",
|
||||||
|
"invoice_payment_applied_amount": "Invoice amount applied",
|
||||||
|
"invoice_payment_reporting_amount": "Receipt value",
|
||||||
|
"invoice_payment_reporting_help": "Record the receipt's value in your reporting currency. This is used by Payment date reports and for settlement/FX reconciliation; Invoice date reports use the invoice's separate Reporting value instead.",
|
||||||
|
"invoice_payment_record": "Record payment",
|
||||||
|
"invoice_payment_exchange_rate": "Effective rate",
|
||||||
|
"invoice_payment_outstanding": "Outstanding: {amount:,.2f} {currency}",
|
||||||
|
"invoice_payment_amount_required": "Both the invoice amount applied and receipt value must be greater than zero, and a reporting currency is required.",
|
||||||
|
"invoice_payment_delete_confirm": "Delete the selected payment record?",
|
||||||
|
"earnings_report": "Earnings report…",
|
||||||
|
"earnings_report_help": "Invoice-date mode groups the full value of each invoice by its issue date. Same-currency invoices use their invoice totals automatically; foreign-currency invoices use the structured Reporting value stored on the invoice. Payment dates and bank receipt values do not affect this view.",
|
||||||
|
"earnings_this_quarter": "This quarter",
|
||||||
|
"earnings_previous_quarter": "Previous quarter",
|
||||||
|
"earnings_month": "Month",
|
||||||
|
"earnings_sales_ex_tax": "Sales excl. tax",
|
||||||
|
"earnings_tax": "Tax",
|
||||||
|
"earnings_sales_inc_tax": "Sales incl. tax",
|
||||||
|
"earnings_payments": "Payments",
|
||||||
|
"earnings_totals": "Total sales excl. tax: {ex_tax} {currency} Tax: {tax} {currency} Total sales incl. tax: {inc_tax} {currency}",
|
||||||
|
"earnings_no_data": "No reportable sales in this period.",
|
||||||
|
"earnings_invalid_range": "The end date cannot be earlier than the start date.",
|
||||||
|
"earnings_currency_required": "A reporting currency is required.",
|
||||||
|
"earnings_unstructured_warning": "{count} invoice(s) are marked paid in this period but have no structured payment record, so they are not included. Open Payments… for those invoices to backfill the received amount.",
|
||||||
|
"earnings_other_currency_warning": "{count} payment(s) use a reporting currency other than {currency} and are not included.",
|
||||||
|
"invoice_paid_managed_by_payments": "Paid on is managed automatically once structured payment records exist. Open Payments… to add, correct, or remove receipts.",
|
||||||
|
"invoice_reporting_value": "Reporting value…",
|
||||||
|
"invoice_reporting_value_title": "Reporting value — {invoice} ({project})",
|
||||||
|
"invoice_reporting_value_summary": "Invoice date: {issue_date} Invoice total: {total:,.2f} {currency}",
|
||||||
|
"invoice_reporting_total": "Invoice-date reporting total",
|
||||||
|
"invoice_reporting_note": "Rate / source note",
|
||||||
|
"invoice_reporting_value_help": "For a foreign-currency invoice, record the value of the whole invoice in your reporting currency using the valuation date/rate required by your accounting rules. Invoice-date earnings reports use this value and ignore later receipt values. Same-currency invoices need no manual valuation.",
|
||||||
|
"invoice_reporting_value_required": "A reporting currency and reporting total greater than zero are required.",
|
||||||
|
"earnings_basis": "Recognition basis",
|
||||||
|
"earnings_basis_invoice": "Invoice date",
|
||||||
|
"earnings_basis_payment": "Payment date",
|
||||||
|
"earnings_invoices": "Invoices",
|
||||||
|
"earnings_report_help_invoice": "Invoice-date mode groups the full value of each invoice by its issue date. Same-currency invoices use their invoice totals automatically; foreign-currency invoices use the structured Reporting value stored on the invoice. Payment dates and bank receipt values do not affect this view.",
|
||||||
|
"earnings_report_help_payment": "Payment-date mode groups structured receipts by the date each payment was received. Use this for cash-style reporting or cash-flow analysis; partial payments can fall into different periods.",
|
||||||
|
"earnings_missing_invoice_values": "{count} foreign-currency invoice(s) in this period do not have a {currency} invoice-date reporting value and are not included. Select each invoice in Manage Invoices and use Reporting value… to backfill it.",
|
||||||
|
"invoice_reporting_value_clear": "Clear reporting value"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ def load_db_config() -> DBConfig:
|
||||||
reminders_webhook_secret = s.value("ui/reminders_webhook_secret", None, type=str)
|
reminders_webhook_secret = s.value("ui/reminders_webhook_secret", None, type=str)
|
||||||
documents = s.value("ui/documents", True, type=bool)
|
documents = s.value("ui/documents", True, type=bool)
|
||||||
invoicing = s.value("ui/invoicing", False, type=bool)
|
invoicing = s.value("ui/invoicing", False, type=bool)
|
||||||
|
reporting_currency = s.value("ui/reporting_currency", "AUD", type=str)
|
||||||
locale = s.value("ui/locale", "en", type=str)
|
locale = s.value("ui/locale", "en", type=str)
|
||||||
font_size = s.value("ui/font_size", 11, type=int)
|
font_size = s.value("ui/font_size", 11, type=int)
|
||||||
return DBConfig(
|
return DBConfig(
|
||||||
|
|
@ -68,6 +69,7 @@ def load_db_config() -> DBConfig:
|
||||||
reminders_webhook_secret=reminders_webhook_secret,
|
reminders_webhook_secret=reminders_webhook_secret,
|
||||||
documents=documents,
|
documents=documents,
|
||||||
invoicing=invoicing,
|
invoicing=invoicing,
|
||||||
|
reporting_currency=reporting_currency,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
font_size=font_size,
|
font_size=font_size,
|
||||||
)
|
)
|
||||||
|
|
@ -88,5 +90,6 @@ def save_db_config(cfg: DBConfig) -> None:
|
||||||
s.setValue("ui/reminders_webhook_secret", str(cfg.reminders_webhook_secret))
|
s.setValue("ui/reminders_webhook_secret", str(cfg.reminders_webhook_secret))
|
||||||
s.setValue("ui/documents", str(cfg.documents))
|
s.setValue("ui/documents", str(cfg.documents))
|
||||||
s.setValue("ui/invoicing", str(cfg.invoicing))
|
s.setValue("ui/invoicing", str(cfg.invoicing))
|
||||||
|
s.setValue("ui/reporting_currency", str(cfg.reporting_currency))
|
||||||
s.setValue("ui/locale", str(cfg.locale))
|
s.setValue("ui/locale", str(cfg.locale))
|
||||||
s.setValue("ui/font_size", str(cfg.font_size))
|
s.setValue("ui/font_size", str(cfg.font_size))
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,10 @@ class SettingsDialog(QDialog):
|
||||||
self.company_phone_edit = QLineEdit(phone or "")
|
self.company_phone_edit = QLineEdit(phone or "")
|
||||||
self.company_email_edit = QLineEdit(email or "")
|
self.company_email_edit = QLineEdit(email or "")
|
||||||
self.company_tax_id_edit = QLineEdit(tax_id or "")
|
self.company_tax_id_edit = QLineEdit(tax_id or "")
|
||||||
|
self.reporting_currency_edit = QLineEdit(
|
||||||
|
self.current_settings.reporting_currency or "AUD"
|
||||||
|
)
|
||||||
|
self.reporting_currency_edit.setMaxLength(8)
|
||||||
self.company_payment_details_edit = QTextEdit()
|
self.company_payment_details_edit = QTextEdit()
|
||||||
self.company_payment_details_edit.setPlainText(payment_details or "")
|
self.company_payment_details_edit.setPlainText(payment_details or "")
|
||||||
|
|
||||||
|
|
@ -314,6 +318,9 @@ class SettingsDialog(QDialog):
|
||||||
invoicing_layout.addRow(
|
invoicing_layout.addRow(
|
||||||
strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit
|
strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit
|
||||||
)
|
)
|
||||||
|
invoicing_layout.addRow(
|
||||||
|
strings._("reporting_currency") + ":", self.reporting_currency_edit
|
||||||
|
)
|
||||||
invoicing_layout.addRow(
|
invoicing_layout.addRow(
|
||||||
strings._("invoice_company_payment_details") + ":",
|
strings._("invoice_company_payment_details") + ":",
|
||||||
self.company_payment_details_edit,
|
self.company_payment_details_edit,
|
||||||
|
|
@ -471,6 +478,9 @@ class SettingsDialog(QDialog):
|
||||||
invoicing=(
|
invoicing=(
|
||||||
self.invoicing.isChecked() if self.time_log.isChecked() else False
|
self.invoicing.isChecked() if self.time_log.isChecked() else False
|
||||||
),
|
),
|
||||||
|
reporting_currency=(
|
||||||
|
self.reporting_currency_edit.text().strip().upper() or "AUD"
|
||||||
|
),
|
||||||
locale=self.locale_combobox.currentText(),
|
locale=self.locale_combobox.currentText(),
|
||||||
font_size=self.font_size.value(),
|
font_size=self.font_size.value(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1080,6 +1080,9 @@ class TimeReportDialog(QDialog):
|
||||||
self.manage_invoices_btn = QPushButton(strings._("manage_invoices"))
|
self.manage_invoices_btn = QPushButton(strings._("manage_invoices"))
|
||||||
self.manage_invoices_btn.clicked.connect(self._on_manage_invoices)
|
self.manage_invoices_btn.clicked.connect(self._on_manage_invoices)
|
||||||
|
|
||||||
|
self.earnings_btn = QPushButton(strings._("earnings_report"))
|
||||||
|
self.earnings_btn.clicked.connect(self._on_earnings_report)
|
||||||
|
|
||||||
# Project
|
# Project
|
||||||
self.project_combo = QComboBox()
|
self.project_combo = QComboBox()
|
||||||
self.project_combo.addItem(strings._("all_projects"), None)
|
self.project_combo.addItem(strings._("all_projects"), None)
|
||||||
|
|
@ -1153,6 +1156,7 @@ class TimeReportDialog(QDialog):
|
||||||
if getattr(self._db.cfg, "invoicing", False):
|
if getattr(self._db.cfg, "invoicing", False):
|
||||||
run_row.addWidget(self.invoice_btn)
|
run_row.addWidget(self.invoice_btn)
|
||||||
run_row.addWidget(self.manage_invoices_btn)
|
run_row.addWidget(self.manage_invoices_btn)
|
||||||
|
run_row.addWidget(self.earnings_btn)
|
||||||
root.addLayout(run_row)
|
root.addLayout(run_row)
|
||||||
|
|
||||||
# Table
|
# Table
|
||||||
|
|
@ -1731,6 +1735,11 @@ class TimeReportDialog(QDialog):
|
||||||
|
|
||||||
dlg.exec()
|
dlg.exec()
|
||||||
|
|
||||||
|
def _on_earnings_report(self) -> None:
|
||||||
|
from .earnings import EarningsReportDialog
|
||||||
|
|
||||||
|
EarningsReportDialog(self._db, self).exec()
|
||||||
|
|
||||||
def _on_create_invoice(self) -> None:
|
def _on_create_invoice(self) -> None:
|
||||||
idx = self.project_combo.currentIndex()
|
idx = self.project_combo.currentIndex()
|
||||||
if idx < 0:
|
if idx < 0:
|
||||||
|
|
|
||||||
173
tests/test_earnings.py
Normal file
173
tests/test_earnings.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
from bouquin.earnings import (
|
||||||
|
aggregate_invoices_by_month,
|
||||||
|
aggregate_payments_by_month,
|
||||||
|
invoice_reporting_amount_cents,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _invoice(
|
||||||
|
db,
|
||||||
|
project_id,
|
||||||
|
number,
|
||||||
|
currency="AUD",
|
||||||
|
tax_rate=10.0,
|
||||||
|
issue_date="2026-04-01",
|
||||||
|
):
|
||||||
|
return db.create_invoice(
|
||||||
|
project_id=project_id,
|
||||||
|
invoice_number=number,
|
||||||
|
issue_date=issue_date,
|
||||||
|
due_date=issue_date,
|
||||||
|
currency=currency,
|
||||||
|
tax_label="GST" if tax_rate else None,
|
||||||
|
tax_rate_percent=tax_rate,
|
||||||
|
detail_mode="summary",
|
||||||
|
line_items=[("Consulting", 10.0, 1000)],
|
||||||
|
time_log_ids=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_payments_mark_invoice_paid_only_when_fully_settled(fresh_db):
|
||||||
|
project_id = fresh_db.add_project("Client A")
|
||||||
|
invoice_id = _invoice(fresh_db, project_id, "INV-1")
|
||||||
|
|
||||||
|
first = fresh_db.add_invoice_payment(
|
||||||
|
invoice_id,
|
||||||
|
received_at="2026-04-15",
|
||||||
|
invoice_amount_cents=5500,
|
||||||
|
reporting_currency="AUD",
|
||||||
|
reporting_amount_cents=5500,
|
||||||
|
note="First half",
|
||||||
|
)
|
||||||
|
assert first > 0
|
||||||
|
assert fresh_db.get_invoice_with_project(invoice_id)["paid_at"] is None
|
||||||
|
|
||||||
|
second = fresh_db.add_invoice_payment(
|
||||||
|
invoice_id,
|
||||||
|
received_at="2026-05-03",
|
||||||
|
invoice_amount_cents=5500,
|
||||||
|
reporting_currency="AUD",
|
||||||
|
reporting_amount_cents=5500,
|
||||||
|
note="Balance",
|
||||||
|
)
|
||||||
|
assert second > first
|
||||||
|
assert fresh_db.get_invoice_with_project(invoice_id)["paid_at"] == "2026-05-03"
|
||||||
|
|
||||||
|
fresh_db.delete_invoice_payment(second)
|
||||||
|
assert fresh_db.get_invoice_with_project(invoice_id)["paid_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_invoice_basis_uses_invoice_date_value_not_later_bank_receipt(fresh_db):
|
||||||
|
project_id = fresh_db.add_project("Foreign Client")
|
||||||
|
invoice_id = _invoice(fresh_db, project_id, "USD-1", currency="USD")
|
||||||
|
|
||||||
|
# Value of the invoice in AUD on the invoice-date conversion basis.
|
||||||
|
fresh_db.set_invoice_reporting_value(
|
||||||
|
invoice_id,
|
||||||
|
reporting_currency="AUD",
|
||||||
|
reporting_total_cents=16500,
|
||||||
|
note="Invoice-date FX rate",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The eventual bank receipt is later and a different AUD amount.
|
||||||
|
fresh_db.add_invoice_payment(
|
||||||
|
invoice_id,
|
||||||
|
received_at="2026-05-20",
|
||||||
|
invoice_amount_cents=11000,
|
||||||
|
reporting_currency="AUD",
|
||||||
|
reporting_amount_cents=17000,
|
||||||
|
note="Actual receipt",
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_rows = fresh_db.get_invoices_for_earnings_range("2026-04-01", "2026-06-30")
|
||||||
|
monthly = aggregate_invoices_by_month(
|
||||||
|
invoice_rows, "AUD", "2026-04-01", "2026-06-30"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert monthly[0].month == "2026-04"
|
||||||
|
assert monthly[0].sales_inc_tax_cents == 16500
|
||||||
|
assert monthly[0].tax_cents == 1500
|
||||||
|
assert monthly[0].sales_ex_tax_cents == 15000
|
||||||
|
assert monthly[0].entry_count == 1
|
||||||
|
assert monthly[1].sales_inc_tax_cents == 0
|
||||||
|
|
||||||
|
payment_rows = fresh_db.get_payments_for_range("2026-04-01", "2026-06-30")
|
||||||
|
payment_monthly = aggregate_payments_by_month(
|
||||||
|
payment_rows, "2026-04-01", "2026-06-30"
|
||||||
|
)
|
||||||
|
assert payment_monthly[0].sales_inc_tax_cents == 0
|
||||||
|
assert payment_monthly[1].sales_inc_tax_cents == 17000
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_currency_invoice_needs_no_manual_reporting_value(fresh_db):
|
||||||
|
project_id = fresh_db.add_project("Local Client")
|
||||||
|
_invoice(fresh_db, project_id, "AUD-1", currency="AUD")
|
||||||
|
|
||||||
|
rows = fresh_db.get_invoices_for_earnings_range("2026-04-01", "2026-04-30")
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["reporting_total_cents"] is None
|
||||||
|
assert invoice_reporting_amount_cents(rows[0], "AUD") == 11000
|
||||||
|
|
||||||
|
monthly = aggregate_invoices_by_month(rows, "AUD", "2026-04-01", "2026-04-30")
|
||||||
|
assert monthly[0].sales_inc_tax_cents == 11000
|
||||||
|
assert monthly[0].tax_cents == 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_invoices_are_aggregated_by_issue_month(fresh_db):
|
||||||
|
project_id = fresh_db.add_project("Client B")
|
||||||
|
_invoice(
|
||||||
|
fresh_db,
|
||||||
|
project_id,
|
||||||
|
"INV-APR",
|
||||||
|
tax_rate=None,
|
||||||
|
issue_date="2026-04-30",
|
||||||
|
)
|
||||||
|
_invoice(
|
||||||
|
fresh_db,
|
||||||
|
project_id,
|
||||||
|
"INV-JUN",
|
||||||
|
tax_rate=None,
|
||||||
|
issue_date="2026-06-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = fresh_db.get_invoices_for_earnings_range("2026-04-01", "2026-06-30")
|
||||||
|
monthly = aggregate_invoices_by_month(rows, "AUD", "2026-04-01", "2026-06-30")
|
||||||
|
|
||||||
|
assert [r.sales_inc_tax_cents for r in monthly] == [10000, 0, 10000]
|
||||||
|
assert [r.entry_count for r in monthly] == [1, 0, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_foreign_invoice_without_reporting_value_is_not_guessed(fresh_db):
|
||||||
|
project_id = fresh_db.add_project("Foreign Client")
|
||||||
|
_invoice(fresh_db, project_id, "USD-MISSING", currency="USD")
|
||||||
|
|
||||||
|
rows = fresh_db.get_invoices_for_earnings_range("2026-04-01", "2026-04-30")
|
||||||
|
assert invoice_reporting_amount_cents(rows[0], "AUD") is None
|
||||||
|
|
||||||
|
monthly = aggregate_invoices_by_month(rows, "AUD", "2026-04-01", "2026-04-30")
|
||||||
|
assert monthly[0].sales_inc_tax_cents == 0
|
||||||
|
assert monthly[0].entry_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_reporting_value_is_invalidated_when_invoice_date_changes(fresh_db):
|
||||||
|
project_id = fresh_db.add_project("Foreign Client")
|
||||||
|
invoice_id = _invoice(fresh_db, project_id, "USD-EDIT", currency="USD")
|
||||||
|
fresh_db.set_invoice_reporting_value(invoice_id, "AUD", 16500, "RBA")
|
||||||
|
|
||||||
|
fresh_db.set_invoice_field_by_id(invoice_id, "issue_date", "2026-04-02")
|
||||||
|
invoice = fresh_db.get_invoice_with_project(invoice_id)
|
||||||
|
assert invoice["reporting_currency"] is None
|
||||||
|
assert invoice["reporting_total_cents"] is None
|
||||||
|
assert invoice["reporting_note"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_paid_invoice_is_reported_as_unstructured_for_payment_basis(fresh_db):
|
||||||
|
project_id = fresh_db.add_project("Legacy Client")
|
||||||
|
invoice_id = _invoice(fresh_db, project_id, "LEGACY-1")
|
||||||
|
fresh_db.set_invoice_field_by_id(invoice_id, "paid_at", "2026-04-22")
|
||||||
|
fresh_db.set_invoice_field_by_id(invoice_id, "payment_note", "Received AUD 110.00")
|
||||||
|
|
||||||
|
missing = fresh_db.get_paid_invoices_without_payments("2026-04-01", "2026-06-30")
|
||||||
|
|
||||||
|
assert len(missing) == 1
|
||||||
|
assert missing[0]["invoice_number"] == "LEGACY-1"
|
||||||
|
|
@ -14,6 +14,7 @@ def _clear_db_settings():
|
||||||
"ui/tags",
|
"ui/tags",
|
||||||
"ui/time_log",
|
"ui/time_log",
|
||||||
"ui/reminders",
|
"ui/reminders",
|
||||||
|
"ui/reporting_currency",
|
||||||
"ui/locale",
|
"ui/locale",
|
||||||
"ui/font_size",
|
"ui/font_size",
|
||||||
]:
|
]:
|
||||||
|
|
@ -32,6 +33,7 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
|
||||||
tags=True,
|
tags=True,
|
||||||
time_log=True,
|
time_log=True,
|
||||||
reminders=True,
|
reminders=True,
|
||||||
|
reporting_currency="NZD",
|
||||||
locale="en",
|
locale="en",
|
||||||
font_size=11,
|
font_size=11,
|
||||||
)
|
)
|
||||||
|
|
@ -46,6 +48,7 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
|
||||||
assert loaded.tags == cfg.tags
|
assert loaded.tags == cfg.tags
|
||||||
assert loaded.time_log == cfg.time_log
|
assert loaded.time_log == cfg.time_log
|
||||||
assert loaded.reminders == cfg.reminders
|
assert loaded.reminders == cfg.reminders
|
||||||
|
assert loaded.reporting_currency == cfg.reporting_currency
|
||||||
assert loaded.locale == cfg.locale
|
assert loaded.locale == cfg.locale
|
||||||
assert loaded.font_size == cfg.font_size
|
assert loaded.font_size == cfg.font_size
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue