New 'Earnings' interface for viewing/tracking earnings across reporting periods (e.g for BAS)

This commit is contained in:
Miguel Jacq 2026-08-25 16:53:28 +10:00
parent 3361537a59
commit ebdf29cdbd
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
10 changed files with 1561 additions and 4 deletions

View file

@ -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.

View file

@ -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
View 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,
]
)

View file

@ -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")

View file

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

View file

@ -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))

View file

@ -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(),
) )

View file

@ -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
View 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"

View file

@ -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