From ebdf29cdbd0f3a4e404229360839940b96bf42a0 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 25 Aug 2026 16:53:28 +1000 Subject: [PATCH] New 'Earnings' interface for viewing/tracking earnings across reporting periods (e.g for BAS) --- CHANGELOG.md | 4 + bouquin/db.py | 302 ++++++++++++ bouquin/earnings.py | 920 +++++++++++++++++++++++++++++++++++++ bouquin/invoices.py | 95 +++- bouquin/locales/en.json | 46 +- bouquin/settings.py | 3 + bouquin/settings_dialog.py | 10 + bouquin/time_log.py | 9 + tests/test_earnings.py | 173 +++++++ tests/test_settings.py | 3 + 10 files changed, 1561 insertions(+), 4 deletions(-) create mode 100644 bouquin/earnings.py create mode 100644 tests/test_earnings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 19290cb..4f88ee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.10.0 + + * New 'Earnings' interface for viewing/tracking earnings across reporting periods (e.g for BAS) + # 0.9.0 * Add 'Projects' interface for unified time/invoice/docs view. diff --git a/bouquin/db.py b/bouquin/db.py index 35888a9..f0e1d6d 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -100,6 +100,7 @@ class DBConfig: reminders_webhook_secret: str = (None,) documents: bool = True invoicing: bool = False + reporting_currency: str = "AUD" locale: str = "en" font_size: int = 11 @@ -120,6 +121,9 @@ class DBManager: "detail_mode", "paid_at", "payment_note", + "reporting_currency", + "reporting_total_cents", + "reporting_note", "document_id", } ) @@ -369,6 +373,9 @@ class DBManager: detail_mode TEXT NOT NULL, -- 'detailed' | 'summary' paid_at TEXT, payment_note TEXT, + reporting_currency TEXT, + reporting_total_cents INTEGER, + reporting_note TEXT, document_id INTEGER, created_at TEXT NOT NULL DEFAULT ( strftime('%Y-%m-%dT%H:%M:%fZ','now') @@ -401,6 +408,25 @@ class DBManager: REFERENCES time_log(id) ON DELETE RESTRICT, 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( @@ -408,6 +434,21 @@ class DBManager: "created_at", "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() def _ensure_column(self, table: str, column: str, definition: str) -> None: @@ -2924,6 +2965,252 @@ class DBManager: ).fetchall() 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: if field not in self._INVOICE_COLUMN_ALLOWLIST: raise ValueError(f"Invalid invoice field name: {field!r}") @@ -2952,6 +3239,21 @@ class DBManager: 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: with self.conn: diff --git a/bouquin/earnings.py b/bouquin/earnings.py new file mode 100644 index 0000000..2643aac --- /dev/null +++ b/bouquin/earnings.py @@ -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, + ] + ) diff --git a/bouquin/invoices.py b/bouquin/invoices.py index 45cfe59..d1f8947 100644 --- a/bouquin/invoices.py +++ b/bouquin/invoices.py @@ -1071,6 +1071,18 @@ class InvoicesDialog(QDialog): delete_btn.clicked.connect(self._on_delete_clicked) 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.clicked.connect(self.accept) btn_row.addWidget(close_btn) @@ -1148,14 +1160,21 @@ class InvoicesDialog(QDialog): self.project_combo.blockSignals(True) try: 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) finally: self.project_combo.blockSignals(False) def _select_initial_project(self, project_id: int | None) -> 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) return @@ -1163,7 +1182,68 @@ class InvoicesDialog(QDialog): if idx >= 0: self.project_combo.setCurrentIndex(idx) 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: idx = self.project_combo.currentIndex() @@ -1371,6 +1451,15 @@ class InvoicesDialog(QDialog): # ---- 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 == 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 if text: new_date = QDate.fromString(text, "yyyy-MM-dd") diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index b3cd3f9..4045476 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -486,5 +486,49 @@ "project_changelog_type_invoice": "Invoice", "project_changelog_type_bucket": "Bucket", "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" } diff --git a/bouquin/settings.py b/bouquin/settings.py index fde863d..90acf9b 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -52,6 +52,7 @@ def load_db_config() -> DBConfig: reminders_webhook_secret = s.value("ui/reminders_webhook_secret", None, type=str) documents = s.value("ui/documents", True, 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) font_size = s.value("ui/font_size", 11, type=int) return DBConfig( @@ -68,6 +69,7 @@ def load_db_config() -> DBConfig: reminders_webhook_secret=reminders_webhook_secret, documents=documents, invoicing=invoicing, + reporting_currency=reporting_currency, locale=locale, 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/documents", str(cfg.documents)) 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/font_size", str(cfg.font_size)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 3e1213c..085c42d 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -296,6 +296,10 @@ class SettingsDialog(QDialog): self.company_phone_edit = QLineEdit(phone or "") self.company_email_edit = QLineEdit(email 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.setPlainText(payment_details or "") @@ -314,6 +318,9 @@ class SettingsDialog(QDialog): invoicing_layout.addRow( strings._("invoice_company_tax_id") + ":", self.company_tax_id_edit ) + invoicing_layout.addRow( + strings._("reporting_currency") + ":", self.reporting_currency_edit + ) invoicing_layout.addRow( strings._("invoice_company_payment_details") + ":", self.company_payment_details_edit, @@ -471,6 +478,9 @@ class SettingsDialog(QDialog): invoicing=( 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(), font_size=self.font_size.value(), ) diff --git a/bouquin/time_log.py b/bouquin/time_log.py index eaee522..cf3c61a 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -1080,6 +1080,9 @@ class TimeReportDialog(QDialog): self.manage_invoices_btn = QPushButton(strings._("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 self.project_combo = QComboBox() self.project_combo.addItem(strings._("all_projects"), None) @@ -1153,6 +1156,7 @@ class TimeReportDialog(QDialog): if getattr(self._db.cfg, "invoicing", False): run_row.addWidget(self.invoice_btn) run_row.addWidget(self.manage_invoices_btn) + run_row.addWidget(self.earnings_btn) root.addLayout(run_row) # Table @@ -1731,6 +1735,11 @@ class TimeReportDialog(QDialog): dlg.exec() + def _on_earnings_report(self) -> None: + from .earnings import EarningsReportDialog + + EarningsReportDialog(self._db, self).exec() + def _on_create_invoice(self) -> None: idx = self.project_combo.currentIndex() if idx < 0: diff --git a/tests/test_earnings.py b/tests/test_earnings.py new file mode 100644 index 0000000..0f67c8f --- /dev/null +++ b/tests/test_earnings.py @@ -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" diff --git a/tests/test_settings.py b/tests/test_settings.py index 086d590..5874403 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -14,6 +14,7 @@ def _clear_db_settings(): "ui/tags", "ui/time_log", "ui/reminders", + "ui/reporting_currency", "ui/locale", "ui/font_size", ]: @@ -32,6 +33,7 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path): tags=True, time_log=True, reminders=True, + reporting_currency="NZD", locale="en", 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.time_log == cfg.time_log assert loaded.reminders == cfg.reminders + assert loaded.reporting_currency == cfg.reporting_currency assert loaded.locale == cfg.locale assert loaded.font_size == cfg.font_size