@@ -73,7 +74,7 @@
{% macro settlement_lines_table(invoice, deposit_deductions, label="totals-label", value="totals-value") %}
{% endif %}
+ {% if is_final or deposit_deductions %}{{ doc.settlement_lines_table(invoice, deposit_deductions) }}{% endif %}
{% if is_reminder and invoice.reminder_fee %}
{% 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 17d480bb..3ae3e9a8 100644
--- a/tuttle/app/invoicing/data_source.py
+++ b/tuttle/app/invoicing/data_source.py
@@ -15,27 +15,19 @@ 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
+ @staticmethod
+ def _hydrate(entities, _depth: int = 2):
+ """Also load the settlement that a reminder of a final invoice restates.
- 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
+ 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.
"""
- 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,
- )
+ 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."""
diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py
index dd19bbcd..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,
@@ -258,23 +263,24 @@ 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.",
+ )
+ 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)
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,
@@ -327,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
@@ -365,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()
@@ -373,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,
@@ -420,17 +427,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:
@@ -524,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)
@@ -729,30 +728,42 @@ 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,
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:
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,
@@ -979,7 +990,9 @@ def toggle_invoice_sent_status(self, invoice: Invoice) -> IntentResult[Invoice]:
logger.exception(ex)
return IntentResult(
was_intent_successful=False,
- error_msg=f"Failed to toggle the invoice sent status: {ex}",
+ error_msg=(
+ "The sent status could not be changed because of an unexpected error. Details are in the application log."
+ ),
)
def toggle_invoice_paid_status(self, invoice: Invoice) -> IntentResult[Invoice]:
@@ -987,8 +1000,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
@@ -999,11 +1013,16 @@ 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.
- 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
+ # 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.
+ 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
dep_fresh = self._invoicing_data_source.get_invoice_by_id(dep.id)
if dep_fresh.was_intent_successful and dep_fresh.data:
dep_fresh.data.paid = True
@@ -1019,36 +1038,63 @@ def toggle_invoice_paid_status(self, invoice: Invoice) -> IntentResult[Invoice]:
logger.exception(ex)
return IntentResult(
was_intent_successful=False,
- error_msg=f"Failed to toggle the invoice paid status: {ex}",
+ error_msg=(
+ "The paid status could not be changed because of an unexpected error. Details are in the application log."
+ ),
)
def toggle_invoice_cancelled_status(self, invoice: Invoice) -> IntentResult[Invoice]:
- """
- Toggles the "cancelled" status of an invoice and updates it in the data source.
-
- Parameters:
- invoice (Invoice):
- The invoice object whose "cancelled" status will be toggled.
+ """Toggle cancelled status. Propagates from an invoice to its reminders.
- 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.
"""
+ 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
+ if final is not None and not final.cancelled:
+ return IntentResult(
+ was_intent_successful=False,
+ error_msg=(
+ f"Deposit invoice {invoice.number} is settled by final invoice {final.number}. "
+ "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}")
logger.exception(ex)
return IntentResult(
was_intent_successful=False,
- error_msg=f"Failed to toggle the invoice cancelled status: {ex}",
+ error_msg=(
+ "The invoice could not be cancelled or restored because of an unexpected error. "
+ "Details are in the application log."
+ ),
)
def view_invoice(self, invoice: Invoice) -> IntentResult[Path]:
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 3832ffc3..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:
@@ -914,7 +919,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 +966,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 +1165,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,
@@ -1423,13 +1443,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,
@@ -1442,9 +1486,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)
@@ -1455,8 +1500,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_cancel_reminders_with_invoice.py b/tuttle_tests/test_cancel_reminders_with_invoice.py
new file mode 100644
index 00000000..a199bb97
--- /dev/null
+++ b/tuttle_tests/test_cancel_reminders_with_invoice.py
@@ -0,0 +1,146 @@
+"""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",
+ VAT_number="27B-6",
+ 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",
+ 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"),
+ 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
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..a5b26011
--- /dev/null
+++ b/tuttle_tests/test_final_invoice_reminder_balance.py
@@ -0,0 +1,182 @@
+"""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",
+ 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")],
+ )
+
+
+def _contract() -> Contract:
+ return Contract(
+ term_of_payment=14,
+ title="Central Heating Overhaul",
+ 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"),
+ 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/tuttle_tests/test_keep_settled_deposits.py b/tuttle_tests/test_keep_settled_deposits.py
new file mode 100644
index 00000000..6a8c8057
--- /dev/null
+++ b/tuttle_tests/test_keep_settled_deposits.py
@@ -0,0 +1,160 @@
+"""A deposit stays live while a final invoice settles it.
+
+The final invoice deducts each of its deposits. A deposit cancelled behind its
+back would still be deducted, so the client would be billed too little, and
+revenue would lose the deposit's amount.
+"""
+
+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, 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 _ok(result):
+ assert result.was_intent_successful, result.error_msg
+ return result.data
+
+
+@pytest.fixture
+def settlement(in_memory_db) -> tuple[InvoicingIntent, list[Invoice], Invoice]:
+ """Two 40% deposits and the final invoice that settles them on 10,000 net."""
+ with Session(in_memory_db) 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",
+ 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",
+ 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"),
+ 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"))
+ ],
+ )
+ 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 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 _cancel_behind_the_final(engine, deposit: Invoice) -> None:
+ """The state an older version left behind when a settled deposit was cancelled."""
+ with Session(engine) as session:
+ row = session.get(Invoice, deposit.id)
+ row.cancelled = True
+ session.add(row)
+ session.commit()
+
+
+class TestCancellingASettledDeposit:
+ def test_is_refused_until_the_final_invoice_is_cancelled(self, settlement):
+ intent, (deposit, _), final = settlement
+
+ result = intent.toggle_cancelled(deposit.id)
+
+ assert not result.was_intent_successful
+ assert result.error_msg == (
+ f"Deposit invoice {deposit.number} is settled by final invoice {final.number}. "
+ "Cancel the final invoice first, then the deposit."
+ )
+ assert not _load(intent, deposit).cancelled
+ # 11,900 gross less two deposits of 4,760.
+ assert _load(intent, final).remaining_balance == Decimal("2380.00")
+
+ def test_is_allowed_once_the_final_invoice_is_cancelled(self, settlement):
+ intent, (deposit, _), final = settlement
+ _ok(intent.toggle_cancelled(final.id))
+
+ _ok(intent.toggle_cancelled(deposit.id))
+
+ assert _load(intent, deposit).cancelled
+
+ def test_restoring_a_cancelled_deposit_is_allowed(self, in_memory_db, settlement):
+ intent, (deposit, _), _final = settlement
+ _cancel_behind_the_final(in_memory_db, deposit)
+
+ _ok(intent.toggle_cancelled(deposit.id))
+
+ assert not _load(intent, deposit).cancelled
+
+
+class TestPayingTheFinalInvoice:
+ def test_marks_its_deposits_paid(self, settlement):
+ intent, deposits, final = settlement
+
+ _ok(intent.toggle_paid(final.id))
+
+ assert [_load(intent, d).paid for d in deposits] == [True, True]
+
+ def test_leaves_a_cancelled_deposit_unpaid(self, in_memory_db, settlement):
+ intent, (cancelled, live), final = settlement
+ _cancel_behind_the_final(in_memory_db, cancelled)
+
+ _ok(intent.toggle_paid(final.id))
+
+ assert not _load(intent, cancelled).paid
+ assert _load(intent, live).paid
+
+
+@pytest.mark.parametrize("toggle", ["toggle_sent", "toggle_paid", "toggle_cancelled"])
+def test_a_failed_status_change_says_where_the_details_are(settlement, monkeypatch, toggle):
+ """The invoice view shows the message, so it must not be a raw exception."""
+ intent, _, final = settlement
+
+ def broken_save(invoice):
+ raise RuntimeError("database is locked")
+
+ monkeypatch.setattr(intent._invoicing_data_source, "save_invoice", broken_save)
+ result = getattr(intent, toggle)(final.id)
+
+ assert not result.was_intent_successful
+ assert "unexpected error. Details are in the application log." in result.error_msg
+ assert "database is locked" not in result.error_msg
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
new file mode 100644
index 00000000..c94d4f7f
--- /dev/null
+++ b/tuttle_tests/test_one_final_invoice_per_project.py
@@ -0,0 +1,181 @@
+"""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",
+ 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"),
+ 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",
+ VAT_number="27B-6",
+ 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/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
diff --git a/ui/src/components/invoicing/InvoicingView.tsx b/ui/src/components/invoicing/InvoicingView.tsx
index fd8ba953..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";
@@ -52,6 +52,7 @@ export function InvoicingView() {
const [createOpen, setCreateOpen] = useState(false);
const [newlyCreatedId, setNewlyCreatedId] = useState(null);
const [mailError, setMailError] = useState(null);
+ const [statusError, setStatusError] = useState(null);
const [renderWarning, setRenderWarning] = useState(null);
const defaultColumn = useCallback(
@@ -67,7 +68,7 @@ export function InvoicingView() {
navScrolled.current = true;
document.querySelector(`[data-invoice-id="${navFilter.invoiceId}"]`)?.scrollIntoView({ block: "center" });
}, [invoices]); // eslint-disable-line react-hooks/exhaustive-deps
- useEffect(() => { setMailError(null); }, [selected?.id]);
+ useEffect(() => { setMailError(null); setStatusError(null); }, [selected?.id]);
async function load(selectId?: number) {
setLoading(true);
@@ -127,9 +128,16 @@ export function InvoicingView() {
preferred: navFilter.contractId != null ? (inv) => num(inv, "contract_id") === navFilter.contractId : undefined,
});
- async function toggleSent(id: number) { await rpc("invoicing.toggle_sent", { id }); load(); }
- async function togglePaid(id: number) { await rpc("invoicing.toggle_paid", { id }); load(); }
- async function toggleCancelled(id: number) { await rpc("invoicing.toggle_cancelled", { id }); load(); }
+ // A status change can be refused, e.g. cancelling a deposit that a final invoice settles.
+ async function toggleStatus(method: "toggle_sent" | "toggle_paid" | "toggle_cancelled", id: number) {
+ setStatusError(null);
+ const res = await rpc(`invoicing.${method}`, { id });
+ if (!res.ok) setStatusError(res.error || "The invoice status could not be changed.");
+ load();
+ }
+ async function toggleSent(id: number) { await toggleStatus("toggle_sent", id); }
+ async function togglePaid(id: number) { await toggleStatus("toggle_paid", id); }
+ async function toggleCancelled(id: number) { await toggleStatus("toggle_cancelled", id); }
async function sendMail(id: number) {
setMailError(null);
const res = await rpc("invoicing.send_mail", { id });
@@ -176,6 +184,14 @@ export function InvoicingView() {
)}
+ {statusError && (
+
+
+
{statusError}
+
setStatusError(null)} className="text-status-danger hover:text-status-danger/80">✕
+
+ )}
+
{loadFailed ? (
load()} />
) : loading && invoices.length === 0 ? (
@@ -411,7 +427,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 +613,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 (
{ setDocType(value); setSelectedMilestoneId(null); }}
- title={disabled ? "Invoice at least one milestone first" : undefined}
+ title={disabled ? disabledReason : undefined}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition-colors border
${docType === value ? "border-accent bg-accent/15 text-primary" : "border-border-subtle text-tertiary"}
${disabled ? "opacity-40 cursor-not-allowed" : ""}`}>
@@ -1113,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);
@@ -1202,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 && (
+
+ )}
>
) : (