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 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} + +
+ )} + {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 (
- {/* 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 && ( + + )} ) : (