From 10e7285612c21eb02bba5889749fe07d4f1d163d Mon Sep 17 00:00:00 2001 From: Christian Staudt <875194+clstaudt@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:42:31 +0200 Subject: [PATCH 1/5] Allow only one final invoice per project A second final invoice for a project went through, because the check for an existing settlement relied on a data-source method that was never implemented. The new final invoice took over the deposits, so the first one's balance jumped back to the full price, and revenue counted the contract twice. A project now knows it is settled while it has a final invoice that is not cancelled. Creating another final invoice is refused with a message that names the existing one and says to cancel it first, also on fixed-price contracts without deposits. The invoice dialog no longer offers Final for a settled project. An invoice now carries only the id and title of its project, so serialising it does not need the project's invoices. Co-Authored-By: Claude Opus 5.5 --- tuttle/app/invoicing/data_source.py | 22 --- tuttle/app/invoicing/intent.py | 29 +-- tuttle/model.py | 19 +- .../test_one_final_invoice_per_project.py | 177 ++++++++++++++++++ ui/src/components/invoicing/InvoicingView.tsx | 9 +- 5 files changed, 208 insertions(+), 48 deletions(-) create mode 100644 tuttle_tests/test_one_final_invoice_per_project.py diff --git a/tuttle/app/invoicing/data_source.py b/tuttle/app/invoicing/data_source.py index 17d480bb..dc6ed87c 100644 --- a/tuttle/app/invoicing/data_source.py +++ b/tuttle/app/invoicing/data_source.py @@ -15,28 +15,6 @@ class InvoicingDataSource(SQLModelDataSourceMixin): def __init__(self): super().__init__() - def get_invoices_for_project(self, project_id) -> IntentResult[List[Invoice]]: - """TODO Get all invoices associated with a given project - - Returns: - IntentResult: - was_intent_successful : bool - data : list[Invoice] was_intent_successful else None - log_message : str if an error or exception occurs - exception : Exception if an exception occurs - """ - try: - return IntentResult( - was_intent_successful=False, - log_message="NotImplementedError @InvoicingDataSource.get_invoices_for_project", - ) - except Exception as e: - return IntentResult( - was_intent_successful=False, - log_message=f"Exception raised @TimeTrackingDataSource.get_invoices_for_project {e.__class__.__name__}", - exception=e, - ) - def get_invoice_by_id(self, invoice_id: int) -> IntentResult[Optional[Invoice]]: """Fetch a single invoice by primary key.""" try: diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index dd19bbcd..2a31df8d 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -258,23 +258,19 @@ def create_final( was_intent_successful=False, error_msg="Final invoices require a fixed-price contract.", ) + final = project.final_invoice + if final is not None: + return IntentResult( + was_intent_successful=False, + error_msg=f"Final invoice {final.number} already settles this project. Cancel it to issue a new final invoice.", + ) + # The deposits of a cancelled final invoice are open to be settled again. deposits_result = self._invoicing_data_source.get_deposit_invoices(contract.id, project.id) if not deposits_result.was_intent_successful: return deposits_result deposit_invoices = deposits_result.data or [] - # A cancelled settlement is void, so its deposits are open to be settled again. - invoices = self.get_invoices_for_project_as_map(project.id) or {} - already_settled = [ - d for d in deposit_invoices if d.deposit_for_id in invoices and not invoices[d.deposit_for_id].cancelled - ] - if already_settled: - return IntentResult( - was_intent_successful=False, - error_msg="A final invoice already settles the deposits of this project.", - ) - try: invoice = invoicing.generate_final_invoice( contract=contract, @@ -420,17 +416,6 @@ def get_user(self) -> IntentResult[User]: def get_active_projects_as_map(self) -> Mapping[int, Project]: return self._projects_intent.get_active_as_map() - def get_invoices_for_project_as_map(self, project_id) -> IntentResult: - result = self._invoicing_data_source.get_invoices_for_project(project_id) - if result.was_intent_successful and result.data: - invoices_list = result.data - invoices_map = {invoice.id: invoice for invoice in invoices_list} - return invoices_map - else: - if not result.was_intent_successful: - result.log_message_if_any() - return {} - def get_all_invoices_as_map(self) -> Mapping[int, Invoice]: result = self._invoicing_data_source.get_all_invoices() if result.was_intent_successful: diff --git a/tuttle/model.py b/tuttle/model.py index 3832ffc3..17177861 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -914,7 +914,7 @@ class Project(RpcMixin, SQLModel, table=True): """A project is a group of contract work for a client.""" __rpc_relationships__ = ("contract",) - __rpc_computed__ = ("is_fixed_price",) + __rpc_computed__ = ("is_fixed_price", "is_settled") id: Optional[int] = Field(default=None, primary_key=True) title: str = Field( @@ -961,6 +961,19 @@ def __repr__(self): def is_fixed_price(self) -> bool: return self.contract is not None and self.contract.is_fixed_price + @property + def final_invoice(self) -> Optional["Invoice"]: + """The final invoice that settles this project, if any. + + A cancelled final invoice settles nothing, so it does not count. + """ + return next((inv for inv in self.invoices if inv.is_final_invoice and not inv.cancelled), None) + + @property + def is_settled(self) -> bool: + """Whether a final invoice settles the project. Only one may, or the contract is billed twice.""" + return self.final_invoice is not None + @property def client(self) -> Optional[Client]: if self.contract: @@ -1147,7 +1160,9 @@ class Invoice(RpcMixin, SQLModel, table=True): __rpc_relationships__ = { "contract": None, - "project": None, + # Only what the invoice list shows: a full dump would compute whether the + # project is settled from its invoices, which are not loaded with an invoice. + "project": ("id", "title"), "items": None, "milestone": ("id", "title"), "deposits": None, diff --git a/tuttle_tests/test_one_final_invoice_per_project.py b/tuttle_tests/test_one_final_invoice_per_project.py new file mode 100644 index 00000000..e686a847 --- /dev/null +++ b/tuttle_tests/test_one_final_invoice_per_project.py @@ -0,0 +1,177 @@ +"""A project is settled by one final invoice at a time. + +A second final invoice would bill the contract price again and take over the +deposits, so the first one's balance would jump back to the full price. +Cancelling the final invoice voids the settlement and allows a new one. +""" + +import datetime +from decimal import Decimal + +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + +from tuttle.app.core.abstractions import SQLModelDataSourceMixin +from tuttle.app.core.rpc_utils import dump +from tuttle.app.invoicing.intent import InvoicingIntent +from tuttle.app.projects.intent import ProjectsIntent +from tuttle.model import Address, Client, Contract, Invoice, PaymentMilestone, Project, User +from tuttle.time import ContractType, Cycle + +DATE = datetime.date(2026, 2, 1) + + +@pytest.fixture +def in_memory_db(monkeypatch): + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + monkeypatch.setattr(SQLModelDataSourceMixin, "create_session", lambda self: Session(engine)) + return engine + + +def _contract(*percentages: str) -> Contract: + return Contract( + term_of_payment=14, + title="Central Heating Overhaul", + client=Client(name="Sam Lowry"), + start_date=datetime.date(2026, 1, 10), + type=ContractType.fixed_price, + fixed_price=Decimal("10000"), + currency="EUR", + VAT_rate=Decimal("0.19"), + billing_cycle=Cycle.monthly, + payment_milestones=[ + PaymentMilestone(title=f"Instalment {position + 1}", percentage=Decimal(pct), position=position) + for position, pct in enumerate(percentages) + ], + ) + + +def _project(contract: Contract) -> Project: + return Project( + title="Heating Overhaul", + description="Ductwork", + tag="#overhaul", + contract=contract, + start_date=datetime.date(2026, 1, 10), + ) + + +def _persist(engine, *percentages: str) -> tuple[int, list[int]]: + """Persist a fixed-price project with one milestone per percentage.""" + with Session(engine) as session: + # Rendering the PDF after creation reads the user's preferences. + session.add( + User( + name="Harry Tuttle", + subtitle="Heating Engineer", + email="mail@tuttle.example", + address=Address(street="Main Street", number="450", postal_code="55555", city="Sao Paolo", country="Brazil"), + ) + ) + project = _project(_contract(*percentages)) + session.add(project) + session.commit() + return project.id, [m.id for m in project.contract.payment_milestones] + + +def _ok(result): + assert result.was_intent_successful, result.error_msg + return result.data + + +def _finals(engine) -> list[int]: + with Session(engine) as session: + return list(session.exec(select(Invoice.id).where(Invoice.document_type == "final")).all()) + + +class TestSecondFinalInvoice: + def test_is_refused_and_names_the_final_invoice_to_cancel(self, in_memory_db): + project_id, (first, _) = _persist(in_memory_db, "50", "50") + intent = InvoicingIntent() + _ok(intent.create_deposit(project_id, first, DATE)) + final = _ok(intent.create_final(project_id, DATE)) + + result = intent.create_final(project_id, DATE) + + assert not result.was_intent_successful + assert result.error_msg == ( + f"Final invoice {final.number} already settles this project. Cancel it to issue a new final invoice." + ) + assert _finals(in_memory_db) == [final.id] + + def test_leaves_the_first_settlement_untouched(self, in_memory_db): + project_id, (first, _) = _persist(in_memory_db, "50", "50") + intent = InvoicingIntent() + deposit = _ok(intent.create_deposit(project_id, first, DATE)) + final = _ok(intent.create_final(project_id, DATE)) + + intent.create_final(project_id, DATE) + + settled = _ok(intent._invoicing_data_source.get_invoice_by_id(final.id)) + assert [d.id for d in settled.deposits] == [deposit.id] + # 11,900 gross less the 5,950 deposit. + assert settled.remaining_balance == Decimal("5950.00") + + def test_is_refused_on_a_contract_without_deposits(self, in_memory_db): + project_id, _ = _persist(in_memory_db) + intent = InvoicingIntent() + final = _ok(intent.create_final(project_id, DATE)) + + result = intent.create_final(project_id, DATE) + + assert not result.was_intent_successful + assert f"Final invoice {final.number} already settles this project." in result.error_msg + assert _finals(in_memory_db) == [final.id] + + def test_is_allowed_once_the_first_is_cancelled(self, in_memory_db): + project_id, _ = _persist(in_memory_db) + intent = InvoicingIntent() + final = _ok(intent.create_final(project_id, DATE)) + _ok(intent.toggle_cancelled(final.id)) + + replacement = _ok(intent.create_final(project_id, DATE)) + + assert replacement.id != final.id + assert _ok(ProjectsIntent().get_by_id(project_id)).final_invoice.id == replacement.id + + +class TestProjectIsSettled: + @staticmethod + def _with(*documents: str) -> Project: + """A project with one invoice per document type, "cancelled final" for a cancelled one.""" + project = _project(_contract()) + project.invoices = [ + Invoice( + number=f"2026-{position + 1:02d}", + date=DATE, + document_type=kind.removeprefix("cancelled "), + cancelled=kind.startswith("cancelled "), + ) + for position, kind in enumerate(documents) + ] + return project + + def test_a_final_invoice_settles_the_project(self): + project = self._with("deposit", "final") + assert project.is_settled + assert project.final_invoice.number == "2026-02" + + def test_a_cancelled_final_invoice_does_not(self): + project = self._with("deposit", "cancelled final") + assert not project.is_settled + assert project.final_invoice is None + + def test_deposits_alone_do_not(self): + assert not self._with("deposit", "deposit").is_settled + + def test_the_project_list_tells_the_ui(self, in_memory_db): + project_id, _ = _persist(in_memory_db) + intent = InvoicingIntent() + assert dump(_ok(ProjectsIntent().get_all()))[0]["is_settled"] is False + + _ok(intent.create_final(project_id, DATE)) + + assert dump(_ok(ProjectsIntent().get_all()))[0]["is_settled"] is True + # An invoice carries its project too, and must serialise without the project's invoices. + assert [inv["project"] for inv in dump(_ok(intent.get_all()))] == [{"id": project_id, "title": "Heating Overhaul"}] diff --git a/ui/src/components/invoicing/InvoicingView.tsx b/ui/src/components/invoicing/InvoicingView.tsx index fd8ba953..f309d816 100644 --- a/ui/src/components/invoicing/InvoicingView.tsx +++ b/ui/src/components/invoicing/InvoicingView.tsx @@ -411,7 +411,9 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr const openMilestones = milestones.filter((m) => !bool(m, "invoiced")); const isLastOpenMilestone = openMilestones.length === 1 && selectedMilestoneId === openMilestones[0]?.id; - const canSettle = hasMilestones && openMilestones.length < milestones.length; + // A project is settled once: a second final invoice would bill the contract again. + const isSettled = selectedProject ? bool(selectedProject, "is_settled") : false; + const canSettle = hasMilestones && openMilestones.length < milestones.length && !isSettled; // The document type resets with the project, so a picker option that no // longer applies cannot survive into a submission. @@ -595,10 +597,13 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr {DOCUMENT_TYPE_OPTIONS.map(({ value, label, icon: Icon }) => { // Settling early is only meaningful once a deposit exists to deduct. const disabled = value === "final" && !canSettle; + const disabledReason = isSettled + ? "A final invoice already settles this project" + : "Invoice at least one milestone first"; return ( + + )} + {loadFailed ? ( load()} /> ) : loading && invoices.length === 0 ? ( From a414a39122c54a9ecfb4c616ecdbcae80f757c05 Mon Sep 17 00:00:00 2001 From: Christian Staudt <875194+clstaudt@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:55:54 +0200 Subject: [PATCH 3/5] Ask for the remaining balance in a final invoice's reminder and QR code A reminder copies the items of the invoice it chases. For a final invoice these state the full contract price, so its reminder asked for the whole price plus the fee instead of what the deposits left. Paying through such a reminder left the deposits unpaid, and the payment QR code of a final invoice also asked for the full price. A reminder for a final invoice now restates the final's settlement: the full price, the deposits deducted, then its fee. The model gives it the final's deductions, so its remaining balance, its PDF, the invoice view and the QR code agree. The QR code asks for the remaining balance, and is left out when nothing is due. Paying any document of a final invoice's reminder chain marks the deposits paid. Loading a reminder now loads its whole chain, which also lets the invoice list show a second reminder under its invoice. Co-Authored-By: Claude Opus 5.5 --- templates/_shared/document_type.html | 11 +- templates/invoice-bold/invoice.html | 2 +- templates/invoice-grayshades/invoice.html | 2 +- templates/invoice-minimal/invoice.html | 2 +- templates/invoice-modern/invoice.html | 2 +- tuttle/app/invoicing/data_source.py | 14 ++ tuttle/app/invoicing/intent.py | 15 +- tuttle/model.py | 41 +++- tuttle/rendering.py | 15 +- .../test_final_invoice_reminder_balance.py | 178 ++++++++++++++++++ ui/src/components/invoicing/InvoicingView.tsx | 20 +- 11 files changed, 269 insertions(+), 33 deletions(-) create mode 100644 tuttle_tests/test_final_invoice_reminder_balance.py diff --git a/templates/_shared/document_type.html b/templates/_shared/document_type.html index d6dbebfe..232955c0 100644 --- a/templates/_shared/document_type.html +++ b/templates/_shared/document_type.html @@ -47,15 +47,16 @@ {% endmacro %} {# - Settlement lines for a final invoice, as
rows. + Settlement lines for a final invoice, or a reminder for one, as
rows. - `line` and `amount` name the template's own classes for a totals row and its - amount cell, so each skin keeps its own typography. + The gross amount leaves out a reminder fee, which templates list after the + deductions. `line` and `amount` name the template's own classes for a totals + row and its amount cell, so each skin keeps its own typography. #} {% macro settlement_lines_div(invoice, deposit_deductions, line="totals-line", amount="totals-amount", label="totals-label") %}
{{ _("Gross") }} - {{ invoice.total | as_currency }} + {{ (invoice.sum + invoice.VAT_total) | as_currency }}
{%- for dep in deposit_deductions %}
@@ -73,7 +74,7 @@ {% macro settlement_lines_table(invoice, deposit_deductions, label="totals-label", value="totals-value") %} {{ _("Gross") }} - {{ invoice.total | as_currency }} + {{ (invoice.sum + invoice.VAT_total) | as_currency }} {%- for dep in deposit_deductions %} diff --git a/templates/invoice-bold/invoice.html b/templates/invoice-bold/invoice.html index 2f1a1e98..cd5dcc3b 100644 --- a/templates/invoice-bold/invoice.html +++ b/templates/invoice-bold/invoice.html @@ -134,13 +134,13 @@ {{ invoice.VAT_total | as_currency }}
{% endif %} + {% if is_final or deposit_deductions %}{{ doc.settlement_lines_div(invoice, deposit_deductions) }}{% endif %} {% if is_reminder and invoice.reminder_fee %}
{{ _("Reminder Fee") }} {{ invoice.reminder_fee | as_currency }}
{% endif %} - {% if is_final %}{{ doc.settlement_lines_div(invoice, deposit_deductions) }}{% endif %}
{{ doc.amount_due_label(is_deposit, is_final) }} {{ remaining_balance | as_currency }} diff --git a/templates/invoice-grayshades/invoice.html b/templates/invoice-grayshades/invoice.html index d92c3d9f..e7767629 100644 --- a/templates/invoice-grayshades/invoice.html +++ b/templates/invoice-grayshades/invoice.html @@ -100,13 +100,13 @@

{{ _("Invoice No.") }} {{ invoice.number }}{% if is_de {{ invoice.VAT_total | as_currency }} {% endif %} + {% if is_final or deposit_deductions %}{{ doc.settlement_lines_table(invoice, deposit_deductions) }}{% endif %} {% if is_reminder and invoice.reminder_fee %} {{ _("Reminder Fee") }} {{ invoice.reminder_fee | as_currency }} {% endif %} - {% if is_final %}{{ doc.settlement_lines_table(invoice, deposit_deductions) }}{% endif %} {{ doc.amount_due_label(is_deposit, is_final) }} (€) {{ remaining_balance | as_currency }} diff --git a/templates/invoice-minimal/invoice.html b/templates/invoice-minimal/invoice.html index 9c902a0a..759a0baa 100644 --- a/templates/invoice-minimal/invoice.html +++ b/templates/invoice-minimal/invoice.html @@ -119,13 +119,13 @@ {{ invoice.VAT_total | as_currency }}

{% endif %} + {% if is_final or deposit_deductions %}{{ doc.settlement_lines_div(invoice, deposit_deductions, amount="totals-value") }}{% endif %} {% if is_reminder and invoice.reminder_fee %}
{{ _("Reminder Fee") }} {{ invoice.reminder_fee | as_currency }}
{% endif %} - {% if is_final %}{{ doc.settlement_lines_div(invoice, deposit_deductions, amount="totals-value") }}{% endif %}
{{ doc.amount_due_label(is_deposit, is_final) }} diff --git a/templates/invoice-modern/invoice.html b/templates/invoice-modern/invoice.html index d4b0df04..f1e0ef1a 100644 --- a/templates/invoice-modern/invoice.html +++ b/templates/invoice-modern/invoice.html @@ -131,13 +131,13 @@ {{ invoice.VAT_total | as_currency }}
{% endif %} + {% if is_final or deposit_deductions %}{{ doc.settlement_lines_div(invoice, deposit_deductions) }}{% endif %} {% if is_reminder and invoice.reminder_fee %}
{{ _("Reminder Fee") }} {{ invoice.reminder_fee | as_currency }}
{% endif %} - {% if is_final %}{{ doc.settlement_lines_div(invoice, deposit_deductions) }}{% endif %}
{{ doc.amount_due_label(is_deposit, is_final) }} diff --git a/tuttle/app/invoicing/data_source.py b/tuttle/app/invoicing/data_source.py index dc6ed87c..3ae3e9a8 100644 --- a/tuttle/app/invoicing/data_source.py +++ b/tuttle/app/invoicing/data_source.py @@ -15,6 +15,20 @@ class InvoicingDataSource(SQLModelDataSourceMixin): def __init__(self): super().__init__() + @staticmethod + def _hydrate(entities, _depth: int = 2): + """Also load the settlement that a reminder of a final invoice restates. + + Its figures come from the final invoice at the head of its reminder + chain and from the deposits deducted there, which the generic + hydration does not reach. + """ + SQLModelDataSourceMixin._hydrate(entities, _depth) + for invoice in entities: + if isinstance(invoice, Invoice) and invoice.is_reminder: + # Reading the deductions walks the chain and loads what they need. + invoice.deposit_deductions + def get_invoice_by_id(self, invoice_id: int) -> IntentResult[Optional[Invoice]]: """Fetch a single invoice by primary key.""" try: diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index 3cebb199..2c50d82a 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -974,8 +974,9 @@ def toggle_invoice_paid_status(self, invoice: Invoice) -> IntentResult[Invoice]: try: new_paid = not invoice.paid chain_result = self._invoicing_data_source.get_reminder_chain(invoice.id) - if chain_result.was_intent_successful and chain_result.data: - for inv in chain_result.data: + chain = chain_result.data if chain_result.was_intent_successful and chain_result.data else [] + if chain: + for inv in chain: fresh = self._invoicing_data_source.get_invoice_by_id(inv.id) if fresh.was_intent_successful and fresh.data: fresh.data.paid = new_paid @@ -986,11 +987,13 @@ def toggle_invoice_paid_status(self, invoice: Invoice) -> IntentResult[Invoice]: # Settling the Schlussrechnung settles the whole contract: its # remaining balance is what is left after the deposits, so a paid - # final invoice means every deposit in the chain was paid too. + # final invoice means every deposit in the chain was paid too, also + # when the payment came in on a reminder for it. # A cancelled deposit was never charged, so it stays unpaid. - if invoice.is_final_invoice and new_paid: - reload = self._invoicing_data_source.get_invoice_by_id(invoice.id) - final = reload.data if reload.was_intent_successful and reload.data else invoice + head = chain[0] if chain else invoice + if head.is_final_invoice and new_paid: + reload = self._invoicing_data_source.get_invoice_by_id(head.id) + final = reload.data if reload.was_intent_successful and reload.data else head for dep in final.deposits: if dep.cancelled: continue diff --git a/tuttle/model.py b/tuttle/model.py index 17177861..c25b6527 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -1438,13 +1438,37 @@ def deposit_chain_head_id(self) -> Optional[int]: return self.id return None + @property + def reminder_chain_head(self) -> "Invoice": + """The invoice a reminder chases, at the start of its chain; any other document is its own head. + + Needs the chain loaded: ``InvoicingDataSource`` loads it with every reminder. + """ + node = self + visited: set[int] = set() + while node.is_reminder and node.reminder_for is not None and id(node) not in visited: + visited.add(id(node)) + node = node.reminder_for + return node + + @property + def settlement(self) -> Optional["Invoice"]: + """The final invoice whose settlement this document states, if any. + + That is a final invoice itself, and a reminder for one: the reminder + restates the final's deductions and asks for its remaining balance. + """ + head = self.reminder_chain_head + return head if head.is_final_invoice else None + @property def deposit_deductions(self) -> list: - """For a final invoice, return list of deposit deduction dicts for rendering.""" - if not self.is_final_invoice: + """The deposits a final invoice, or a reminder for one, deducts, as dicts for rendering.""" + settlement = self.settlement + if settlement is None: return [] deductions = [] - for dep in self.deposits: + for dep in settlement.deposits: deductions.append( { "invoice_number": dep.number, @@ -1457,9 +1481,10 @@ def deposit_deductions(self) -> list: @property def remaining_balance(self) -> Decimal: - """For a final invoice: total minus sum of deposit gross amounts.""" - if not self.is_final_invoice: - return self.total + """What the client still owes on this document: its total less the deposits it deducts. + + For a reminder of a final invoice that is the final's remaining balance plus the reminder fee. + """ deposit_gross = sum(d["gross"] for d in self.deposit_deductions) return Decimal(self.total - deposit_gross) @@ -1470,8 +1495,8 @@ def remaining_balance_formatted(self) -> str: @property def deposits_deducted_formatted(self) -> str: - """For a final invoice: formatted sum of the gross amounts it deducts.""" - if not self.is_final_invoice: + """For a final invoice, or a reminder for one: formatted sum of the gross amounts it deducts.""" + if self.settlement is None: return "" currency = self.contract.currency if self.contract else "EUR" deducted = sum(d["gross"] for d in self.deposit_deductions) diff --git a/tuttle/rendering.py b/tuttle/rendering.py index a5a93bee..872f1ba9 100644 --- a/tuttle/rendering.py +++ b/tuttle/rendering.py @@ -117,19 +117,25 @@ def convert_html_to_pdf( def generate_payment_qr(bank_account: Optional[BankAccount], invoice: Invoice) -> Optional[str]: """Generate a SEPA payment (EPC/Girocode) QR code for an invoice, if eligible. + The code asks for the amount due, which on a final invoice, or a reminder for + one, is what the deposits left. + Returns an SVG data URI, or None if ``bank_account`` is missing or incomplete, - or the invoice isn't in EUR (the EPC QR format is SEPA-only). + the invoice isn't in EUR (the EPC QR format is SEPA-only), or nothing is due. """ if bank_account is None or not (bank_account.BIC and bank_account.IBAN and bank_account.name): return None if invoice.currency != "EUR": return None + amount_due = invoice.remaining_balance + if amount_due <= 0: + return None try: qr = make_epc_qr( name=bank_account.name, iban=bank_account.IBAN, - amount=float(invoice.total), + amount=float(amount_due), text=invoice.number, bic=bank_account.BIC, ) @@ -234,8 +240,9 @@ def render_invoice( # Deposit and final invoices are the two halves of one settlement: the # deposit states what share of the contract it bills, the final states the - # whole contract and deducts every deposit already issued. Both need the - # invoice fully hydrated — callers render from a session-loaded instance. + # whole contract and deducts every deposit already issued. A reminder for a + # final invoice restates those deductions. All need the invoice fully + # hydrated — callers render from a session-loaded instance. is_deposit = invoice.is_deposit is_final = invoice.is_final_invoice deposit_deductions = invoice.deposit_deductions diff --git a/tuttle_tests/test_final_invoice_reminder_balance.py b/tuttle_tests/test_final_invoice_reminder_balance.py new file mode 100644 index 00000000..05ee4fad --- /dev/null +++ b/tuttle_tests/test_final_invoice_reminder_balance.py @@ -0,0 +1,178 @@ +"""A reminder for a final invoice asks for what the deposits left, plus its fee. + +The reminder repeats the final invoice's items, which state the full contract +price, so it must also repeat the deductions; otherwise the PDF and its payment +QR code ask the client for the whole price again. +""" + +import datetime +from decimal import Decimal +from pathlib import Path + +import pytest +from sqlmodel import Session, SQLModel, create_engine + +from tuttle import invoicing, rendering +from tuttle.app.core.abstractions import SQLModelDataSourceMixin +from tuttle.app.core.rpc_utils import dump +from tuttle.app.invoicing.intent import InvoicingIntent +from tuttle.model import Address, BankAccount, Client, Contract, Invoice, PaymentMilestone, Project, User +from tuttle.time import ContractType, Cycle + +DATE = datetime.date(2026, 2, 1) +TEMPLATES = sorted(p.parent.name for p in (Path(__file__).parent.parent / "templates").glob("invoice*/invoice.html")) + +# 10,000 net at 19% VAT in instalments of 40%, 40% and 20%: two deposits of +# 4,760 gross leave 2,380 of the 11,900 for the final invoice. +FINAL_BALANCE = Decimal("2380.00") +FEE = Decimal("5.00") + + +def _user() -> User: + return User( + name="Harry Tuttle", + subtitle="Heating Engineer", + email="mail@tuttle.example", + address=Address(street="Main Street", number="450", postal_code="55555", city="Sao Paolo", country="Brazil"), + bank_accounts=[BankAccount(name="Harry Tuttle", IBAN="DE89370400440532013000", BIC="COBADEFFXXX")], + ) + + +def _contract() -> Contract: + return Contract( + term_of_payment=14, + title="Central Heating Overhaul", + client=Client(name="Sam Lowry"), + start_date=datetime.date(2026, 1, 10), + type=ContractType.fixed_price, + fixed_price=Decimal("10000"), + currency="EUR", + VAT_rate=Decimal("0.19"), + billing_cycle=Cycle.monthly, + payment_milestones=[ + PaymentMilestone(title=f"Instalment {position + 1}", percentage=Decimal(pct), position=position) + for position, pct in enumerate(("40", "40", "20")) + ], + ) + + +def _ok(result): + assert result.was_intent_successful, result.error_msg + return result.data + + +@pytest.fixture +def settlement(monkeypatch) -> tuple[InvoicingIntent, list[Invoice], Invoice]: + """Two deposits and the final invoice that settles them, all unpaid.""" + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + monkeypatch.setattr(SQLModelDataSourceMixin, "create_session", lambda self: Session(engine)) + with Session(engine) as session: + session.add(_user()) + project = Project( + title="Heating Overhaul", + description="Ductwork", + tag="#overhaul", + contract=_contract(), + start_date=datetime.date(2026, 1, 10), + ) + session.add(project) + session.commit() + project_id, milestone_ids = project.id, [m.id for m in project.contract.payment_milestones] + + intent = InvoicingIntent() + deposits = [_ok(intent.create_deposit(project_id, milestone_id, DATE)) for milestone_id in milestone_ids[:2]] + final = _ok(intent.create_final(project_id, DATE)) + return intent, deposits, final + + +def _load(intent: InvoicingIntent, invoice: Invoice) -> Invoice: + return _ok(intent._invoicing_data_source.get_invoice_by_id(invoice.id)) + + +def _remind(intent: InvoicingIntent, invoice: Invoice, fee=FEE) -> Invoice: + _ok(intent.toggle_sent(invoice.id)) + reminder = _ok(intent.create_reminder(invoice.id, "2026-08-01", "2026-08-15", str(fee))) + return _load(intent, reminder) + + +class TestReminderForAFinalInvoice: + def test_restates_the_deductions_of_the_final_invoice(self, settlement): + intent, _, final = settlement + reminder = _remind(intent, final) + + assert reminder.deposit_deductions == _load(intent, final).deposit_deductions + assert len(reminder.deposit_deductions) == 2 + + def test_asks_for_the_remaining_balance_plus_the_fee(self, settlement): + intent, _, final = settlement + reminder = _remind(intent, final) + + assert reminder.total == Decimal("11905.00") + assert reminder.remaining_balance == FINAL_BALANCE + FEE + + @pytest.mark.parametrize("template", TEMPLATES) + def test_its_pdf_deducts_the_deposits_before_adding_the_fee(self, settlement, template): + intent, deposits, final = settlement + reminder = _remind(intent, final) + + html = rendering.render_invoice(user=_user(), invoice=reminder, out_dir=None, template_name=template) + + assert "€2,385.00" in html + assert "€11,905.00" not in html + assert "€11,900.00" in html # the gross amount the deposits are deducted from + for deposit in deposits: + assert f"less deposit per invoice no. {deposit.number}" in html + assert html.index("Reminder Fee") > html.rindex("less deposit per invoice no.") + + def test_its_payment_qr_code_asks_for_the_same_amount(self, settlement, monkeypatch): + intent, _, final = settlement + reminder = _remind(intent, final) + amounts = [] + make_epc_qr = rendering.make_epc_qr + monkeypatch.setattr(rendering, "make_epc_qr", lambda **kw: amounts.append(kw["amount"]) or make_epc_qr(**kw)) + + assert rendering.generate_payment_qr(_user().bank_account, _load(intent, final)) is not None + assert rendering.generate_payment_qr(_user().bank_account, reminder) is not None + + assert amounts == [float(FINAL_BALANCE), float(FINAL_BALANCE + FEE)] + + def test_a_second_reminder_restates_it_too(self, settlement): + intent, _, final = settlement + first = _remind(intent, final) + second = _remind(intent, first, fee=Decimal("10.00")) + + assert second.reminder_chain_head_id == final.id + assert second.remaining_balance == FINAL_BALANCE + Decimal("10.00") + served = {inv["id"]: inv for inv in dump(_ok(intent.get_all()))} + assert served[second.id]["remaining_balance"] == float(FINAL_BALANCE + Decimal("10.00")) + + def test_paid_settles_the_deposits_too(self, settlement): + intent, deposits, final = settlement + reminder = _remind(intent, final) + + _ok(intent.toggle_paid(reminder.id)) + + assert [_load(intent, inv).paid for inv in (reminder, final, *deposits)] == [True] * 4 + + +def test_a_reminder_for_an_ordinary_invoice_asks_for_its_total(settlement): + intent, (deposit, _), _ = settlement + reminder = _remind(intent, deposit) + + assert reminder.deposit_deductions == [] + assert reminder.remaining_balance == reminder.total == Decimal("4765.00") + + +def test_a_settled_final_invoice_gets_no_payment_qr_code(): + """Nothing is due once the deposits cover the whole price.""" + project = Project(title="Heating Overhaul", description="", tag="#overhaul", contract=_contract()) + contract = project.contract + deposits = [ + invoicing.generate_deposit_invoice(contract, project, milestone, number=f"2026-0{position}", date=DATE) + for position, milestone in enumerate(contract.payment_milestones) + ] + final = invoicing.generate_final_invoice(contract, project, deposits, number="2026-09", date=DATE) + + assert final.remaining_balance == 0 + assert rendering.generate_payment_qr(_user().bank_account, final) is None diff --git a/ui/src/components/invoicing/InvoicingView.tsx b/ui/src/components/invoicing/InvoicingView.tsx index 4fcf246d..306a5fc4 100644 --- a/ui/src/components/invoicing/InvoicingView.tsx +++ b/ui/src/components/invoicing/InvoicingView.tsx @@ -3,7 +3,7 @@ import { FileText, Send, CheckCircle, XCircle, Mail, Trash2, Building2, FolderKanban, Calendar, Banknote, Eye, Plus, Clock, AlertTriangle, ChevronDown, ChevronLeft, ChevronRight, Share, Receipt, Milestone, FileSignature, RotateCcw, } from "lucide-react"; import { rpc, readFileAsDataURL } from "../../api/rpc"; -import { str, num, bool, entity as subEntity, list as entityList, formatDate, invoiceStatus, deepStr, isReminder, isDeposit, isFinalInvoice, reminderLevel, depositChainHeadId, depositMilestoneLabel, milestoneScheduleStatus, type MilestoneScheduleStatus } from "../../api/entity"; +import { str, num, bool, entity as subEntity, list as entityList, formatDate, formatMoney, invoiceStatus, deepStr, isReminder, isDeposit, isFinalInvoice, reminderLevel, depositChainHeadId, depositMilestoneLabel, milestoneScheduleStatus, type MilestoneScheduleStatus } from "../../api/entity"; import { taxCategory, taxTreatment } from "../../api/tax"; import { StatusBadge } from "../shared/StatusBadge"; import { statusColor } from "../shared/status-colors"; @@ -1134,6 +1134,10 @@ function InvoiceDetail({ invoice, allInvoices, onToggleSent, onTogglePaid, onTog .sort((a, b) => num(a, "reminder_level") - num(b, "reminder_level")); return [root, ...reminders]; }, [invoice, allInvoices]); + // The final invoice whose settlement this document states: itself, or the one a reminder chases. + const settlement = isFinalInvoice(invoice) ? invoice + : isRem && chain[0] && isFinalInvoice(chain[0]) ? chain[0] : null; + const reminderFee = isRem ? num(invoice, "reminder_fee") : 0; const [detailTab, setDetailTab] = useState<"invoice" | "timesheet" | "details">(pdfPath ? "invoice" : "details"); const [pdfDataUrl, setPdfDataUrl] = useState(null); @@ -1223,13 +1227,17 @@ function InvoiceDetail({ invoice, allInvoices, onToggleSent, onTogglePaid, onTog
- {/* A final invoice settles the whole contract: what matters is the - balance still due after deducting the deposits, not the full total. */} + {/* A final invoice settles the whole contract, and a reminder for it + restates that settlement: what matters is the balance still due + after deducting the deposits, not the full total. */}
- {isFinalInvoice(invoice) ? ( + {settlement ? ( <> - - + + + {reminderFee > 0 && ( + + )} ) : ( From 8f7605791ebfc06cbba378a1bf7ba21bf0e9d4a1 Mon Sep 17 00:00:00 2001 From: Christian Staudt <875194+clstaudt@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:04:17 +0200 Subject: [PATCH 4/5] Cancel an invoice's reminders with it Cancelling an invoice left its reminders overdue, and a new reminder could still be created from one of them, because only the reminder being chased was checked for being cancelled. A reminder chain now shares the cancelled state of its invoice, as it already shares the paid state: cancelling or restoring the invoice does the same to all its reminders. A single reminder can still be cancelled on its own, but not restored while its invoice is cancelled, and no new reminder can be created for a cancelled invoice. Co-Authored-By: Claude Opus 5.5 --- tuttle/app/invoicing/intent.py | 58 +++++--- .../test_cancel_reminders_with_invoice.py | 140 ++++++++++++++++++ 2 files changed, 177 insertions(+), 21 deletions(-) create mode 100644 tuttle_tests/test_cancel_reminders_with_invoice.py diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index 2c50d82a..2bc45c84 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -714,11 +714,25 @@ def _create_reminder( return IntentResult(was_intent_successful=False, error_msg="Invoice not found.") predecessor = result.data + # Walk to root via explicit queries to avoid DetachedInstanceError + root = predecessor + while root.reminder_for_id is not None: + parent_result = self._invoicing_data_source.get_invoice_by_id(root.reminder_for_id) + if not parent_result.was_intent_successful or not parent_result.data: + break + root = parent_result.data + if predecessor.paid: return IntentResult( was_intent_successful=False, error_msg="Cannot create a reminder for a paid invoice.", ) + # A cancelled invoice is owed no more, whichever reminder of it is chased. + if root.cancelled: + return IntentResult( + was_intent_successful=False, + error_msg=f"Invoice {root.number} is cancelled. Restore it first if the client still owes it.", + ) if predecessor.cancelled: return IntentResult( was_intent_successful=False, @@ -730,14 +744,6 @@ def _create_reminder( user = self._user_data_source.get_user() new_level = predecessor.reminder_level + 1 - # Walk to root via explicit queries to avoid DetachedInstanceError - root = predecessor - while root.reminder_for_id is not None: - parent_result = self._invoicing_data_source.get_invoice_by_id(root.reminder_for_id) - if not parent_result.was_intent_successful or not parent_result.data: - break - root = parent_result.data - items = [ InvoiceItem( start_date=item.start_date, @@ -1018,18 +1024,14 @@ def toggle_invoice_paid_status(self, invoice: Invoice) -> IntentResult[Invoice]: ) def toggle_invoice_cancelled_status(self, invoice: Invoice) -> IntentResult[Invoice]: - """ - Toggles the "cancelled" status of an invoice and updates it in the data source. + """Toggle cancelled status. Propagates from an invoice to its reminders. - Parameters: - invoice (Invoice): - The invoice object whose "cancelled" status will be toggled. - - Returns: - IntentResult[Invoice]: - An IntentResult object containing the updated invoice, or old invoice if update was not successful. + A cancelled invoice is owed no more, so its reminders are void with it + and come back when it is restored. A reminder can be cancelled on its + own, but not restored while its invoice is cancelled. """ - if invoice.is_deposit and not invoice.cancelled and invoice.deposit_for_id is not None: + new_cancelled = not invoice.cancelled + if invoice.is_deposit and new_cancelled and invoice.deposit_for_id is not None: # The final invoice deducts this deposit. Voided on its own, the deposit # would stay deducted there and the client would be asked for too little. final = self._invoicing_data_source.get_invoice_by_id(invoice.deposit_for_id).data @@ -1041,14 +1043,28 @@ def toggle_invoice_cancelled_status(self, invoice: Invoice) -> IntentResult[Invo "Cancel the final invoice first, then the deposit." ), ) + chain_result = self._invoicing_data_source.get_reminder_chain(invoice.id) + chain = chain_result.data if chain_result.was_intent_successful and chain_result.data else [invoice] + head = chain[0] + if invoice.is_reminder: + if not new_cancelled and head.id != invoice.id and head.cancelled: + return IntentResult( + was_intent_successful=False, + error_msg=f"Invoice {head.number} is cancelled. Restore the invoice to restore its reminders.", + ) + chain = [invoice] try: - invoice.cancelled = not invoice.cancelled - self._invoicing_data_source.save_invoice(invoice) + for inv in chain: + fresh = self._invoicing_data_source.get_invoice_by_id(inv.id) + if fresh.was_intent_successful and fresh.data: + fresh.data.cancelled = new_cancelled + self._invoicing_data_source.save_invoice(fresh.data) if invoice.is_deposit or invoice.is_final_invoice: self._invoicing_data_source.reconcile_milestones_invoiced(invoice.contract_id) + result = self._invoicing_data_source.get_invoice_by_id(invoice.id) return IntentResult( was_intent_successful=True, - data=invoice, + data=result.data if result.was_intent_successful and result.data else invoice, ) except Exception as ex: logger.error(f"Error toggling invoice cancelled status: {ex}") diff --git a/tuttle_tests/test_cancel_reminders_with_invoice.py b/tuttle_tests/test_cancel_reminders_with_invoice.py new file mode 100644 index 00000000..bb3d7bd5 --- /dev/null +++ b/tuttle_tests/test_cancel_reminders_with_invoice.py @@ -0,0 +1,140 @@ +"""A reminder chain shares the cancelled state of the invoice it chases. + +A cancelled invoice is owed no more, so its reminders must not stay overdue, +and no new reminder may chase it. A single reminder can still be withdrawn. +""" + +import datetime +from decimal import Decimal + +import pytest +from sqlmodel import Session, SQLModel, create_engine + +from tuttle.app.core.abstractions import SQLModelDataSourceMixin +from tuttle.app.invoicing.intent import InvoicingIntent +from tuttle.model import Address, Client, Contract, Invoice, Project, User +from tuttle.time import ContractType, Cycle + +DATE = datetime.date(2026, 2, 1) + + +def _ok(result): + assert result.was_intent_successful, result.error_msg + return result.data + + +@pytest.fixture +def chain(monkeypatch) -> tuple[InvoicingIntent, Invoice, Invoice, Invoice]: + """An invoice with a first and a second reminder.""" + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + monkeypatch.setattr(SQLModelDataSourceMixin, "create_session", lambda self: Session(engine)) + with Session(engine) as session: + # Rendering the PDF after creation reads the user's preferences. + session.add( + User( + name="Harry Tuttle", + subtitle="Heating Engineer", + email="mail@tuttle.example", + address=Address(street="Main Street", number="450", postal_code="55555", city="Sao Paolo", country="Brazil"), + ) + ) + project = Project( + title="Boiler Repair", + description="Burner", + tag="#boiler", + start_date=datetime.date(2026, 1, 10), + contract=Contract( + VAT_rate=Decimal("0.19"), + term_of_payment=14, + title="Boiler Repair", + client=Client(name="Sam Lowry"), + start_date=datetime.date(2026, 1, 10), + type=ContractType.fixed_price, + fixed_price=Decimal("800"), + currency="EUR", + billing_cycle=Cycle.monthly, + ), + ) + session.add(project) + session.commit() + project_id = project.id + + intent = InvoicingIntent() + invoice = _ok(intent.create(project_id, DATE, DATE, DATE)) + _ok(intent.toggle_sent(invoice.id)) + first = _ok(intent.create_reminder(invoice.id, "2026-03-01", "2026-03-15", "5.00")) + _ok(intent.toggle_sent(first.id)) + second = _ok(intent.create_reminder(first.id, "2026-04-01", "2026-04-15", "10.00")) + return intent, invoice, first, second + + +def _cancelled(intent: InvoicingIntent, *invoices: Invoice) -> list[bool]: + return [_ok(intent._invoicing_data_source.get_invoice_by_id(inv.id)).cancelled for inv in invoices] + + +class TestCancellingTheInvoice: + def test_cancels_its_reminders(self, chain): + intent, invoice, first, second = chain + + _ok(intent.toggle_cancelled(invoice.id)) + + assert _cancelled(intent, invoice, first, second) == [True, True, True] + + def test_restoring_it_restores_its_reminders(self, chain): + intent, invoice, first, second = chain + _ok(intent.toggle_cancelled(invoice.id)) + + _ok(intent.toggle_cancelled(invoice.id)) + + assert _cancelled(intent, invoice, first, second) == [False, False, False] + + +class TestCancellingAReminder: + def test_cancels_only_that_reminder(self, chain): + intent, invoice, first, second = chain + + _ok(intent.toggle_cancelled(first.id)) + + assert _cancelled(intent, invoice, first, second) == [False, True, False] + + def test_it_can_be_restored_while_the_invoice_stands(self, chain): + intent, invoice, first, second = chain + _ok(intent.toggle_cancelled(first.id)) + + _ok(intent.toggle_cancelled(first.id)) + + assert _cancelled(intent, invoice, first, second) == [False, False, False] + + def test_it_cannot_be_restored_while_the_invoice_is_cancelled(self, chain): + intent, invoice, first, second = chain + _ok(intent.toggle_cancelled(invoice.id)) + + result = intent.toggle_cancelled(first.id) + + assert not result.was_intent_successful + assert result.error_msg == f"Invoice {invoice.number} is cancelled. Restore the invoice to restore its reminders." + assert _cancelled(intent, first) == [True] + + +class TestNewReminder: + def test_is_refused_for_a_cancelled_invoice_even_from_a_live_reminder(self, chain): + """Reminders that an older version left live when it cancelled their invoice.""" + intent, invoice, first, second = chain + invoice_row = _ok(intent._invoicing_data_source.get_invoice_by_id(invoice.id)) + invoice_row.cancelled = True + intent._invoicing_data_source.save_invoice(invoice_row) + + result = intent.create_reminder(second.id, "2026-05-01", "2026-05-15") + + assert not result.was_intent_successful + assert result.error_msg == f"Invoice {invoice.number} is cancelled. Restore it first if the client still owes it." + + def test_is_allowed_again_once_the_invoice_is_restored(self, chain): + intent, invoice, first, second = chain + _ok(intent.toggle_cancelled(invoice.id)) + _ok(intent.toggle_cancelled(invoice.id)) + + third = _ok(intent.create_reminder(second.id, "2026-05-01", "2026-05-15")) + + assert third.reminder_level == 3 From f218b6dacbcc73077ba0f67a554d8cf0df476538 Mon Sep 17 00:00:00 2001 From: Christian Staudt <875194+clstaudt@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:20:01 +0200 Subject: [PATCH 5/5] Refuse to issue an invoice without the details the law requires An invoice went out without the seller's VAT number or tax number, or without the seller's or the client's address, and nobody was told: the PDF left the lines out, and the e-invoice XML still passed schema validation. An e-invoice for a contract without a term of payment broke EN16931 the same way. Before an invoice, deposit invoice, final invoice or reminder is created, Tuttle now checks these details and refuses with one message that names what to add. With an e-invoice profile on, the contract's term of payment is required too. An invoice outside the scope of VAT needs the tax number, since it may not show the VAT number. The e-invoice now states the address the PDF shows, which is the invoicing contact's when it has one. The demo user gets a tax number for its dollar invoices. Co-Authored-By: Claude Opus 5.5 --- tuttle/app/invoicing/intent.py | 36 ++- tuttle/demo.py | 2 + tuttle/einvoice.py | 3 +- tuttle/invoicing.py | 81 +++++ tuttle/model.py | 5 + .../test_cancel_reminders_with_invoice.py | 8 +- .../test_final_invoice_reminder_balance.py | 6 +- tuttle_tests/test_keep_settled_deposits.py | 6 +- tuttle_tests/test_milestone_invoiced_flag.py | 1 + .../test_one_final_invoice_per_project.py | 6 +- ...efuse_invoice_without_mandatory_details.py | 289 ++++++++++++++++++ 11 files changed, 430 insertions(+), 13 deletions(-) create mode 100644 tuttle_tests/test_refuse_invoice_without_mandatory_details.py diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index 2bc45c84..909447c3 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -109,7 +109,6 @@ def create( number_scheme = ( app_db.get_setting(PreferencesStorageKeys.invoice_number_scheme_key.value) or DEFAULT_INVOICE_NUMBER_SCHEME ) - e_invoice_profile = app_db.get_setting(PreferencesStorageKeys.e_invoice_profile_key.value) or DEFAULT_E_INVOICE_PROFILE return self.create_invoice( invoice_date=invoice_date, @@ -122,7 +121,7 @@ def create( template_name=template_name, number_scheme=number_scheme, with_timesheet=with_timesheet, - e_invoice_profile=e_invoice_profile or None, + e_invoice_profile=self._e_invoice_profile(), notes=notes, ) @@ -209,6 +208,12 @@ def create_deposit( # ``create_final`` closes out every remaining milestone itself. return self.create_final(project_id, invoice_date) + missing = invoicing.missing_mandatory_details( + self._user_data_source.get_user(), contract, self._e_invoice_profile(), document="deposit invoice" + ) + if missing: + return IntentResult(was_intent_successful=False, error_msg=missing) + try: invoice = invoicing.generate_deposit_invoice( contract=contract, @@ -264,6 +269,11 @@ def create_final( was_intent_successful=False, error_msg=f"Final invoice {final.number} already settles this project. Cancel it to issue a new final invoice.", ) + missing = invoicing.missing_mandatory_details( + self._user_data_source.get_user(), contract, self._e_invoice_profile(), document="final invoice" + ) + if missing: + return IntentResult(was_intent_successful=False, error_msg=missing) # The deposits of a cancelled final invoice are open to be settled again. deposits_result = self._invoicing_data_source.get_deposit_invoices(contract.id, project.id) @@ -323,6 +333,11 @@ def _mark_milestones_invoiced(self, milestones) -> None: for milestone in milestones: milestone.invoiced = True + def _e_invoice_profile(self) -> Optional[str]: + """The e-invoice profile from the preferences, which rendering embeds in invoices.""" + app_db = AppDatabase() + return app_db.get_setting(PreferencesStorageKeys.e_invoice_profile_key.value) or DEFAULT_E_INVOICE_PROFILE or None + def _next_invoice_number(self, invoice_date: date) -> str: app_db = AppDatabase() scheme = app_db.get_setting(PreferencesStorageKeys.invoice_number_scheme_key.value) or DEFAULT_INVOICE_NUMBER_SCHEME @@ -361,7 +376,6 @@ def _render_saved_invoice(self, invoice_id: int, description: str) -> tuple[Invo def _resolved_render_options(self, invoice: Invoice) -> dict: """Template, language and layout preferences for rendering ``invoice``.""" - app_db = AppDatabase() def _pref(getter, default): result = getter() @@ -369,13 +383,10 @@ def _pref(getter, default): return result.data return default - # The e-invoice profile is resolved like any other preference; rendering - # decides per document type whether XML can be embedded at all. - e_invoice_profile = app_db.get_setting(PreferencesStorageKeys.e_invoice_profile_key.value) or DEFAULT_E_INVOICE_PROFILE - + # Rendering decides per document type whether XML can be embedded at all. return { "language": _document_language(invoice.client), - "e_invoice_profile": e_invoice_profile or None, + "e_invoice_profile": self._e_invoice_profile(), "template_name": _pref( self._preferences_intent.get_preferred_invoice_template, DEFAULT_INVOICE_TEMPLATE, @@ -509,6 +520,9 @@ def create_invoice( ) language = language or _document_language(contract.client) user = self._user_data_source.get_user() + missing = invoicing.missing_mandatory_details(user, contract, e_invoice_profile) + if missing: + return IntentResult(was_intent_successful=False, error_msg=missing) try: invoice_number = self._invoicing_data_source.generate_invoice_number(invoice_date, scheme=number_scheme) @@ -738,6 +752,12 @@ def _create_reminder( was_intent_successful=False, error_msg="Cannot create a reminder for a cancelled invoice.", ) + # A reminder carries no e-invoice, but restates the invoice and its parties. + missing = invoicing.missing_mandatory_details( + self._user_data_source.get_user(), root.contract, outside_scope=root.is_outside_scope, document="reminder" + ) + if missing: + return IntentResult(was_intent_successful=False, error_msg=missing) language = language or _document_language(predecessor.client) try: diff --git a/tuttle/demo.py b/tuttle/demo.py index da0c1838..b3456e2d 100644 --- a/tuttle/demo.py +++ b/tuttle/demo.py @@ -1160,6 +1160,8 @@ def create_demo_user() -> User: email="mail@tuttle.com", phone_number="+55555555555", VAT_number="27B-6", + # Shown instead of the VAT number on invoices outside the scope of VAT. + tax_number="27/815/06150", signature=_load_demo_signature(), address=Address( street="Main Street", diff --git a/tuttle/einvoice.py b/tuttle/einvoice.py index 53b003f7..5d926a63 100644 --- a/tuttle/einvoice.py +++ b/tuttle/einvoice.py @@ -210,7 +210,8 @@ def build_zugferd_document( # -- Buyer (Client) ------------------------------------------------------- doc.trade.agreement.buyer.name = client.invoice_recipient_name - buyer_address = client.address + # The address the PDF shows, which issuing checks for: the invoicing contact's, else the client's. + buyer_address = client.invoice_recipient_address if buyer_address: if not is_minimum: doc.trade.agreement.buyer.address.line_one = f"{buyer_address.street} {buyer_address.number}".strip() diff --git a/tuttle/invoicing.py b/tuttle/invoicing.py index 6cc3d41c..49ab7039 100644 --- a/tuttle/invoicing.py +++ b/tuttle/invoicing.py @@ -6,6 +6,7 @@ from .i18n import _ from .model import ( + Address, Contract, ContractCharge, Invoice, @@ -295,6 +296,86 @@ def generate_final_invoice( return invoice +def _join(parts: Sequence[str]) -> str: + """Join words as a sentence does: a; a and b; a, b and c.""" + if len(parts) < 2: + return "".join(parts) + return f"{', '.join(parts[:-1])} and {parts[-1]}" + + +def _possessive(name: str) -> str: + """Central Services → Central Services', Sam Lowry → Sam Lowry's.""" + return f"{name}'" if name.endswith("s") else f"{name}'s" + + +def _address_gap(address: Optional[Address], owner: str) -> Optional[str]: + """What *address* lacks to be printed on an invoice, in words, or None. + + Street, city and country are part of every postal address. House number + and postcode are not: some addresses have none. + """ + if address is None or address.is_empty: + return f"{owner} address" + gaps = [part for part in ("street", "city", "country") if not (getattr(address, part) or "").strip()] + return f"the {_join(gaps)} of {owner} address" if gaps else None + + +def missing_mandatory_details( + user: Optional[User], + contract: Contract, + e_invoice_profile: Optional[str] = None, + outside_scope: Optional[bool] = None, + document: str = "invoice", +) -> Optional[str]: + """Why a document for *contract* may not be issued yet, or None if it may. + + §14 Abs. 4 UStG and EN16931 require the seller's name, address and VAT + number or tax number, and the buyer's name and address (BR-6 to BR-10, + BR-S-02). The documents leave out what is not set, and the XML still + passes schema validation, so the gap would go unnoticed. An e-invoice + also needs payment terms whenever an amount is due (BR-CO-25). + + An invoice outside the scope of VAT may not show the VAT number + (BR-O-02), so it needs the tax number. *outside_scope* defaults to the + contract's tax category; a reminder passes that of the invoice it chases. + """ + if outside_scope is None: + outside_scope = contract.is_outside_scope + missing: List[str] = [] + if user is None or not (user.name or "").strip(): + missing.append("your name") + gap = _address_gap(user.address if user else None, "your") + if gap: + missing.append(gap) + if outside_scope: + if not (user and user.tax_number): + missing.append("your tax number") + elif not (user and (user.VAT_number or user.tax_number)): + missing.append("your VAT number or tax number") + + client = contract.client + if client is None: + missing.append("a client on the contract") + else: + # The documents print the invoicing contact's address, else the client's + # own, so a gap is named after whoever's address it is. + contact = client.invoice_recipient_contact + address = client.invoice_recipient_address + holder = client.name + if contact is not None and address is not None and address is contact.address: + holder = contact.name or client.name + gap = _address_gap(address, _possessive(holder)) + if gap: + missing.append(gap) + + if e_invoice_profile and contract.term_of_payment is None: + missing.append("the contract's term of payment") + + if not missing: + return None + return f"Add {_join(missing)} before issuing this {document}." + + def generate_invoice_email( invoice: Invoice, user: User, diff --git a/tuttle/model.py b/tuttle/model.py index c25b6527..535edb2e 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -665,6 +665,11 @@ def is_fixed_price(self) -> bool: def has_milestones(self) -> bool: return bool(self.payment_milestones) + @property + def is_outside_scope(self) -> bool: + """Whether the contract's work is outside the scope of VAT, as its invoices will be.""" + return normalize_tax_category(self.VAT_category) is TaxCategory.outside_scope + @property def fixed_price_formatted(self) -> str: if self.fixed_price is None: diff --git a/tuttle_tests/test_cancel_reminders_with_invoice.py b/tuttle_tests/test_cancel_reminders_with_invoice.py index bb3d7bd5..a199bb97 100644 --- a/tuttle_tests/test_cancel_reminders_with_invoice.py +++ b/tuttle_tests/test_cancel_reminders_with_invoice.py @@ -36,6 +36,7 @@ def chain(monkeypatch) -> tuple[InvoicingIntent, Invoice, Invoice, Invoice]: name="Harry Tuttle", subtitle="Heating Engineer", email="mail@tuttle.example", + VAT_number="27B-6", address=Address(street="Main Street", number="450", postal_code="55555", city="Sao Paolo", country="Brazil"), ) ) @@ -48,7 +49,12 @@ def chain(monkeypatch) -> tuple[InvoicingIntent, Invoice, Invoice, Invoice]: VAT_rate=Decimal("0.19"), term_of_payment=14, title="Boiler Repair", - client=Client(name="Sam Lowry"), + client=Client( + name="Sam Lowry", + address=Address( + street="Main Street", number="9999", postal_code="55555", city="Sao Paolo", country="Brazil" + ), + ), start_date=datetime.date(2026, 1, 10), type=ContractType.fixed_price, fixed_price=Decimal("800"), diff --git a/tuttle_tests/test_final_invoice_reminder_balance.py b/tuttle_tests/test_final_invoice_reminder_balance.py index 05ee4fad..a5b26011 100644 --- a/tuttle_tests/test_final_invoice_reminder_balance.py +++ b/tuttle_tests/test_final_invoice_reminder_balance.py @@ -33,6 +33,7 @@ def _user() -> User: name="Harry Tuttle", subtitle="Heating Engineer", email="mail@tuttle.example", + VAT_number="27B-6", address=Address(street="Main Street", number="450", postal_code="55555", city="Sao Paolo", country="Brazil"), bank_accounts=[BankAccount(name="Harry Tuttle", IBAN="DE89370400440532013000", BIC="COBADEFFXXX")], ) @@ -42,7 +43,10 @@ def _contract() -> Contract: return Contract( term_of_payment=14, title="Central Heating Overhaul", - client=Client(name="Sam Lowry"), + client=Client( + name="Sam Lowry", + address=Address(street="Main Street", number="9999", postal_code="55555", city="Sao Paolo", country="Brazil"), + ), start_date=datetime.date(2026, 1, 10), type=ContractType.fixed_price, fixed_price=Decimal("10000"), diff --git a/tuttle_tests/test_keep_settled_deposits.py b/tuttle_tests/test_keep_settled_deposits.py index 8d6737f8..6a8c8057 100644 --- a/tuttle_tests/test_keep_settled_deposits.py +++ b/tuttle_tests/test_keep_settled_deposits.py @@ -42,13 +42,17 @@ def settlement(in_memory_db) -> tuple[InvoicingIntent, list[Invoice], Invoice]: name="Harry Tuttle", subtitle="Heating Engineer", email="mail@tuttle.example", + VAT_number="27B-6", address=Address(street="Main Street", number="450", postal_code="55555", city="Sao Paolo", country="Brazil"), ) ) contract = Contract( term_of_payment=14, title="Central Heating Overhaul", - client=Client(name="Sam Lowry"), + client=Client( + name="Sam Lowry", + address=Address(street="Main Street", number="9999", postal_code="55555", city="Sao Paolo", country="Brazil"), + ), start_date=datetime.date(2026, 1, 10), type=ContractType.fixed_price, fixed_price=Decimal("10000"), diff --git a/tuttle_tests/test_milestone_invoiced_flag.py b/tuttle_tests/test_milestone_invoiced_flag.py index c62ad025..a7c97922 100644 --- a/tuttle_tests/test_milestone_invoiced_flag.py +++ b/tuttle_tests/test_milestone_invoiced_flag.py @@ -36,6 +36,7 @@ def _schedule(engine, *percentages: str) -> tuple[int, list[int]]: name="Harry Tuttle", subtitle="Heating Engineer", email="mail@tuttle.example", + VAT_number="27B-6", address=Address(street="Main Street", number="450", postal_code="55555", city="Sao Paolo", country="Brazil"), ) ) diff --git a/tuttle_tests/test_one_final_invoice_per_project.py b/tuttle_tests/test_one_final_invoice_per_project.py index e686a847..c94d4f7f 100644 --- a/tuttle_tests/test_one_final_invoice_per_project.py +++ b/tuttle_tests/test_one_final_invoice_per_project.py @@ -33,7 +33,10 @@ def _contract(*percentages: str) -> Contract: return Contract( term_of_payment=14, title="Central Heating Overhaul", - client=Client(name="Sam Lowry"), + client=Client( + name="Sam Lowry", + address=Address(street="Main Street", number="9999", postal_code="55555", city="Sao Paolo", country="Brazil"), + ), start_date=datetime.date(2026, 1, 10), type=ContractType.fixed_price, fixed_price=Decimal("10000"), @@ -66,6 +69,7 @@ def _persist(engine, *percentages: str) -> tuple[int, list[int]]: name="Harry Tuttle", subtitle="Heating Engineer", email="mail@tuttle.example", + VAT_number="27B-6", address=Address(street="Main Street", number="450", postal_code="55555", city="Sao Paolo", country="Brazil"), ) ) diff --git a/tuttle_tests/test_refuse_invoice_without_mandatory_details.py b/tuttle_tests/test_refuse_invoice_without_mandatory_details.py new file mode 100644 index 00000000..4903694c --- /dev/null +++ b/tuttle_tests/test_refuse_invoice_without_mandatory_details.py @@ -0,0 +1,289 @@ +"""No invoice is issued without the details the law requires. + +§14 Abs. 4 UStG and EN16931 want the seller's name, address and VAT number or +tax number, and the buyer's name and address; an e-invoice also wants payment +terms. The documents left out whatever was missing and the XML still passed +schema validation, so nobody noticed. Issuing is now refused with a message +that names what to add, before a number is taken or anything is saved. +""" + +import datetime +from decimal import Decimal + +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + +from tuttle import demo +from tuttle.app.core.abstractions import SQLModelDataSourceMixin +from tuttle.app.invoicing.intent import InvoicingIntent +from tuttle.einvoice import serialize_zugferd_xml +from tuttle.invoicing import missing_mandatory_details +from tuttle.model import ( + Address, + Client, + Contact, + Contract, + Invoice, + InvoiceItem, + PaymentMilestone, + Project, + TaxCategory, + User, +) +from tuttle.time import ContractType, Cycle + +DATE = datetime.date(2026, 2, 1) + + +def _address(**overrides) -> Address: + fields = dict(street="Main Street", number="42", postal_code="55555", city="Somewhere", country="Germany") + return Address(**{**fields, **overrides}) + + +def _user(**overrides) -> User: + fields = dict( + name="Harry Tuttle", + subtitle="Heating Engineer", + email="mail@tuttle.example", + VAT_number="DE123456789", + address=_address(number="450"), + ) + return User(**{**fields, **overrides}) + + +def _contract(client: Client, **overrides) -> Contract: + fields = dict( + title="Duct Inspection", + client=client, + start_date=datetime.date(2026, 1, 10), + rate=Decimal("80"), + currency="EUR", + VAT_rate=Decimal("0.19"), + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) + return Contract(**{**fields, **overrides}) + + +def _central_services(**overrides) -> Client: + return Client(**{"name": "Central Services", "address": _address(), **overrides}) + + +class TestMissingMandatoryDetails: + def test_complete_details_pass(self): + assert missing_mandatory_details(_user(), _contract(_central_services()), "EN16931") is None + + def test_names_everything_that_is_missing_in_one_message(self): + user = _user(VAT_number=None) + contract = _contract(_central_services(address=None)) + + assert missing_mandatory_details(user, contract) == ( + "Add your VAT number or tax number and Central Services' address before issuing this invoice." + ) + + def test_names_the_parts_of_an_incomplete_address(self): + user = _user(address=_address(street="", city=" ")) + + assert missing_mandatory_details(user, _contract(_central_services())) == ( + "Add the street and city of your address before issuing this invoice." + ) + + def test_house_number_and_postcode_are_not_required(self): + user = _user(address=_address(number="", postal_code="")) + + assert missing_mandatory_details(user, _contract(_central_services())) is None + + def test_a_tax_number_identifies_the_seller_too(self): + user = _user(VAT_number=None, tax_number="21/815/08150") + + assert missing_mandatory_details(user, _contract(_central_services())) is None + + def test_outside_the_scope_of_vat_the_tax_number_is_needed(self): + # BR-O-02 keeps the VAT number off such an invoice. + contract = _contract(_central_services(), VAT_rate=Decimal("0"), VAT_category=TaxCategory.outside_scope) + + assert missing_mandatory_details(_user(), contract) == "Add your tax number before issuing this invoice." + assert missing_mandatory_details(_user(tax_number="21/815/08150"), contract) is None + + def test_names_the_invoicing_contact_whose_address_is_printed(self): + contact = Contact(first_name="Jill", last_name="Layton", address=_address(city="")) + client = _central_services(invoicing_contact=contact) + + assert missing_mandatory_details(_user(), _contract(client)) == ( + "Add the city of Jill Layton's address before issuing this invoice." + ) + + def test_the_client_address_serves_when_the_contact_has_none(self): + client = _central_services(invoicing_contact=Contact(first_name="Jill", last_name="Layton")) + + assert missing_mandatory_details(_user(), _contract(client)) is None + + def test_an_e_invoice_needs_a_term_of_payment(self): + contract = _contract(_central_services(), term_of_payment=None) + + assert missing_mandatory_details(_user(), contract, "EN16931") == ( + "Add the contract's term of payment before issuing this invoice." + ) + assert missing_mandatory_details(_user(), contract, None) is None + + def test_a_term_of_zero_days_is_a_term(self): + contract = _contract(_central_services(), term_of_payment=0) + + assert missing_mandatory_details(_user(), contract, "EN16931") is None + + def test_names_the_document(self): + user = _user(VAT_number=None) + + message = missing_mandatory_details(user, _contract(_central_services()), document="reminder") + + assert message == "Add your VAT number or tax number before issuing this reminder." + + +def test_the_e_invoice_states_the_address_the_pdf_shows(): + """A client reached through its invoicing contact has the contact's address on both documents.""" + contact = Contact(first_name="Jill", last_name="Layton", address=_address(street="Ministry Road", city="Somewhere")) + contract = _contract(Client(name="Central Services", invoicing_contact=contact)) + invoice = Invoice( + number="2026-02-01-01", + date=DATE, + contract=contract, + items=[ + InvoiceItem( + quantity=8, unit="hour", unit_price=Decimal("80"), VAT_rate=Decimal("0.19"), description="Duct inspection" + ) + ], + ) + + xml = serialize_zugferd_xml(invoice, _user(operating_country="Germany")).decode() + buyer = xml.split("")[1].split("")[0] + + assert "Ministry Road 42" in buyer + assert "Somewhere" in buyer + + +def test_the_demo_satisfies_the_check(): + user = demo.create_demo_user() + projects, _invoices, _contacts = demo.create_heating_data(user) + + refusals = {p.contract.title: missing_mandatory_details(user, p.contract, "EN16931") for p in projects} + + assert {title: message for title, message in refusals.items() if message} == {} + + +# -- Issuing ------------------------------------------------------------------ + + +@pytest.fixture +def engine(monkeypatch): + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + monkeypatch.setattr(SQLModelDataSourceMixin, "create_session", lambda self: Session(engine)) + return engine + + +def _persist(engine, user: User, contract: Contract) -> int: + with Session(engine) as session: + session.add(user) + project = Project( + title="Duct Inspection", + description="Ducts", + tag="#ducts", + start_date=datetime.date(2026, 1, 10), + contract=contract, + ) + session.add(project) + session.commit() + return project.id + + +def _invoices(engine) -> list[Invoice]: + with Session(engine) as session: + return list(session.exec(select(Invoice)).all()) + + +def _fixed_price(**overrides) -> Contract: + return _contract( + _central_services(address=None), + type=ContractType.fixed_price, + rate=None, + fixed_price=Decimal("10000"), + payment_milestones=[ + PaymentMilestone(title="Upfront", percentage=Decimal("40"), position=0), + PaymentMilestone(title="On delivery", percentage=Decimal("60"), position=1), + ], + **overrides, + ) + + +EXPECTED = "Add your VAT number or tax number and Central Services' address before issuing this {}." + + +class TestIssuingIsRefused: + def test_for_an_invoice(self, engine): + project_id = _persist(engine, _user(VAT_number=None), _contract(_central_services(address=None))) + + result = InvoicingIntent().create( + project_id, + DATE, + DATE, + DATE, + manual_items=[{"description": "Inspection", "quantity": 8, "unit": "hour", "unit_price": 80}], + ) + + assert not result.was_intent_successful + assert result.error_msg == EXPECTED.format("invoice") + assert _invoices(engine) == [] + + def test_for_a_deposit_invoice(self, engine): + project_id = _persist(engine, _user(VAT_number=None), _fixed_price()) + intent = InvoicingIntent() + milestone_id = intent._projects_intent.get_by_id(project_id).data.contract.payment_milestones[0].id + + result = intent.create_deposit(project_id, milestone_id, DATE) + + assert result.error_msg == EXPECTED.format("deposit invoice") + assert _invoices(engine) == [] + with Session(engine) as session: + assert not session.get(PaymentMilestone, milestone_id).invoiced + + def test_for_a_final_invoice(self, engine): + project_id = _persist(engine, _user(VAT_number=None), _fixed_price()) + + result = InvoicingIntent().create_final(project_id, DATE) + + assert result.error_msg == EXPECTED.format("final invoice") + assert _invoices(engine) == [] + + def test_for_a_reminder(self, engine): + project_id = _persist(engine, _user(), _contract(_central_services())) + intent = InvoicingIntent() + invoice = intent.create(project_id, DATE, DATE, DATE, manual_quantity=8).data + with Session(engine) as session: + session.exec(select(User)).one().VAT_number = None + session.commit() + + result = intent.create_reminder(invoice.id, "2026-03-01", "2026-03-15") + + assert result.error_msg == "Add your VAT number or tax number before issuing this reminder." + assert [inv.id for inv in _invoices(engine)] == [invoice.id] + + def test_for_an_e_invoice_without_a_term_of_payment(self, engine): + project_id = _persist(engine, _user(), _contract(_central_services())) + with Session(engine) as session: + # Cleared on an existing contract; a new one would get the default term. + session.exec(select(Contract)).one().term_of_payment = None + session.commit() + + result = InvoicingIntent().create(project_id, DATE, DATE, DATE, manual_quantity=8) + + assert result.error_msg == "Add the contract's term of payment before issuing this invoice." + assert _invoices(engine) == [] + + +def test_complete_details_issue_the_invoice(engine): + project_id = _persist(engine, _user(), _contract(_central_services())) + + result = InvoicingIntent().create(project_id, DATE, DATE, DATE, manual_quantity=8) + + assert result.was_intent_successful, result.error_msg + assert len(_invoices(engine)) == 1