From b49dc1446312911764be2acc7d9ee3ebc753f99b Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 28 Jun 2026 12:04:23 +0200 Subject: [PATCH 1/5] feat(invoicing): deposit & final invoices (Abschlagsrechnung / Schlussrechnung) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #326 — linked partial invoice chains with milestone-based payment schedules. Schema: - Add PaymentMilestone table for contract payment schedules - Extend Invoice.document_type with "deposit" and "final" - Add deposit chain FK (deposit_for_id) and milestone FK - Alembic migration for all new columns/tables Invoicing logic: - generate_deposit_invoice() for milestone-based deposits - generate_final_invoice() showing full amount with deposit deductions - Last-milestone shortcut auto-creates final invoice - toggle_paid propagates across deposit chains Rendering: - Legally compliant PDF layout for deposit and final invoices - Deposit deduction lines with VAT breakdown on final invoices - i18n labels (EN/DE/ES) for all new document types UI: - Milestone editor on contract detail view - Document type picker (Invoice / Milestone) in create dialog - Deposit chain visualization in invoice list - MilestoneScheduleBadge showing progress - Deposit/final invoice detail views Known issues: - Milestone paid count badge may show stale data - PDF rendering for final invoices needs session hydration fix Co-authored-by: Cursor --- templates/invoice-modern/invoice.css | 48 ++ templates/invoice-modern/invoice.html | 52 +- tuttle/app/contracts/intent.py | 132 ++++- tuttle/app/invoicing/data_source.py | 22 + tuttle/app/invoicing/intent.py | 227 +++++++- tuttle/demo.py | 135 ++++- tuttle/invoicing.py | 121 +++- ...1100c34b90c6_deposit_and_final_invoices.py | 109 ++++ tuttle/model.py | 165 +++++- tuttle/rendering.py | 64 +++ tuttle_tests/test_rendering.py | 17 + tuttle_tests/test_rpc_dispatch.py | 145 +++++ ui/src/api/entity.ts | 78 +++ ui/src/components/business/ContractsView.tsx | 187 +++++- ui/src/components/invoicing/InvoicingView.tsx | 530 ++++++++++++++++-- ui/src/components/layout/Shell.tsx | 1 + 16 files changed, 1950 insertions(+), 83 deletions(-) create mode 100644 tuttle/migrations/versions/1100c34b90c6_deposit_and_final_invoices.py diff --git a/templates/invoice-modern/invoice.css b/templates/invoice-modern/invoice.css index f2bd8019..c4dadfbf 100644 --- a/templates/invoice-modern/invoice.css +++ b/templates/invoice-modern/invoice.css @@ -326,3 +326,51 @@ body { line-height: 1.4; color: #555; } + +/* ── Document type (deposit / final / reminder) ─ */ + +.document-type-banner { + font-size: 13pt; + font-weight: 700; + letter-spacing: 0.6pt; + text-transform: uppercase; + padding: 9pt 12pt; + margin-bottom: 10pt; +} + +.document-type-banner.deposit { + background: #e8f0fe; + color: #1e40af; + border-left: 4pt solid #2563eb; +} + +.document-type-banner.final { + background: #f3e8ff; + color: #6b21a8; + border-left: 4pt solid #7c3aed; +} + +.document-type-banner.reminder { + background: #fef3c7; + color: #92400e; + border-left: 4pt solid #f59e0b; +} + +.deposit-context { + font-size: 9pt; + color: #444; + margin-bottom: 12pt; + line-height: 1.65; + padding: 8pt 10pt; + background: #f9fafb; + border: 0.5pt solid #e5e7eb; +} + +.deposit-context .label { + color: var(--invoice-accent); + font-weight: 600; + text-transform: uppercase; + font-size: 7pt; + letter-spacing: 0.4pt; + margin-right: 4pt; +} diff --git a/templates/invoice-modern/invoice.html b/templates/invoice-modern/invoice.html index d9c8db03..6381810c 100644 --- a/templates/invoice-modern/invoice.html +++ b/templates/invoice-modern/invoice.html @@ -2,7 +2,7 @@ - {% if is_reminder %}{{ reminder_title }} – {{ invoice.number }}{% else %}{{ l.invoice_no }} {{ invoice.number }}{% endif %} + {% if is_reminder %}{{ reminder_title }} – {{ invoice.number }}{% elif is_deposit %}{{ l.deposit_invoice }} {{ invoice.number }}{% elif is_final %}{{ l.final_invoice }} {{ invoice.number }}{% else %}{{ l.invoice_no }} {{ invoice.number }}{% endif %} {% if accent_color %}{% endif %} @@ -46,12 +46,30 @@
{% if is_reminder %} -
{{ reminder_title }}
+
{{ reminder_title }}
+ {% elif is_deposit %} +
{{ l.deposit_invoice }}
+ {% elif is_final %} +
{{ l.final_invoice }}
+ {% endif %} + + {% if is_deposit and (contract_title or milestone_title) %} +
+ {% if contract_title %} +
{{ l.in_respect_of }} {{ contract_title }}
+ {% endif %} + {% if milestone_title %} +
{{ l.payment_milestone }} {{ milestone_title }}{% if milestone_percentage %} ({{ milestone_percentage }}%){% endif %}
+ {% endif %} + {% if contract_total %} +
{{ l.contract_total }} {{ contract_total | as_currency }}
+ {% endif %} +
{% endif %} - + {% if is_reminder %} @@ -115,7 +133,7 @@
- {{ l.subtotal }} + {% if is_final %}{{ l.total_fee }}{% else %}{{ l.subtotal }}{% endif %} {{ invoice.sum | as_currency }}
{% if not invoice.is_outside_scope %} @@ -130,11 +148,33 @@ {{ invoice.reminder_fee | as_currency }}
{% endif %} + {% if is_final %} +
+ {{ l.gross }} + {{ invoice.total | as_currency }} +
+ {% for dep in deposit_deductions %} +
+ {{ l.less_deposit }} {{ dep.invoice_number }} + −{{ dep.gross | as_currency }} +
+
+ ({{ l.vat_included_therein }}: {{ dep.vat | as_currency }}) + +
+ {% endfor %}
- {{ l.total_due }} + {{ l.remaining_balance }} + {{ remaining_balance | as_currency }} +
+ {% else %} +
+
+ {% if is_deposit %}{{ l.deposit_due }}{% else %}{{ l.total_due }}{% endif %} {{ invoice.total | as_currency }}
+ {% endif %} @@ -143,7 +183,7 @@ {% endif %}
-

{% if is_reminder %}{{ l.reminder_closing }}{% elif notes %}{{ notes }}{% else %}{{ l.closing }}{% endif %}

+

{% if is_reminder %}{{ l.reminder_closing }}{% elif is_deposit %}{{ l.deposit_closing }}{% elif notes %}{{ notes }}{% else %}{{ l.closing }}{% endif %}

{% if include_signature and user.signature %} {% else %} diff --git a/tuttle/app/contracts/intent.py b/tuttle/app/contracts/intent.py index 5b077624..0ed7c109 100644 --- a/tuttle/app/contracts/intent.py +++ b/tuttle/app/contracts/intent.py @@ -1,6 +1,7 @@ from decimal import Decimal, InvalidOperation +from typing import Optional -from ...model import Client, Contract, ContractCharge, User +from ...model import Client, Contract, ContractCharge, PaymentMilestone, User from ...tax import get_tax_system from ...time import ChargeBasis from ..clients.intent import ClientsIntent @@ -27,6 +28,17 @@ def _parse_amount(value) -> Decimal: raise ValueError(f"Additional charge amount must be a number, got {value!r}") +def _parse_milestone_number(value, field: str) -> Decimal: + """Coerce a milestone percentage or amount from JSON into a Decimal.""" + try: + parsed = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + raise ValueError(f"Milestone {field} must be a number, got {value!r}") + if parsed <= 0: + raise ValueError(f"Milestone {field} must be greater than zero, got {value!r}") + return parsed + + def _parse_basis(value) -> ChargeBasis: """Coerce a charge basis from JSON into the enum. @@ -52,7 +64,7 @@ class ContractsIntent(CrudIntent): ("projects", "projects", lambda p: p.title), ("invoices", "invoices", lambda i: i.number or f"#{i.id}"), ] - __save_skip__ = {"client", "projects", "invoices"} + __save_skip__ = {"client", "projects", "invoices", "payment_milestones"} def __init__(self): super().__init__() @@ -193,3 +205,119 @@ def _describe_save_error(exc) -> str: return "Failed to save the contract." toggle_complete_status = CrudIntent.toggle_completed + + # -- Milestone management -------------------------------------------------- + + def save_milestones(self, contract_id, milestones) -> IntentResult: + """Replace a contract's payment schedule with the incoming rows. + + Rows are matched to existing milestones by id so an edit preserves + identity, and with it the record of whether the instalment has already + been invoiced. Every rule is checked before anything is written: a + half-applied schedule whose parts no longer sum to the contract total + would silently under- or over-bill the client. + + Each entry is a dict with keys: id, title, percentage, amount. + """ + result = self.get_by_id(contract_id) + if not result.was_intent_successful or not result.data: + return IntentResult( + was_intent_successful=False, + error_msg="Contract not found.", + ) + contract = result.data + + existing_by_id = {m.id: m for m in contract.payment_milestones if m.id is not None} + incoming_ids = set() + rows = [] + for position, raw in enumerate(milestones): + mid = raw.get("id") + pct = raw.get("percentage") + amt = raw.get("amount") + try: + percentage = _parse_milestone_number(pct, "percentage") if pct is not None else None + amount = _parse_milestone_number(amt, "amount") if amt is not None else None + except ValueError as e: + return IntentResult(was_intent_successful=False, error_msg=str(e)) + existing = existing_by_id.get(mid) if mid else None + if existing is not None: + incoming_ids.add(mid) + rows.append( + { + "existing": existing, + "title": raw.get("title") or (existing.title if existing else ""), + "percentage": percentage, + "amount": amount, + "position": position, + } + ) + + error = self._validate_milestone_schedule(contract, rows, existing_by_id, incoming_ids) + if error: + return IntentResult(was_intent_successful=False, error_msg=error) + + for old_id, old_ms in existing_by_id.items(): + if old_id not in incoming_ids: + self.delete_by_id(PaymentMilestone, old_id) + + for row in rows: + ms = row["existing"] + if ms is None: + ms = PaymentMilestone(contract_id=contract_id, invoiced=False) + ms.title = row["title"] + ms.percentage = row["percentage"] + ms.amount = row["amount"] + ms.position = row["position"] + self.store(ms) + + return IntentResult(was_intent_successful=True) + + @staticmethod + def _validate_milestone_schedule(contract, rows, existing_by_id, incoming_ids) -> Optional[str]: + """Why a payment schedule cannot be saved, or None when it is sound.""" + for old_id, old_ms in existing_by_id.items(): + if old_id not in incoming_ids and old_ms.invoiced: + return f"Cannot remove milestone '{old_ms.title}' — it has already been invoiced." + + for row in rows: + existing = row["existing"] + if existing is None or not existing.invoiced: + continue + if row["percentage"] != existing.percentage or row["amount"] != existing.amount: + return f"Cannot change the amount of milestone '{existing.title}' — it has already been invoiced." + + if not rows: + return None + + if not any(row["title"].strip() for row in rows): + return "Every payment milestone needs a title." + + if all(row["percentage"] is not None and row["amount"] is None for row in rows): + total = sum(row["percentage"] for row in rows) + if total != Decimal("100"): + return f"Milestone percentages must sum to 100% (currently {total}%)." + return None + + if all(row["amount"] is not None and row["percentage"] is None for row in rows): + if contract.fixed_price is None: + return "Amount-based milestones require a fixed-price contract." + total = sum(row["amount"] for row in rows) + fixed = Decimal(str(contract.fixed_price)) + if total != fixed: + return f"Milestone amounts must sum to the contract fixed price ({fixed}, currently {total})." + return None + + return "Each milestone must use either a percentage or an amount, consistently across the schedule." + + def get_milestones(self, contract_id) -> IntentResult: + """Get all payment milestones for a contract.""" + result = self.get_by_id(contract_id) + if not result.was_intent_successful or not result.data: + return IntentResult( + was_intent_successful=False, + error_msg="Contract not found.", + ) + return IntentResult( + was_intent_successful=True, + data=result.data.payment_milestones, + ) diff --git a/tuttle/app/invoicing/data_source.py b/tuttle/app/invoicing/data_source.py index f0f534e0..818f19fd 100644 --- a/tuttle/app/invoicing/data_source.py +++ b/tuttle/app/invoicing/data_source.py @@ -175,6 +175,28 @@ def get_billed_charge_ids(self, contract_id: int) -> Set[int]: ).all() return {row for row in rows if row is not None} + def get_deposit_invoices(self, contract_id: int, project_id: int) -> IntentResult[List[Invoice]]: + """Deposit invoices of one project, oldest first, ready to be settled. + + Cancelled deposits are left out: a voided instalment was never charged, + so deducting it on the final invoice would short the total owed. + """ + try: + deposits = [ + inv + for inv in self.query(Invoice) + if inv.is_deposit and inv.contract_id == contract_id and inv.project_id == project_id and not inv.cancelled + ] + deposits.sort(key=lambda inv: (inv.date, inv.id or 0)) + return IntentResult(was_intent_successful=True, data=deposits) + except Exception as ex: + return IntentResult( + was_intent_successful=False, + error_msg="Could not load the deposit invoices of this project.", + log_message=f"InvoicingDataSource.get_deposit_invoices({contract_id}, {project_id}): {ex}", + exception=ex, + ) + def get_all_reminders_for_invoice(self, invoice_id: int) -> List[Invoice]: """Return only the reminders (not the root) for a given root invoice id.""" with self.create_session() as session: diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index 7e27f966..b7436ca4 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -34,6 +34,11 @@ from .data_source import InvoicingDataSource +def _as_date(value) -> date: + """Coerce an RPC date argument, which arrives as an ISO string, to a date.""" + return value if isinstance(value, date) else _dt.date.fromisoformat(value) + + class InvoicingIntent(Intent): """Invoicing CRUD, creation orchestration, and status toggles.""" @@ -139,6 +144,211 @@ def _to_date(v): template_name=template_name, ) + def create_deposit( + self, + project_id, + milestone_id, + invoice_date, + ) -> IntentResult[Invoice]: + """Create a deposit invoice (Abschlagsrechnung) for one payment milestone. + + Invoicing the last open milestone produces a final invoice instead: the + closing instalment of a schedule *is* the settlement, and issuing it as + a plain deposit would leave the contract without the Schlussrechnung + that German tax law expects. + """ + proj_result = self._projects_intent.get_by_id(project_id) + if not proj_result.was_intent_successful or proj_result.data is None: + return IntentResult(was_intent_successful=False, error_msg="Project not found.") + + project = proj_result.data + contract = project.contract + if contract is None or not contract.is_fixed_price: + return IntentResult( + was_intent_successful=False, + error_msg="Deposit invoices require a fixed-price contract.", + ) + + milestone = next((m for m in contract.payment_milestones if m.id == int(milestone_id)), None) + if milestone is None: + return IntentResult(was_intent_successful=False, error_msg="Payment milestone not found.") + if milestone.invoiced: + return IntentResult( + was_intent_successful=False, + error_msg="This milestone has already been invoiced.", + ) + + open_milestones = [m for m in contract.payment_milestones if not m.invoiced] + if len(open_milestones) == 1: + # ``create_final`` closes out every remaining milestone itself. + return self.create_final(project_id, invoice_date) + + try: + invoice = invoicing.generate_deposit_invoice( + contract=contract, + project=project, + milestone=milestone, + number=self._next_invoice_number(invoice_date), + date=_as_date(invoice_date), + ) + for item in invoice.items: + item.validate_vat() + + self._invoicing_data_source.save_invoice(invoice) + self._mark_milestone_invoiced(milestone) + + invoice, warnings = self._render_saved_invoice(invoice.id, "deposit invoice") + return IntentResult( + was_intent_successful=True, + data=invoice, + warning="; ".join(warnings), + ) + except Exception as ex: + logger.error(f"Failed to create deposit invoice: {ex}") + logger.exception(ex) + return IntentResult( + was_intent_successful=False, + error_msg=f"Failed to create deposit invoice: {ex}", + ) + + def create_final( + self, + project_id, + invoice_date, + ) -> IntentResult[Invoice]: + """Create a final invoice (Schlussrechnung) settling a contract's deposits.""" + proj_result = self._projects_intent.get_by_id(project_id) + if not proj_result.was_intent_successful or proj_result.data is None: + return IntentResult(was_intent_successful=False, error_msg="Project not found.") + + project = proj_result.data + contract = project.contract + if contract is None or not contract.is_fixed_price: + return IntentResult( + was_intent_successful=False, + error_msg="Final invoices require a fixed-price contract.", + ) + + 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 [] + + already_settled = [d for d in deposit_invoices if d.deposit_for_id is not None] + 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, + project=project, + deposit_invoices=deposit_invoices, + number=self._next_invoice_number(invoice_date), + date=_as_date(invoice_date), + charges=self._eligible_charges(contract), + ) + for item in invoice.items: + item.validate_vat() + + # ``generate_final_invoice`` cannot set deposit_for_id before the + # final invoice has an id, so the chain is linked after the insert. + self._invoicing_data_source.save_invoice(invoice) + for dep in deposit_invoices: + dep.deposit_for_id = invoice.id + self._invoicing_data_source.save_invoice(dep) + + # The settlement bills whatever the deposits left, so no milestone + # of this contract is still open once it exists. + for milestone in contract.payment_milestones: + if not milestone.invoiced: + self._mark_milestone_invoiced(milestone) + + invoice, warnings = self._render_saved_invoice(invoice.id, "final invoice") + + unpaid = [d for d in deposit_invoices if not d.paid] + if unpaid: + nums = ", ".join(d.number or f"#{d.id}" for d in unpaid) + warnings.append(f"Deposit invoices still unpaid: {nums}") + + return IntentResult( + was_intent_successful=True, + data=invoice, + warning="; ".join(warnings), + ) + except Exception as ex: + logger.error(f"Failed to create final invoice: {ex}") + logger.exception(ex) + return IntentResult( + was_intent_successful=False, + error_msg=f"Failed to create final invoice: {ex}", + ) + + def _mark_milestone_invoiced(self, milestone) -> None: + milestone.invoiced = True + self._invoicing_data_source.store(milestone) + + def _next_invoice_number(self, invoice_date) -> str: + app_db = AppDatabase() + scheme = app_db.get_setting(PreferencesStorageKeys.invoice_number_scheme_key.value) or DEFAULT_INVOICE_NUMBER_SCHEME + return self._invoicing_data_source.generate_invoice_number(_as_date(invoice_date), scheme=scheme) + + def _render_saved_invoice(self, invoice_id: int, description: str) -> tuple[Invoice, list[str]]: + """Render a persisted invoice's PDF, returning it and any warnings. + + Rendering happens from a freshly loaded instance so that the deposit + chain and milestone are hydrated — a final invoice rendered from the + in-memory object would print no deduction lines and overstate what the + client owes. + """ + warnings: list[str] = [] + reloaded = self._invoicing_data_source.get_invoice_by_id(invoice_id) + invoice = reloaded.data if reloaded.was_intent_successful and reloaded.data else None + if invoice is None: + warnings.append(f"The {description} was saved but could not be reloaded for rendering.") + return invoice, warnings + + options = self._resolved_render_options() + try: + rendering.render_invoice( + user=self._user_data_source.get_user(), + invoice=invoice, + out_dir=get_data_dir() / "Invoices", + only_final=True, + **options, + ) + self._invoicing_data_source.save_invoice(invoice) + except Exception as ex: + logger.error(f"Error rendering {description}: {ex}") + logger.exception(ex) + warnings.append(f"Invoice PDF could not be generated: {ex}") + return invoice, warnings + + def _resolved_render_options(self) -> dict: + """Template, language and layout preferences for rendering a document.""" + app_db = AppDatabase() + language = app_db.get_setting(PreferencesStorageKeys.language_key.value) or "en" + + def _pref(getter, default): + result = getter() + if result.was_intent_successful and result.data is not None: + return result.data + return default + + return { + "language": language, + "template_name": _pref( + self._preferences_intent.get_preferred_invoice_template, + DEFAULT_INVOICE_TEMPLATE, + ), + "include_logo": _pref(self._preferences_intent.get_include_logo, True), + "include_due_date": _pref(self._preferences_intent.get_include_due_date, True), + "include_signature": _pref(self._preferences_intent.get_include_signature, True), + "accent_color": self._user_data_source.get_user().accent_color or "", + } + def toggle_sent(self, id) -> IntentResult: return self._toggle("sent", id) @@ -729,13 +939,12 @@ def toggle_invoice_sent_status(self, invoice: Invoice) -> IntentResult[Invoice]: ) def toggle_invoice_paid_status(self, invoice: Invoice) -> IntentResult[Invoice]: - """Toggle paid status. Propagates across the entire reminder chain.""" + """Toggle paid status. Propagates across reminder and deposit chains.""" 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: - # Re-load each invoice in its own session to avoid detached errors fresh = self._invoicing_data_source.get_invoice_by_id(inv.id) if fresh.was_intent_successful and fresh.data: fresh.data.paid = new_paid @@ -743,7 +952,19 @@ def toggle_invoice_paid_status(self, invoice: Invoice) -> IntentResult[Invoice]: else: invoice.paid = new_paid self._invoicing_data_source.save_invoice(invoice) - # Return a fresh copy of the toggled 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 + for dep in final.deposits: + 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 + self._invoicing_data_source.save_invoice(dep_fresh.data) + result = self._invoicing_data_source.get_invoice_by_id(invoice.id) return IntentResult( was_intent_successful=True, diff --git a/tuttle/demo.py b/tuttle/demo.py index 5effbe28..387e0d86 100644 --- a/tuttle/demo.py +++ b/tuttle/demo.py @@ -24,10 +24,12 @@ Contact, Contract, ContractCharge, + ContractType, Cycle, FinancialGoal, Invoice, InvoiceItem, + PaymentMilestone, Project, TaxCategory, Timesheet, @@ -622,27 +624,45 @@ def create_heating_data( # -- contracts (one per client) -------------------------------------------- contracts = [] + sam_lowry_contract = None for i, client in enumerate(clients): if client is sam_lowry: - rate = 0 - title = "Heating Repair" + # Fixed price with a milestone schedule: the deposit / final + # invoice workflow (Abschlagsrechnung / Schlussrechnung). + contract = Contract( + title=f"Heating Repair – {client.name}", + client=client, + signature_date=fake.date_between(start_date="-30M", end_date="-24M"), + start_date=fake.date_between(start_date="-24M", end_date="-20M"), + type=ContractType.fixed_price, + rate=None, + fixed_price=Decimal("5000"), + currency="EUR", + VAT_rate=Decimal("0.19"), + unit=TimeUnit.hour, + units_per_workday=8, + volume=40, + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) + sam_lowry_contract = contract else: rate = random.choice([65, 72, 80, 85, 95]) title = _HEATING_CONTRACTS[i % len(_HEATING_CONTRACTS)] - contract = Contract( - title=f"{title} – {client.name}", - client=client, - signature_date=fake.date_between(start_date="-30M", end_date="-24M"), - start_date=fake.date_between(start_date="-24M", end_date="-20M"), - rate=rate, - currency="EUR", - VAT_rate=Decimal("0.19"), - unit=TimeUnit.hour, - units_per_workday=8, - volume=random.randint(100, 400), - term_of_payment=14, - billing_cycle=Cycle.monthly, - ) + contract = Contract( + title=f"{title} – {client.name}", + client=client, + signature_date=fake.date_between(start_date="-30M", end_date="-24M"), + start_date=fake.date_between(start_date="-24M", end_date="-20M"), + rate=rate, + currency="EUR", + VAT_rate=Decimal("0.19"), + unit=TimeUnit.hour, + units_per_workday=8, + volume=random.randint(100, 400), + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) contracts.append(contract) _CANONICAL_PROJECTS = { @@ -731,9 +751,14 @@ def create_heating_data( today = datetime.date.today() invoices = [us_invoice] + sam_lowry_project = None + # The last project is the US one, already invoiced above. Sam Lowry's is + # billed through the milestone schedule further down, not as a lump sum. for i, project in enumerate(projects[:-1]): + if project.contract is sam_lowry_contract: + sam_lowry_project = project + continue if i < 2: - # First two invoices: sent but unpaid, dated 30+ days ago → overdue inv_date = today - timedelta(days=random.randint(30, 60)) inv = create_fake_invoice( fake, @@ -746,9 +771,84 @@ def create_heating_data( else: inv = create_fake_invoice(fake, project=project, user=user) invoices.append(inv) + + if sam_lowry_contract and sam_lowry_project: + invoices.extend( + create_milestone_invoices( + contract=sam_lowry_contract, + project=sam_lowry_project, + user=user, + today=today, + ) + ) + return projects, invoices, client_contacts +def create_milestone_invoices( + contract: Contract, + project: Project, + user: User, + today: date, +) -> List[Invoice]: + """Bill a fixed-price contract in two instalments, deposit then settlement. + + Demonstrates the Abschlagsrechnung / Schlussrechnung chain: the first + milestone is invoiced as a paid deposit, the second closes the contract + with a final invoice that states the full price and deducts the deposit. + """ + # Attaching to the contract is what persists them: the schedule reaches the + # database through the contract's milestone cascade. + schedule = [ + PaymentMilestone( + contract=contract, + title=title, + percentage=Decimal("50"), + position=position, + invoiced=True, + ) + for position, title in enumerate(("Half upfront on commissioning", "Half on delivery")) + ] + first = schedule[0] + + deposit_date = today - timedelta(days=45) + deposit = invoicing.generate_deposit_invoice( + contract=contract, + project=project, + milestone=first, + number=f"{deposit_date.strftime('%Y-%m-%d')}-{next(invoice_number_counter)}", + date=deposit_date, + ) + deposit.milestone = first + deposit.sent = True + deposit.paid = True + + final_date = today - timedelta(days=7) + final = invoicing.generate_final_invoice( + contract=contract, + project=project, + deposit_invoices=[deposit], + number=f"{final_date.strftime('%Y-%m-%d')}-{next(invoice_number_counter)}", + date=final_date, + ) + final.sent = True + final.paid = False + + for invoice, label in ((deposit, "deposit"), (final, "final")): + try: + rendering.render_invoice( + user=user, + invoice=invoice, + out_dir=get_data_dir() / "Invoices", + only_final=True, + ) + logger.info(f"✅ rendered {label} invoice for {project.title}") + except Exception as ex: + logger.error(f"❌ Error rendering {label} invoice for {project.title}: {ex}") + + return [deposit, final] + + def create_fake_data( user: User, n: int = 10, @@ -1011,6 +1111,7 @@ def install_demo_data( ) except Exception as ex: logger.warning(f"Could not render demo invoice {inv.number}: {ex}") + session.commit() logger.info("Adding financial goals...") with Session(db_engine) as session: diff --git a/tuttle/invoicing.py b/tuttle/invoicing.py index 656b05ad..1d80adac 100644 --- a/tuttle/invoicing.py +++ b/tuttle/invoicing.py @@ -4,7 +4,15 @@ from decimal import Decimal from typing import Dict, List, Optional, Sequence -from .model import Contract, ContractCharge, Invoice, InvoiceItem, Project, User +from .model import ( + Contract, + ContractCharge, + Invoice, + InvoiceItem, + PaymentMilestone, + Project, + User, +) from .time import ChargeBasis from .timetracking import Timesheet @@ -175,6 +183,117 @@ def generate_fixed_price_invoice( return invoice +def milestone_amount(milestone: PaymentMilestone, contract: Contract) -> Decimal: + """The net amount a milestone bills, resolved against the contract total. + + A milestone is expressed either as an absolute amount or as a percentage + of the contract's fixed price. Percentages are quantized to cents, so a + schedule of thirds bills 3,333.33 rather than a repeating fraction; the + resulting rounding difference is absorbed by the final invoice, which + deducts the deposits actually issued. + """ + if milestone.amount is not None: + return Decimal(str(milestone.amount)).quantize(Decimal("0.01")) + if milestone.percentage is None: + raise ValueError("Milestone must have either a percentage or an amount.") + if contract.fixed_price is None: + raise ValueError("Percentage milestones require a fixed-price contract.") + total_price = Decimal(str(contract.fixed_price)) + share = total_price * Decimal(str(milestone.percentage)) / Decimal("100") + return share.quantize(Decimal("0.01")) + + +def generate_deposit_invoice( + contract: Contract, + project: Project, + milestone: PaymentMilestone, + number: str, + date: datetime.date = datetime.date.today(), +) -> Invoice: + """Create a deposit invoice (Abschlagsrechnung) for a payment milestone. + + Contract charges are deliberately not billed here: a handling fee or setup + cost belongs on the final settlement, not repeated on every instalment. + """ + if contract.fixed_price is None: + raise ValueError("Deposit invoices require a fixed-price contract.") + + invoice = Invoice( + date=date, + document_type="deposit", + contract=contract, + contract_id=contract.id, + project=project, + project_id=project.id, + number=number, + milestone_id=milestone.id, + ) + item = InvoiceItem( + quantity=1, + unit="fixed_price", + unit_price=milestone_amount(milestone, contract), + VAT_rate=_contract_vat_rate(contract), + VAT_category=contract.VAT_category, + description=milestone.title, + ) + invoice.items.append(item) + return invoice + + +def generate_final_invoice( + contract: Contract, + project: Project, + deposit_invoices: List[Invoice], + number: str, + date: datetime.date = datetime.date.today(), + charges: Optional[Sequence[ContractCharge]] = None, +) -> Invoice: + """Create a final invoice (Schlussrechnung) for a fixed-price contract. + + German tax law requires the Schlussrechnung to state the *full* contract + amount with its VAT, then deduct the gross amounts already invoiced as + deposits — so the line items here carry the whole fixed price, not the + remainder. The deduction lines themselves come from the model's + ``deposit_deductions``, and ``Invoice.remaining_balance`` is what the + client actually owes. + """ + if contract.fixed_price is None: + raise ValueError("Final invoices require a fixed-price contract.") + + invoice = Invoice( + date=date, + document_type="final", + contract=contract, + contract_id=contract.id, + project=project, + project_id=project.id, + number=number, + ) + item = InvoiceItem( + quantity=1, + unit="fixed_price", + unit_price=Decimal(str(contract.fixed_price)), + VAT_rate=_contract_vat_rate(contract), + VAT_category=contract.VAT_category, + description=contract.title, + ) + invoice.items.append(item) + # Charges land on the settlement only — a per-invoice fee must not be + # multiplied across the instalments of a single contract. + invoice.items.extend( + build_charge_items( + _applicable_charges(contract, charges), + contract=contract, + ) + ) + + for dep in deposit_invoices: + dep.deposit_for_id = invoice.id + + invoice.deposits = deposit_invoices + return invoice + + def generate_invoice_email( invoice: Invoice, user: User, diff --git a/tuttle/migrations/versions/1100c34b90c6_deposit_and_final_invoices.py b/tuttle/migrations/versions/1100c34b90c6_deposit_and_final_invoices.py new file mode 100644 index 00000000..0fb4b913 --- /dev/null +++ b/tuttle/migrations/versions/1100c34b90c6_deposit_and_final_invoices.py @@ -0,0 +1,109 @@ +"""deposit and final invoices + +Revision ID: 1100c34b90c6 +Revises: 34dd17917a18 +Create Date: 2026-06-28 10:36:58.702409 + +====================================================================== +FROZEN HISTORICAL SNAPSHOT — NOT THE SCHEMA SOURCE OF TRUTH. + +The source of truth is tuttle/model.py. This file captures the schema +DELTA from the previous revision to this point in history. It is +APPEND-ONLY: once committed, never edit it. To change the schema, edit +tuttle/model.py and run `just migrate ""` to ADD a new revision. + +Reading this file to learn the current schema is a MISTAKE — it is a +point-in-time snapshot. Read tuttle/model.py instead. +====================================================================== + +MANDATORY REVIEW CHECKLIST before committing this file: + +1. RENAMES — autogenerate emits drop_column + add_column for renames, + which DESTROYS DATA. If you intended a rename, replace the pair with + op.alter_column(
{{ l.invoice_no }}{% if is_deposit %}{{ l.deposit_invoice }}{% elif is_final %}{{ l.final_invoice }}{% else %}{{ l.invoice_no }}{% endif %} {{ invoice.number }}
, , new_column_name=). + +2. NO MODEL IMPORTS — never `from tuttle.model import ...` here. + Model classes drift over time; this script must be pinned to the + schema at this point in history. For data transformations, declare + a local sa.table(...) snapshot with only the columns this revision + touches. + +3. BATCH MODE — render_as_batch=True rebuilds tables for SQLite. After + a batch op on a table with foreign keys, verify integrity inside the + migration: op.execute("PRAGMA foreign_key_check"). + +See tuttle/migrations/README.md. +---------------------------------------------------------------------- +""" + +# pyright: reportAttributeAccessIssue=false +# sqlmodel.sql.sqltypes is a submodule resolved at runtime; basedpyright +# does not statically expose `sql` as an attribute of `sqlmodel`. +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlmodel +import sqlmodel.sql.sqltypes # noqa: F401 — ensures runtime resolution of AutoString +from alembic import op + +revision: str = "1100c34b90c6" +down_revision: Union[str, Sequence[str], None] = "9cad5ae77a79" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema. + + Idempotent: a prior failed run may have created ``paymentmilestone`` before + the invoice batch step failed (SQLite DDL is not transactional). Re-running + must not error on objects that already exist. + """ + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "paymentmilestone" not in tables: + op.create_table( + "paymentmilestone", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("contract_id", sa.Integer(), nullable=False), + sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("percentage", sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column("amount", sa.Numeric(precision=12, scale=2), nullable=True), + sa.Column("position", sa.Integer(), nullable=False), + sa.Column("invoiced", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(["contract_id"], ["contract.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + + invoice_cols = {c["name"] for c in inspector.get_columns("invoice")} + with op.batch_alter_table("invoice", schema=None) as batch_op: + if "deposit_for_id" not in invoice_cols: + batch_op.add_column(sa.Column("deposit_for_id", sa.Integer(), nullable=True)) + if "milestone_id" not in invoice_cols: + batch_op.add_column(sa.Column("milestone_id", sa.Integer(), nullable=True)) + batch_op.create_foreign_key("fk_invoice_deposit_for_id", "invoice", ["deposit_for_id"], ["id"]) + batch_op.create_foreign_key( + "fk_invoice_milestone_id", + "paymentmilestone", + ["milestone_id"], + ["id"], + ) + + op.execute("PRAGMA foreign_key_check") + + +def downgrade() -> None: + """Downgrades are not supported. + + Tuttle is a single-user desktop app. Rolling back schema is destructive + (data in dropped columns is lost) and offers nothing over restoring a + timestamped backup from ensure_schema()'s pre-upgrade snapshot. + + If you need to iterate on a migration during development: + 1. Delete this revision file (versions/1100c34b90c6_*.py) + 2. Run `just reset` to wipe ~/.tuttle + 3. Edit model.py, run `just migrate` again + """ + raise NotImplementedError("Downgrades are not supported. Restore from a .bak- snapshot instead.") diff --git a/tuttle/model.py b/tuttle/model.py index ce4513d7..b1a80e84 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -38,7 +38,7 @@ from .fx import convert, primary_currency, rate from .time import ChargeBasis, ContractType, Cycle, TimeUnit -DocumentType = Literal["invoice", "reminder"] +DocumentType = Literal["invoice", "reminder", "deposit", "final"] class RpcMixin: @@ -517,8 +517,9 @@ class Contract(RpcMixin, VatCategoryMixin, SQLModel, table=True): "invoices": ("id",), "charges": None, "bank_account": None, + "payment_milestones": None, } - __rpc_computed__ = ("unit_abbrev", "is_fixed_price") + __rpc_computed__ = ("unit_abbrev", "is_fixed_price", "has_milestones") id: Optional[int] = Field(default=None, primary_key=True) title: str = Field( @@ -631,11 +632,23 @@ class Contract(RpcMixin, VatCategoryMixin, SQLModel, table=True): "order_by": "ContractCharge.position", }, ) + payment_milestones: List["PaymentMilestone"] = Relationship( + back_populates="contract", + sa_relationship_kwargs={ + "lazy": "subquery", + "cascade": "all, delete", + "order_by": "PaymentMilestone.position", + }, + ) @property def is_fixed_price(self) -> bool: return self.type == ContractType.fixed_price + @property + def has_milestones(self) -> bool: + return bool(self.payment_milestones) + @property def unit_abbrev(self) -> str: """Short display label for the billing unit, e.g. 'h' or 'd'.""" @@ -791,6 +804,37 @@ def effective_unit(self, contract: "Contract") -> str: return "flat" +class PaymentMilestone(RpcMixin, SQLModel, table=True): + """A payment milestone defines one instalment in a contract's payment schedule. + + Used for deposit/final invoice workflows (Abschlagsrechnung / Schlussrechnung). + """ + + id: Optional[int] = Field(default=None, primary_key=True) + contract_id: int = Field(foreign_key="contract.id", ondelete="CASCADE") + title: str = Field(description="e.g. 'Upon commissioning', 'On delivery'") + percentage: Optional[Decimal] = Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Numeric(5, 2), nullable=True), + description="Milestone as a percentage of the contract total (e.g. 50).", + ) + amount: Optional[Decimal] = Field( + default=None, + sa_column=sqlalchemy.Column(sqlalchemy.Numeric(12, 2), nullable=True), + description="Milestone as an absolute amount (alternative to percentage).", + ) + position: int = Field(default=0, description="Ordering position.") + invoiced: bool = Field( + default=False, + description="Whether a deposit invoice has been created for this milestone.", + ) + + contract: "Contract" = Relationship( + back_populates="payment_milestones", + sa_relationship_kwargs={"lazy": "subquery"}, + ) + + class Project(RpcMixin, SQLModel, table=True): """A project is a group of contract work for a client.""" @@ -971,15 +1015,25 @@ def empty(self) -> bool: class Invoice(RpcMixin, SQLModel, table=True): - """An invoice or payment reminder. + """An invoice, payment reminder, deposit invoice, or final invoice. - Reminders reuse the same table (manual STI) with ``document_type`` - as the discriminator. A reminder references its predecessor via - ``reminder_for_id``, forming a singly-linked chain back to the - original invoice. + All document types reuse the same table (manual STI) with + ``document_type`` as the discriminator: + + - ``"invoice"`` — regular invoice + - ``"reminder"`` — payment reminder (linked via ``reminder_for_id``) + - ``"deposit"`` — deposit / advance payment invoice (Abschlagsrechnung) + - ``"final"`` — final settlement invoice (Schlussrechnung), + deducts prior deposits (linked via ``deposit_for_id``) """ - __rpc_relationships__ = ("contract", "project", "items") + __rpc_relationships__ = { + "contract": None, + "project": None, + "items": None, + "milestone": ("id", "title"), + "deposits": None, + } __rpc_computed__ = ( "sum", "VAT_total", @@ -997,9 +1051,15 @@ class Invoice(RpcMixin, SQLModel, table=True): "total_primary_formatted", "pdf_path", "is_reminder", + "is_deposit", + "is_final_invoice", "reminder_chain_head_id", + "deposit_chain_head_id", "has_timesheet", "timesheet_pdf_path", + "remaining_balance", + "remaining_balance_formatted", + "deposit_deductions", ) id: Optional[int] = Field(default=None, primary_key=True) @@ -1012,8 +1072,8 @@ class Invoice(RpcMixin, SQLModel, table=True): document_type: str = Field( default="invoice", - description="'invoice' for a regular invoice, 'reminder' for a payment reminder.", - schema_extra={"enum": ["invoice", "reminder"]}, + description="Discriminator: 'invoice', 'reminder', 'deposit', or 'final'.", + schema_extra={"enum": ["invoice", "reminder", "deposit", "final"]}, ) # -- Reminder-specific fields (NULL for regular invoices) -------------- @@ -1037,6 +1097,19 @@ class Invoice(RpcMixin, SQLModel, table=True): description="New payment deadline set by this reminder.", ) + # -- Deposit/final invoice fields (NULL for regular invoices) ---------- + + deposit_for_id: Optional[int] = Field( + default=None, + foreign_key="invoice.id", + description="FK to the final invoice this deposit belongs to.", + ) + milestone_id: Optional[int] = Field( + default=None, + foreign_key="paymentmilestone.id", + description="FK to the PaymentMilestone this deposit invoice covers.", + ) + # -- Relationships ----------------------------------------------------- # Invoice n:1 Contract @@ -1077,6 +1150,27 @@ class Invoice(RpcMixin, SQLModel, table=True): }, ) + # Self-referencing: deposit chain + deposit_for: Optional["Invoice"] = Relationship( + back_populates="deposits", + sa_relationship_kwargs={ + "remote_side": "Invoice.id", + "foreign_keys": "[Invoice.deposit_for_id]", + "lazy": "subquery", + }, + ) + deposits: List["Invoice"] = Relationship( + back_populates="deposit_for", + sa_relationship_kwargs={ + "foreign_keys": "[Invoice.deposit_for_id]", + "lazy": "subquery", + }, + ) + + milestone: Optional["PaymentMilestone"] = Relationship( + sa_relationship_kwargs={"lazy": "subquery"}, + ) + # -- Status flags ------------------------------------------------------ sent: Optional[bool] = Field(default=False) @@ -1114,6 +1208,14 @@ def __repr__(self): def is_reminder(self) -> bool: return self.document_type == "reminder" + @property + def is_deposit(self) -> bool: + return self.document_type == "deposit" + + @property + def is_final_invoice(self) -> bool: + return self.document_type == "final" + @property def sum(self) -> Decimal: """Sum over all invoice items.""" @@ -1193,6 +1295,45 @@ def reminder_chain_head_id(self) -> Optional[int]: node = parent return node.id + @property + def deposit_chain_head_id(self) -> Optional[int]: + """For a deposit invoice, return the final invoice id if linked, else None.""" + if self.is_deposit: + return self.deposit_for_id + if self.is_final_invoice: + return self.id + return 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: + return [] + deductions = [] + for dep in self.deposits: + deductions.append( + { + "invoice_number": dep.number, + "gross": dep.total, + "vat": dep.VAT_total, + "net": dep.sum, + } + ) + return deductions + + @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 + deposit_gross = sum(d["gross"] for d in self.deposit_deductions) + return Decimal(self.total - deposit_gross) + + @property + def remaining_balance_formatted(self) -> str: + currency = self.contract.currency if self.contract else "EUR" + return fmt_currency(self.remaining_balance, currency) + @property def client(self): return self.contract.client @@ -1207,6 +1348,10 @@ def prefix(self): base = f"{safe_number}-{client_suffix}" if self.is_reminder: return f"{base}-M{self.reminder_level}" + if self.is_deposit: + return f"{base}-deposit" + if self.is_final_invoice: + return f"{base}-final" return base @property diff --git a/tuttle/rendering.py b/tuttle/rendering.py index 43aa1c9a..b057918f 100644 --- a/tuttle/rendering.py +++ b/tuttle/rendering.py @@ -54,6 +54,18 @@ "reminder_fee": "Reminder Fee", "original_invoice": "Original Invoice", "reminder_closing": "Please settle the outstanding amount by the new due date.", + "deposit_invoice": "Deposit Invoice", + "final_invoice": "Final Invoice", + "total_fee": "Total fee", + "less_deposit": "less deposit per invoice no.", + "vat_included_therein": "VAT included therein", + "remaining_balance": "Remaining balance", + "gross": "Gross", + "deposit_due": "Deposit due", + "in_respect_of": "In respect of", + "payment_milestone": "Payment milestone", + "contract_total": "Contract total", + "deposit_closing": "This is a partial payment (deposit invoice) towards the contract total.", "units": { "hour": ("hour", "hours"), "day": ("day", "days"), @@ -89,6 +101,18 @@ "reminder_fee": "Mahngebühr", "original_invoice": "Ursprungsrechnung", "reminder_closing": "Bitte begleichen Sie den offenen Betrag bis zum neuen Fälligkeitsdatum.", + "deposit_invoice": "Abschlagsrechnung", + "final_invoice": "Schlussrechnung", + "total_fee": "Gesamthonorar", + "less_deposit": "abzgl. Abschlag lt. Rechnung Nr.", + "vat_included_therein": "darin enthaltene USt.", + "remaining_balance": "Restbetrag", + "gross": "Brutto", + "deposit_due": "Abschlagsbetrag", + "in_respect_of": "Betreffend", + "payment_milestone": "Zahlungsmeilenstein", + "contract_total": "Vertragsgesamtbetrag", + "deposit_closing": "Dies ist eine Teilzahlung (Abschlagsrechnung) auf den Vertragsgesamtbetrag.", "units": { "hour": ("Stunde", "Stunden"), "day": ("Tag", "Tage"), @@ -124,6 +148,18 @@ "reminder_fee": "Cargo por recordatorio", "original_invoice": "Factura original", "reminder_closing": "Le rogamos abone el importe pendiente antes de la nueva fecha de vencimiento.", + "deposit_invoice": "Factura de anticipo", + "final_invoice": "Factura final", + "total_fee": "Honorario total", + "less_deposit": "menos anticipo según factura n.º", + "vat_included_therein": "IVA incluido", + "remaining_balance": "Saldo pendiente", + "gross": "Bruto", + "deposit_due": "Anticipo a pagar", + "in_respect_of": "Referente a", + "payment_milestone": "Hito de pago", + "contract_total": "Importe total del contrato", + "deposit_closing": "Este documento es un pago parcial (factura de anticipo) sobre el importe total del contrato.", "units": { "hour": ("hora", "horas"), "day": ("día", "días"), @@ -287,6 +323,26 @@ def unit_label(raw_unit, quantity=None): payee_account = (invoice.contract.bank_account if invoice.contract else None) or user.bank_account qr_code_data_uri = generate_payment_qr(payee_account, invoice) if include_qr_code else None + # 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. + is_deposit = invoice.is_deposit + is_final = invoice.is_final_invoice + deposit_deductions = invoice.deposit_deductions + remaining_balance = invoice.remaining_balance + + contract_title = "" + contract_total = None + milestone_title = "" + milestone_percentage = None + if invoice.contract: + contract_title = invoice.contract.title or "" + contract_total = invoice.contract.fixed_price + if is_deposit and invoice.milestone is not None: + milestone_title = invoice.milestone.title or "" + milestone_percentage = invoice.milestone.percentage + invoice_template = template_env.get_template("invoice.html") html = invoice_template.render( user=user, @@ -296,6 +352,14 @@ def unit_label(raw_unit, quantity=None): seller_tax_id_label=seller_tax_id_label, is_reminder=is_reminder, reminder_title=reminder_title, + is_deposit=is_deposit, + is_final=is_final, + deposit_deductions=deposit_deductions, + remaining_balance=remaining_balance, + contract_title=contract_title, + contract_total=contract_total, + milestone_title=milestone_title, + milestone_percentage=milestone_percentage, notes=invoice.notes, include_logo=include_logo, include_due_date=include_due_date, diff --git a/tuttle_tests/test_rendering.py b/tuttle_tests/test_rendering.py index 57330a9b..6e8c806f 100644 --- a/tuttle_tests/test_rendering.py +++ b/tuttle_tests/test_rendering.py @@ -77,6 +77,23 @@ def test_returns_html_when_out_dir_is_none(self, fake): assert isinstance(result, str) + def test_deposit_invoice_html_shows_document_type(self, fake): + user = demo.create_fake_user(fake) + invoice = demo.create_fake_invoice(fake) + invoice.document_type = "deposit" + + html = rendering.render_invoice( + user=user, + invoice=invoice, + out_dir=None, + document_format="html", + only_final=False, + ) + + assert "document-type-banner deposit" in html + assert "Deposit Invoice" in html + assert "Deposit due" in html + def test_creates_only_final_file(self, fake): user = demo.create_fake_user(fake) invoice = demo.create_fake_invoice(fake) diff --git a/tuttle_tests/test_rpc_dispatch.py b/tuttle_tests/test_rpc_dispatch.py index 59463264..d3a81fdf 100644 --- a/tuttle_tests/test_rpc_dispatch.py +++ b/tuttle_tests/test_rpc_dispatch.py @@ -10,15 +10,26 @@ import importlib import json +from decimal import Decimal from pathlib import Path import pytest +import sqlmodel import tuttle.app import tuttle.app.core.abstractions as abstractions import tuttle.app_db as app_db_mod from tuttle.app.core.dispatch import _intents, dispatch from tuttle.app.core.rpc_utils import reset_all +from tuttle.model import ( + Client, + Contact, + Contract, + ContractType, + Invoice, + Project, + User, +) # --------------------------------------------------------------------------- # Discover every RPC domain on disk: a subpackage of tuttle.app with intent.py @@ -359,6 +370,140 @@ def test_invoices_computed_properties(self, rpc_env): for prop in ("sum", "total", "status", "due_date"): assert prop in invoice, f"Invoice missing computed property '{prop}'" + def test_all_rpc_computed_props_survive_session_close(self, rpc_env): + """Every __rpc_computed__ property must be serialisable after the DB + session closes — catches DetachedInstanceError from lazy-loaded + relationships accessed inside computed properties.""" + models_routes = [ + (User, "users.get_active"), + (Contact, "contacts.get_all"), + (Client, "clients.get_all"), + (Contract, "contracts.get_all"), + (Project, "projects.get_all"), + (Invoice, "invoicing.get_all"), + ] + for model_cls, route in models_routes: + computed = getattr(model_cls, "__rpc_computed__", ()) + if not computed: + continue + result = dispatch(route, {}) + assert result["ok"], f"{route} failed: {result.get('error')}" + items = result["data"] + if not isinstance(items, list): + items = [items] + assert len(items) > 0, f"{route} returned no data" + for prop in computed: + for item in items: + assert prop in item, f"{model_cls.__name__} missing computed prop '{prop}' after serialisation via {route}" + + def test_deposit_and_final_invoice_serialize(self, rpc_env): + """A final invoice with linked deposits must serialise without + DetachedInstanceError when invoicing.get_all runs.""" + dispatch("db.ensure", {}) + + engine = sqlmodel.create_engine(f"sqlite:///{abstractions._active_db_path}") + with sqlmodel.Session(engine) as sess: + # A contract that does not already carry a schedule from the demo + # data, so this test owns the whole milestone lifecycle. + contract = next( + (c for c in sess.exec(sqlmodel.select(Contract)).all() if c.projects and not c.payment_milestones), + None, + ) + assert contract is not None, "No schedule-free contract with projects in demo DB" + contract.type = ContractType.fixed_price + contract.rate = None + contract.fixed_price = Decimal("10000") + sess.add(contract) + sess.commit() + contract_id = contract.id + + reset_all() + + contracts_res = dispatch("contracts.get_all", {}) + assert_ok(contracts_res) + contracts = contracts_res["data"] or [] + target = next((c for c in contracts if c["id"] == contract_id), None) + assert target is not None + project_ids = [p["id"] for p in target.get("projects", [])] + assert project_ids, "Contract has no projects" + project_id = project_ids[0] + + reset_all() + + ms_res = dispatch( + "contracts.save_milestones", + { + "contract_id": contract_id, + "milestones": [ + {"title": "Upfront", "percentage": 50, "position": 0}, + {"title": "On delivery", "percentage": 50, "position": 1}, + ], + }, + ) + assert ms_res["ok"], f"save_milestones failed: {ms_res.get('error')}" + + reset_all() + + ms_list = dispatch( + "contracts.get_milestones", + { + "contract_id": contract_id, + }, + ) + assert ms_list["ok"], f"get_milestones failed: {ms_list.get('error')}" + milestones = ms_list["data"] + assert len(milestones) == 2 + + deposit_res = dispatch( + "invoicing.create_deposit", + { + "project_id": project_id, + "milestone_id": milestones[0]["id"], + "invoice_date": "2026-06-28", + }, + ) + assert deposit_res["ok"], f"create_deposit failed: {deposit_res.get('error')}" + + reset_all() + + result = dispatch("invoicing.get_all", {}) + assert result["ok"], f"invoicing.get_all failed after deposit creation: {result.get('error')}" + data = result["data"] + deposit = next((i for i in data if i.get("document_type") == "deposit"), None) + assert deposit is not None, "Deposit invoice not in get_all results" + assert deposit.get("deposit_deductions") is not None + assert deposit.get("remaining_balance") is not None + try: + json.dumps(deposit) + except (TypeError, ValueError) as exc: + pytest.fail(f"Deposit invoice not JSON-serializable: {exc}") + + reset_all() + + deposit2_res = dispatch( + "invoicing.create_deposit", + { + "project_id": project_id, + "milestone_id": milestones[1]["id"], + "invoice_date": "2026-06-28", + }, + ) + assert deposit2_res["ok"], f"create_deposit (last milestone / final) failed: {deposit2_res.get('error')}" + + reset_all() + + result2 = dispatch("invoicing.get_all", {}) + assert result2["ok"], f"invoicing.get_all failed after final invoice creation: {result2.get('error')}" + data2 = result2["data"] + final = next((i for i in data2 if i.get("document_type") == "final"), None) + assert final is not None, "Final invoice not in get_all results — last milestone should auto-create a final invoice" + assert isinstance(final.get("deposit_deductions"), list) + assert final.get("remaining_balance") is not None + try: + json.dumps(final) + except (TypeError, ValueError) as exc: + pytest.fail(f"Final invoice not JSON-serializable: {exc}") + def test_full_response_is_json_serializable(self, rpc_env): for method in [ "projects.get_all", diff --git a/ui/src/api/entity.ts b/ui/src/api/entity.ts index 18811f1c..e8e49a0e 100644 --- a/ui/src/api/entity.ts +++ b/ui/src/api/entity.ts @@ -154,6 +154,14 @@ export function isReminder(e: Entity): boolean { return bool(e, "is_reminder") || str(e, "document_type") === "reminder"; } +export function isDeposit(e: Entity): boolean { + return bool(e, "is_deposit") || str(e, "document_type") === "deposit"; +} + +export function isFinalInvoice(e: Entity): boolean { + return bool(e, "is_final_invoice") || str(e, "document_type") === "final"; +} + export function reminderLevel(e: Entity): number { return num(e, "reminder_level"); } @@ -163,3 +171,73 @@ export function reminderChainHeadId(e: Entity): number | null { if (v == null) return null; return typeof v === "number" ? v : null; } + +export function depositChainHeadId(e: Entity): number | null { + const v = e.deposit_chain_head_id; + if (v == null) return null; + return typeof v === "number" ? v : null; +} + +/** Milestone title or first line-item description for a deposit invoice. */ +export function depositMilestoneLabel(e: Entity): string { + const title = deepStr(e, "milestone.title"); + if (title) return title; + const items = list(e, "items"); + if (items.length > 0) { + const desc = str(items[0], "description"); + if (desc) return desc; + } + return ""; +} + +export type MilestoneScheduleStatus = { + /** Milestones in the contract's payment schedule. */ + total: number; + /** Milestones already billed. */ + invoicedCount: number; + /** Documents issued for this schedule: the deposits plus the final invoice. */ + issuedCount: number; + /** How many of those documents are paid. */ + paidCount: number; + hasFinal: boolean; + /** Nothing left to bill or collect on this contract. */ + settled: boolean; +}; + +export function chainDepositInvoices(root: Entity, nestedDeposits: Entity[]): Entity[] { + const deps = nestedDeposits.filter(isDeposit); + if (isDeposit(root)) return [root, ...deps]; + return deps; +} + +/** Progress of a contract's payment schedule across a grouped invoice chain. + * + * Derived from the invoices themselves rather than the milestones' `invoiced` + * flags: the invoice list is what the view reloads, so a count taken from it + * cannot go stale against a contract snapshot embedded in an older invoice. + */ +export function milestoneScheduleStatus( + root: Entity, + nestedDeposits: Entity[], +): MilestoneScheduleStatus | null { + const contract = entity(root, "contract"); + if (!contract) return null; + const total = list(contract, "payment_milestones").length; + if (total === 0) return null; + + const deposits = chainDepositInvoices(root, nestedDeposits); + const hasFinal = isFinalInvoice(root); + const issued = hasFinal ? [...deposits, root] : deposits; + const paidCount = issued.filter((inv) => invoiceStatus(inv) === "Paid").length; + + // A final invoice settles every milestone the deposits did not cover, so its + // presence means the whole schedule has been billed out. + const covered = new Set(deposits.map((d) => int(d, "milestone_id")).filter((id) => id > 0)); + const invoicedCount = hasFinal ? total : covered.size; + + const settled = hasFinal + ? invoiceStatus(root) === "Paid" + : invoicedCount === total && issued.length > 0 && paidCount === issued.length; + + return { total, invoicedCount, issuedCount: issued.length, paidCount, hasFinal, settled }; +} diff --git a/ui/src/components/business/ContractsView.tsx b/ui/src/components/business/ContractsView.tsx index 5962c914..862d3eea 100644 --- a/ui/src/components/business/ContractsView.tsx +++ b/ui/src/components/business/ContractsView.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useRef, useCallback } from "react"; import { FileText, FileSignature, Plus, Trash2, Save, X, DollarSign, Calendar, FileUp, Sparkles, Check, CheckCheck, Loader2, CheckCircle2, - FolderKanban, ReceiptText, ArrowRight, ChevronRight, XCircle, + FolderKanban, ReceiptText, ArrowRight, ChevronDown, ChevronRight, XCircle, Milestone, } from "lucide-react"; import { rpc } from "../../api/rpc"; import { str, num, bool, entity as subEntity, list as entityList, displayName, formatDate } from "../../api/entity"; @@ -78,13 +78,13 @@ export function ContractsView() { function startImport() { setSelected(null); setParsedContracts([]); setParseError(null); setMode("import"); } function selectContract(c: Entity) { setSelected(c); setMode("view"); setDeleteError(null); } - async function handleSave(data: ContractFormData) { + async function handleSave(data: ContractFormData, milestones?: MilestoneSavePayload): Promise { setSaveError(null); const titleTrimmed = data.title.trim().toLowerCase(); const duplicate = contracts.find( (c) => str(c, "title").trim().toLowerCase() === titleTrimmed && c.id !== selected?.id, ); - if (duplicate) { setSaveError("A contract with this title already exists."); return; } + if (duplicate) { setSaveError("A contract with this title already exists."); return false; } const contract: Record = { title: data.title, client_id: data.clientId, @@ -112,9 +112,33 @@ export function ContractsView() { })), }; if (mode === "edit" && selected) contract.id = selected.id; - const res = await rpc("contracts.save", { contract }); - if (res.ok) { setMode("view"); await load(); } - else setSaveError(res.error || "Failed to save contract."); + const res = await rpc("contracts.save", { contract }); + if (!res.ok) { + setSaveError(res.error || "Failed to save contract."); + return false; + } + + const contractId = res.data?.id ?? selected?.id; + const isFixed = data.type === "fixed_price"; + if (isFixed && milestones?.open && contractId != null) { + const msRes = await rpc("contracts.save_milestones", { + contract_id: contractId, + milestones: milestones.milestones.map((m, i) => ({ + id: m.id, + title: m.title, + percentage: parseFloat(m.percentage) || null, + position: i, + })), + }); + if (!msRes.ok) { + setSaveError(msRes.error || "Contract saved, but payment schedule could not be saved."); + return false; + } + } + + setMode("view"); + await load(); + return true; } async function handleDelete(id: number) { @@ -392,6 +416,32 @@ function ContractDetail({ contract, onEdit, onDelete, onToggle, deleteError }: { + {/* Payment Schedule (only shown if milestones exist) */} + {(() => { + const ms = entityList(contract, "payment_milestones"); + if (ms.length === 0) return null; + return ( + +
+ {ms.map((m) => ( +
+
+ + {str(m, "title") || "Untitled"} +
+
+ {num(m, "percentage")}% + {bool(m, "invoiced") + ? Invoiced + : Open} +
+
+ ))} +
+
+ ); + })()} + {/* Related */}
@@ -445,6 +495,18 @@ function RelatedCard({ icon, count, label, onClick }: { icon: React.ReactNode; c /* ---------- Form ---------- */ +interface MilestoneRow { + id?: number; + title: string; + percentage: string; + invoiced?: boolean; +} + +interface MilestoneSavePayload { + open: boolean; + milestones: MilestoneRow[]; +} + interface ContractFormData { title: string; clientId: number | null; @@ -497,13 +559,17 @@ function isBlankCharge(row: ChargeRow): boolean { return !row.description.trim() && !row.amount.trim(); } +function isBlankMilestone(row: MilestoneRow): boolean { + return !row.title.trim() && !row.percentage.trim(); +} + function ContractForm({ contract, clients, defaultCurrency, currencies, bankAccounts, onSave, onCancel, error }: { contract: Entity | null; clients: Record; defaultCurrency: string; currencies: string[]; bankAccounts: Entity[]; - onSave: (data: ContractFormData) => void; + onSave: (data: ContractFormData, milestones?: MilestoneSavePayload) => Promise; onCancel: () => void; error?: string | null; }) { @@ -556,6 +622,21 @@ function ContractForm({ contract, clients, defaultCurrency, currencies, bankAcco const isNew = !contract; const isFixed = pricingMode === "fixed_price"; + const [milestonesOpen, setMilestonesOpen] = useState(() => { + if (!contract) return false; + const ms = entityList(contract, "payment_milestones"); + return ms.length > 0; + }); + const [milestones, setMilestones] = useState(() => { + if (!contract) return []; + return entityList(contract, "payment_milestones").map((m) => ({ + id: m.id, + title: str(m, "title"), + percentage: String(num(m, "percentage") || ""), + invoiced: bool(m, "invoiced"), + })); + }); + const clientList = Object.values(clients); // Keep an existing contract's currency selectable even if it left the list. const currencyOptions = currencies.includes(form.currency) ? currencies : [form.currency, ...currencies]; @@ -627,9 +708,25 @@ function ContractForm({ contract, clients, defaultCurrency, currencies, bankAcco setValidationError("Give every additional charge a description and an amount greater than zero"); return; } + const schedule = milestones.filter((m) => !isBlankMilestone(m)); + if (isFixed && milestonesOpen && schedule.length > 0) { + if (schedule.some((m) => !m.title.trim())) { + setValidationError("Give every payment milestone a title"); + return; + } + const total = schedule.reduce((s, m) => s + (parseFloat(m.percentage) || 0), 0); + if (Math.abs(total - 100) > 0.01) { + setValidationError(`Milestone percentages must sum to 100% (currently ${total.toFixed(1)}%)`); + return; + } + } setSaving(true); - await onSave({ ...form, charges }); + const ok = await onSave( + { ...form, charges }, + isFixed && milestonesOpen ? { open: true, milestones: schedule } : undefined, + ); setSaving(false); + if (!ok) return; } const inputCls = "w-full px-3 py-2 rounded-md text-sm bg-bg-card text-primary border border-border-subtle outline-none focus:border-accent transition-colors"; @@ -825,6 +922,80 @@ function ContractForm({ contract, clients, defaultCurrency, currencies, bankAcco

+ + {isFixed && ( +
+ {!milestonesOpen ? ( +
+

+ Split a fixed-price contract into instalments for deposit and final invoices. +

+ +
+ ) : ( +
+ +
+ {milestones.map((m, idx) => ( +
+ setMilestones((prev) => prev.map((ms, i) => i === idx ? { ...ms, title: e.target.value } : ms))} + disabled={m.invoiced} + className={`flex-1 ${inputCls} ${m.invoiced ? "opacity-50" : ""}`} /> +
+ setMilestones((prev) => prev.map((ms, i) => i === idx ? { ...ms, percentage: e.target.value } : ms))} + disabled={m.invoiced} + className={`w-20 ${inputCls} ${m.invoiced ? "opacity-50" : ""}`} /> + % +
+ {m.invoiced ? ( + Invoiced + ) : ( + + )} +
+ ))} +
+ + {milestones.length > 0 && (() => { + const total = milestones.reduce((s, m) => s + (parseFloat(m.percentage) || 0), 0); + const ok = Math.abs(total - 100) < 0.01; + return ( +
+ Total: {total.toFixed(1)}%{!ok && " (must be 100%)"} +
+ ); + })()} +
+ )} +
+ )} ); } diff --git a/ui/src/components/invoicing/InvoicingView.tsx b/ui/src/components/invoicing/InvoicingView.tsx index a2a39066..f3ab4dfa 100644 --- a/ui/src/components/invoicing/InvoicingView.tsx +++ b/ui/src/components/invoicing/InvoicingView.tsx @@ -2,10 +2,10 @@ import { useEffect, useState, useCallback, useMemo } from "react"; import { FileText, Send, CheckCircle, XCircle, Mail, Trash2, Building2, FolderKanban, Calendar, Banknote, Eye, DollarSign, - Plus, Clock, AlertTriangle, ChevronLeft, ChevronRight, Search, Share, Receipt, + Plus, Clock, AlertTriangle, ChevronLeft, ChevronRight, Search, Share, Receipt, Milestone, } from "lucide-react"; import { rpc, readFileAsDataURL } from "../../api/rpc"; -import { str, num, bool, entity as subEntity, list as entityList, formatDate, invoiceStatus, deepStr, isReminder, reminderLevel } from "../../api/entity"; +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 { taxCategory, taxTreatment } from "../../api/tax"; import { StatusBadge } from "../shared/StatusBadge"; import { ViewModeToggle } from "../shared/ViewModeToggle"; @@ -15,7 +15,7 @@ import { useNavigation } from "../shared/NavigationContext"; import { EmptyStateIntro } from "../shared/EmptyStateIntro"; import type { Entity } from "../../api/types"; -type InvoiceChain = { root: Entity; reminders: Entity[] }; +type InvoiceChain = { root: Entity; reminders: Entity[]; deposits: Entity[] }; const INVOICE_COLUMNS: BoardColumn[] = [ { id: "Draft", label: "Draft", color: "#8e8e93" }, @@ -33,6 +33,14 @@ const FILTER_COLORS: Record = { Paid: "#34d399", Overdue: "#f87171", Cancelled: "#fb923c", }; +type DocumentType = "invoice" | "deposit" | "final"; + +const DOCUMENT_TYPE_OPTIONS = [ + { value: "invoice" as DocumentType, label: "Invoice", icon: FileText }, + { value: "deposit" as DocumentType, label: "Deposit", icon: Milestone }, + { value: "final" as DocumentType, label: "Final", icon: Receipt }, +]; + export function InvoicingView() { const { filter: navFilter } = useNavigation(); const [invoices, setInvoices] = useState([]); @@ -96,6 +104,18 @@ export function InvoicingView() { for (const c of boardChains) m.set(c.root.id, c.reminders.length); return m; }, [boardChains]); + const depositCountMap = useMemo(() => { + const m = new Map(); + for (const c of boardChains) { + if (c.deposits.length > 0) m.set(c.root.id, c.deposits.length); + } + return m; + }, [boardChains]); + const chainByRootId = useMemo(() => { + const m = new Map(); + for (const c of boardChains) m.set(c.root.id, c); + return m; + }, [boardChains]); async function toggleSent(id: number) { await rpc("invoicing.toggle_sent", { id }); load(); } async function togglePaid(id: number) { await rpc("invoicing.toggle_paid", { id }); load(); } @@ -163,10 +183,17 @@ export function InvoicingView() { const isSelected = selected?.id === inv.id; const isHighlighted = !isSelected && (inv.id === newlyCreatedId || (navFilter.contractId != null && num(inv, "contract_id") === navFilter.contractId)); return ( -
+
{ setNewlyCreatedId(null); setSelected(inv); }} /> + {chain.deposits.map((dep) => { + const depSelected = selected?.id === dep.id; + return { setNewlyCreatedId(null); setSelected(dep); }} />; + })} {chain.reminders.map((rem) => { const remSelected = selected?.id === rem.id; return stageStore.columnFor(e)} onMove={moveToColumn} - renderCard={(inv, col) => } /> + renderCard={(inv, col) => ( + + )} />
)} @@ -307,6 +341,9 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr const [selectedNoteIds, setSelectedNoteIds] = useState>(new Set()); const [customNoteText, setCustomNoteText] = useState(""); + const [docType, setDocType] = useState("invoice"); + const [selectedMilestoneId, setSelectedMilestoneId] = useState(null); + const selectedProject = projects.find((p) => p.id === projectId) ?? null; const isFixedPrice = selectedProject ? bool(selectedProject, "is_fixed_price") : false; const [eligibleCharges, setEligibleCharges] = useState(null); @@ -314,6 +351,19 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr const selectedContract = contractOf(selectedProject); const chargeCurrency = (selectedContract ? str(selectedContract, "currency") : "") || "EUR"; + const milestones = selectedContract ? entityList(selectedContract, "payment_milestones") : []; + const hasMilestones = milestones.length > 0; + const openMilestones = milestones.filter((m) => !bool(m, "invoiced")); + const isLastOpenMilestone = + openMilestones.length === 1 && selectedMilestoneId === openMilestones[0]?.id; + const canSettle = hasMilestones && openMilestones.length < milestones.length; + + // The document type resets with the project, so a picker option that no + // longer applies cannot survive into a submission. + useEffect(() => { + if (docType === "final" && !canSettle) setDocType("invoice"); + }, [docType, canSettle]); + // The backend decides which charges a new invoice actually carries — a // one-time fee already billed must not be previewed as upcoming. useEffect(() => { @@ -357,6 +407,8 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr setProjectId(newId); const proj = projects.find((p) => p.id === newId) ?? null; setLineItems(makeDefaultItems(proj)); + setDocType("invoice"); + setSelectedMilestoneId(null); } function updateItem(idx: number, patch: Partial) { @@ -383,6 +435,34 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr if (!projectId) { setError("Select a project"); return; } setSubmitting(true); setError(""); + + // Deposit invoice flow + if (docType === "deposit") { + if (!selectedMilestoneId) { setError("Select a milestone"); setSubmitting(false); return; } + const res = await rpc<{ id?: number }>("invoicing.create_deposit", { + project_id: projectId, + milestone_id: selectedMilestoneId, + invoice_date: invoiceDate, + }); + if (res.ok) { await onCreated(res.data?.id, res.warning); } + else { setError(res.error || "Failed to create deposit invoice"); } + setSubmitting(false); + return; + } + + // Final invoice flow + if (docType === "final") { + const res = await rpc<{ id?: number }>("invoicing.create_final", { + project_id: projectId, + invoice_date: invoiceDate, + }); + if (res.ok) { await onCreated(res.data?.id, res.warning); } + else { setError(res.error || "Failed to create final invoice"); } + setSubmitting(false); + return; + } + + // Standard invoice flow const params: Record = { project_id: projectId, invoice_date: invoiceDate, @@ -439,8 +519,61 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr + {/* Document type (only when contract has milestones) */} + {hasMilestones && isFixedPrice && ( +
+ Document Type +
+ {DOCUMENT_TYPE_OPTIONS.map(({ value, label, icon: Icon }) => { + // Settling early is only meaningful once a deposit exists to deduct. + const disabled = value === "final" && !canSettle; + return ( + + ); + })} +
+ {docType === "final" && ( +

+ States the full contract amount and deducts every deposit already issued. +

+ )} +
+ )} + + {/* Milestone picker (deposit only) */} + {docType === "deposit" && hasMilestones && ( + + )} + {/* Fixed-price notice */} - {isFixedPrice && selectedProject && (() => { + {isFixedPrice && docType === "invoice" && selectedProject && (() => { const ct = subEntity(selectedProject, "contract"); const price = ct ? num(ct, "fixed_price") : 0; const currency = ct ? str(ct, "currency") : "EUR"; @@ -477,8 +610,8 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr
)} - {/* Mode toggle (time-based only) */} - {!isFixedPrice && ( + {/* Mode toggle (time-based only, not for deposit/final) */} + {!isFixedPrice && docType === "invoice" && (
Source
@@ -500,8 +633,8 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr
)} - {/* Timesheet opt-out (time-tracking mode only) */} - {!isFixedPrice && mode === "timetracking" && ( + {/* Timesheet opt-out (time-tracking mode only, not for deposit/final) */} + {!isFixedPrice && mode === "timetracking" && docType === "invoice" && (
- {!isFixedPrice && ( + {!isFixedPrice && docType === "invoice" && (
Billing Period*
@@ -552,8 +685,8 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr )}
- {/* Line items editor (time-based manual only) */} - {!isFixedPrice && mode === "manual" && ( + {/* Line items editor (time-based manual only, not for deposit/final) */} + {!isFixedPrice && mode === "manual" && docType === "invoice" && (
Line Items
@@ -641,7 +774,10 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr
@@ -649,18 +785,82 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr ); } -function InvoiceRow({ invoice, isSelected, isHighlighted, reminderCount, onSelect }: { - invoice: Entity; isSelected: boolean; isHighlighted?: boolean; reminderCount?: number; onSelect: () => void; +function DocumentTypeBadge({ type }: { type: "deposit" | "final" }) { + if (type === "deposit") { + return ( + + Deposit + + ); + } + return ( + + Final + + ); +} + +function chainAccentClass(chain: InvoiceChain): string { + if (isFinalInvoice(chain.root) || isDeposit(chain.root) || chain.deposits.length > 0) { + return "border-l-2 border-l-blue-400"; + } + return ""; +} + +function MilestoneScheduleBadge({ schedule }: { schedule: MilestoneScheduleStatus }) { + const { total, invoicedCount, issuedCount, paidCount, hasFinal, settled } = schedule; + + if (settled) { + return ( + + All settled + + ); + } + if (hasFinal) { + return ( + + Settlement · {paidCount}/{issuedCount} paid + + ); + } + return ( + + {invoicedCount}/{total} milestones invoiced · {paidCount}/{issuedCount} paid + + ); +} + +function InvoiceRow({ invoice, isSelected, isHighlighted, reminderCount, depositCount, schedule, onSelect }: { + invoice: Entity; isSelected: boolean; isHighlighted?: boolean; + reminderCount?: number; depositCount?: number; schedule?: MilestoneScheduleStatus | null; onSelect: () => void; }) { const status = invoiceStatus(invoice); + const depositLabel = depositMilestoneLabel(invoice); + const isFinal = isFinalInvoice(invoice); return ( ); @@ -704,12 +905,83 @@ function ReminderRow({ invoice, isSelected, onSelect }: { invoice: Entity; isSel ); } -function InvoiceCard({ invoice, reminderCount }: { invoice: Entity; color: string; reminderCount?: number }) { +function DepositRow({ invoice, isSelected, onSelect }: { invoice: Entity; isSelected: boolean; onSelect: () => void }) { + const status = invoiceStatus(invoice); + const depositLabel = depositMilestoneLabel(invoice); + return ( + + ); +} + +function InvoiceChainCard({ chain, color, reminderCount, depositCount }: { + chain: InvoiceChain; color: string; reminderCount?: number; depositCount?: number; +}) { + const { root, deposits } = chain; + const schedule = milestoneScheduleStatus(root, deposits); + return ( +
+ + {deposits.length > 0 && ( +
+ {deposits.map((dep) => ( +
+
+
+ + {depositMilestoneLabel(dep) || "Deposit"} + + + {str(dep, "number") || "Draft"} +
+ {str(dep, "total_formatted")} +
+
{formatDate(str(dep, "date"))}
+
+ ))} +
+ )} +
+ ); +} + +function InvoiceCard({ invoice, reminderCount, depositCount, schedule }: { invoice: Entity; color: string; reminderCount?: number; depositCount?: number; schedule?: MilestoneScheduleStatus | null }) { + const depositLabel = depositMilestoneLabel(invoice); + const isFinal = isFinalInvoice(invoice); return (
-
- {str(invoice, "number") || "Draft"} +
+ {str(invoice, "number") || "Draft"} + {isDeposit(invoice) && depositLabel && ( + {depositLabel} + )} + {isFinal && ( + Settlement + )} + {isDeposit(invoice) && } + {isFinal && } + {(depositCount ?? 0) > 0 && !isDeposit(invoice) && ( + + {depositCount} + + )} {(reminderCount ?? 0) > 0 && ( {reminderCount} @@ -733,6 +1005,11 @@ function InvoiceCard({ invoice, reminderCount }: { invoice: Entity; color: strin
{formatDate(str(invoice, "date"))}
+ {schedule && ( +
+ +
+ )}
); } @@ -754,6 +1031,7 @@ function InvoiceDetail({ invoice, allInvoices, onToggleSent, onTogglePaid, onTog const tsPath = str(invoice, "timesheet_pdf_path"); const hasTimesheet = bool(invoice, "has_timesheet"); const isRem = isReminder(invoice); + const depositLabel = depositMilestoneLabel(invoice); const showTimesheetTab = hasTimesheet && !isRem; const canCreateReminder = status === "Overdue" && !isCancelled; @@ -831,14 +1109,29 @@ function InvoiceDetail({ invoice, allInvoices, onToggleSent, onTogglePaid, onTog
{isRem ? + : isFinalInvoice(invoice) + ? + : isDeposit(invoice) + ? : }

- {isRem ? `Reminder ${reminderLevel(invoice)}` : str(invoice, "number") || "Draft"} + {isRem + ? `Reminder ${reminderLevel(invoice)}` + : isDeposit(invoice) + ? (depositLabel ? `Deposit · ${depositLabel}` : `Deposit ${str(invoice, "number") || "Draft"}`) + : isFinalInvoice(invoice) + ? `Final ${str(invoice, "number") || "Draft"}` + : str(invoice, "number") || "Draft"}

-
- {isRem && Inv. {str(invoice, "number")}} +
+ {isDeposit(invoice) && depositLabel && ( + Inv. {str(invoice, "number") || "Draft"} + )} + {isFinalInvoice(invoice) && ( + Settlement invoice + )} {deepStr(invoice, "contract.client.name") || "No client"}
@@ -1021,6 +1314,69 @@ function InvoiceDetail({ invoice, allInvoices, onToggleSent, onTogglePaid, onTog
+ {(() => { + const contract = subEntity(invoice, "contract"); + const milestones = contract ? entityList(contract, "payment_milestones") : []; + if (milestones.length === 0) return null; + + const contractId = num(invoice, "contract_id"); + const projectId = num(invoice, "project_id"); + const deposits = allInvoices.filter( + (i) => isDeposit(i) && num(i, "contract_id") === contractId && num(i, "project_id") === projectId, + ); + const depositByMilestone = new Map(); + for (const d of deposits) { + const mid = num(d, "milestone_id"); + if (mid) depositByMilestone.set(mid, d); + } + const finalInv = allInvoices.find( + (i) => isFinalInvoice(i) && num(i, "contract_id") === contractId && num(i, "project_id") === projectId, + ); + + return ( +
+
+ {milestones.map((m) => { + // A milestone with no deposit of its own is covered by the + // final invoice, which settles everything the deposits left. + const dep = depositByMilestone.get(m.id); + const billedBy = dep ?? finalInv; + return ( +
+
+ + {str(m, "title") || "Untitled"} + {billedBy && ( + {str(billedBy, "number")} + )} + {!dep && finalInv && ( + via final invoice + )} +
+
+ {num(m, "percentage")}% + {billedBy + ? + : Open} +
+
+ ); + })} + {finalInv && ( +
+
+ + Final invoice + {str(finalInv, "number")} +
+ +
+ )} +
+
+ ); + })()} + {chain.length > 1 && (
@@ -1045,6 +1401,56 @@ function InvoiceDetail({ invoice, allInvoices, onToggleSent, onTogglePaid, onTog
)} + {/* Deposit chain — final invoice shows its deposits */} + {isFinalInvoice(invoice) && (() => { + const deposits = allInvoices.filter((i) => isDeposit(i) && depositChainHeadId(i) === invoice.id); + if (deposits.length === 0) return null; + return ( +
+
+ {deposits.map((dep) => ( +
+
+ + + {depositMilestoneLabel(dep) || "Deposit"} + + {str(dep, "number")} + {formatDate(str(dep, "date"))} +
+
+ {str(dep, "total_formatted")} + +
+
+ ))} +
+ Remaining balance + {str(invoice, "remaining_balance_formatted")} +
+
+
+ ); + })()} + + {/* Deposit invoice — show which final it belongs to */} + {isDeposit(invoice) && (() => { + const finalId = depositChainHeadId(invoice); + const final_ = finalId ? allInvoices.find((i) => i.id === finalId) : null; + return final_ ? ( +
+
+
+ + {str(final_, "number")} + {formatDate(str(final_, "date"))} +
+ +
+
+ ) : null; + })()} +
)} @@ -1186,31 +1592,83 @@ function CreateReminderDialog({ invoiceId, invoiceNumber, onClose, onCreated }: ); } -function buildChains(invoices: Entity[]): InvoiceChain[] { - const byId = new Map(); - for (const inv of invoices) byId.set(inv.id, inv); +function depositGroupKey(inv: Entity): string { + return `${num(inv, "contract_id")}-${num(inv, "project_id")}`; +} +function buildChains(invoices: Entity[]): InvoiceChain[] { const roots: Entity[] = []; const reminders: Entity[] = []; + const linkedDeposits: Entity[] = []; + const orphanDeposits: Entity[] = []; + const finalsByGroup = new Map(); + for (const inv of invoices) { - if (isReminder(inv)) reminders.push(inv); - else roots.push(inv); + if (isReminder(inv)) { + reminders.push(inv); + } else if (isFinalInvoice(inv)) { + roots.push(inv); + finalsByGroup.set(depositGroupKey(inv), inv); + } else if (isDeposit(inv)) { + if (depositChainHeadId(inv) != null) linkedDeposits.push(inv); + else orphanDeposits.push(inv); + } else { + roots.push(inv); + } } - const chainMap = new Map(); - for (const root of roots) chainMap.set(root.id, []); + const reminderMap = new Map(); + const depositMap = new Map(); + for (const root of roots) { + reminderMap.set(root.id, []); + depositMap.set(root.id, []); + } for (const rem of reminders) { const headId = rem.reminder_chain_head_id as number | undefined; - if (headId != null && chainMap.has(headId)) { - chainMap.get(headId)!.push(rem); + if (headId != null && reminderMap.has(headId)) { + reminderMap.get(headId)!.push(rem); + } + } + + for (const dep of linkedDeposits) { + const headId = depositChainHeadId(dep); + if (headId != null && depositMap.has(headId)) { + depositMap.get(headId)!.push(dep); + } else { + orphanDeposits.push(dep); } } + const orphanByGroup = new Map(); + for (const dep of orphanDeposits) { + const key = depositGroupKey(dep); + if (!orphanByGroup.has(key)) orphanByGroup.set(key, []); + orphanByGroup.get(key)!.push(dep); + } + + for (const [key, deps] of orphanByGroup) { + const sorted = [...deps].sort( + (a, b) => str(a, "date").localeCompare(str(b, "date")) || a.id - b.id, + ); + const finalInv = finalsByGroup.get(key); + if (finalInv && depositMap.has(finalInv.id)) { + depositMap.get(finalInv.id)!.push(...sorted); + continue; + } + const [head, ...rest] = sorted; + roots.push(head); + reminderMap.set(head.id, []); + depositMap.set(head.id, rest); + } + return roots.map((root) => { - const rems = (chainMap.get(root.id) || []).sort( + const rems = (reminderMap.get(root.id) || []).sort( (a, b) => num(a, "reminder_level") - num(b, "reminder_level"), ); - return { root, reminders: rems }; + const deps = (depositMap.get(root.id) || []).sort( + (a, b) => str(a, "date").localeCompare(str(b, "date")) || a.id - b.id, + ); + return { root, reminders: rems, deposits: deps }; }); } diff --git a/ui/src/components/layout/Shell.tsx b/ui/src/components/layout/Shell.tsx index 36187845..8ce3cffe 100644 --- a/ui/src/components/layout/Shell.tsx +++ b/ui/src/components/layout/Shell.tsx @@ -86,6 +86,7 @@ export function Shell() { useEffect(() => { (async () => { + setBootError(null); setBootPhase("registry"); const ensured = await rpc("db.ensure"); if (!ensured.ok) { From 2a21c5e951d739ba1a9eec6169cc91f68872eecd Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 8 Aug 2026 13:50:53 +0200 Subject: [PATCH 2/5] feat(invoicing): settle deposits across every template and guard e-invoicing Completes the deposit / final invoice workflow so a Schlussrechnung is correct wherever it is rendered and cannot be mis-stated to a client: - Shared Jinja partials and stylesheet under templates/_shared/ carry the document-type banner, deposit context and settlement lines, so all seven invoice skins deduct deposits identically instead of each restating the legally relevant layout. - Deposits are linked to their settlement, and milestones flagged, through targeted writes. Merging whole invoice graphs let a stale contract snapshot write back the old deposit_for_id, which dropped every deposit but the newest from the deduction list. - ZUGFeRD XML is skipped with a logged warning for document types the builder cannot express: it emits type code 380 with full totals, while EN16931 wants 386 for a prepayment and BT-113 prepaid amounts on the settlement. - Demo data grows a schedule mid-flight (one deposit issued, two open) so the workflow can be walked in the app, plus a Playwright smoke script. Co-authored-by: Cursor --- templates/_shared/document-type.css | 97 +++++++ templates/_shared/document_type.html | 137 +++++++++ templates/invoice-anvil/invoice.html | 21 +- templates/invoice-bold/invoice.css | 5 +- templates/invoice-bold/invoice.html | 18 +- templates/invoice-classic/invoice.css | 5 +- templates/invoice-classic/invoice.html | 39 +-- templates/invoice-grayshades/invoice.css | 5 +- templates/invoice-grayshades/invoice.html | 21 +- templates/invoice-minimal/invoice.css | 5 +- templates/invoice-minimal/invoice.html | 20 +- templates/invoice-modern/invoice.css | 54 +--- templates/invoice-modern/invoice.html | 56 +--- templates/invoice/invoice.html | 21 +- tuttle/app/invoicing/data_source.py | 49 +++- tuttle/app/invoicing/intent.py | 28 +- tuttle/demo.py | 99 ++++++- tuttle/einvoice.py | 31 +++ tuttle/rendering.py | 43 ++- tuttle_tests/test_deposit_invoices.py | 323 ++++++++++++++++++++++ tuttle_tests/test_rpc_dispatch.py | 104 ++++--- ui/scripts/smoke-deposit.ts | 130 +++++++++ ui/src/api/entity.ts | 3 + 23 files changed, 1100 insertions(+), 214 deletions(-) create mode 100644 templates/_shared/document-type.css create mode 100644 templates/_shared/document_type.html create mode 100644 tuttle_tests/test_deposit_invoices.py create mode 100644 ui/scripts/smoke-deposit.ts diff --git a/templates/_shared/document-type.css b/templates/_shared/document-type.css new file mode 100644 index 00000000..31fae0ce --- /dev/null +++ b/templates/_shared/document-type.css @@ -0,0 +1,97 @@ +/* Deposit / final / reminder presentation, shared by every invoice template. + * + * Neutral by design: colours come from each template's own palette through + * --invoice-accent, and a template can override any rule because its own + * stylesheet is linked after this one. */ + +.document-type-banner { + font-size: 13pt; + font-weight: 700; + letter-spacing: 0.6pt; + text-transform: uppercase; + padding: 9pt 12pt; + margin-bottom: 10pt; +} + +.document-type-banner.deposit { + background: #e8f0fe; + color: #1e40af; + border-left: 4pt solid #2563eb; +} + +.document-type-banner.final { + background: #f3e8ff; + color: #6b21a8; + border-left: 4pt solid #7c3aed; +} + +.document-type-banner.reminder { + background: #fef3c7; + color: #92400e; + border-left: 4pt solid #f59e0b; +} + +.deposit-context { + font-size: 9pt; + color: #444; + margin-bottom: 12pt; + line-height: 1.65; + padding: 8pt 10pt; + background: #f9fafb; + border: 0.5pt solid #e5e7eb; +} + +.deposit-context .label { + color: var(--invoice-accent, #1e40af); + font-weight: 600; + text-transform: uppercase; + font-size: 7pt; + letter-spacing: 0.4pt; + margin-right: 4pt; +} + +/* A deduction is money already invoiced, so it reads as secondary to the + * gross amount above it rather than as another charge. */ +.deposit-deduction, +.deposit-deduction td { + color: #666; +} + +.deposit-deduction-vat, +.deposit-deduction-vat td { + font-size: 0.85em; + color: #888; + padding-left: 1em; +} + +/* "abzgl. Abschlag lt. Rechnung Nr. 2026-001" is far longer than any other + * totals label and has to wrap. In the templates whose totals column is a + * flex row, the amount would otherwise be squeezed out of the page. The + * element selector keeps this ahead of a template's own class rule. */ +.deposit-deduction > span:last-child, +.deposit-deduction-vat > span:last-child { + flex-shrink: 0; + white-space: nowrap; +} + +/* Standalone settlement breakdown, for templates that lay their totals out + * horizontally and have nowhere to append the deduction lines. */ +.settlement-summary { + margin-left: auto; + margin-top: 10pt; + border-collapse: collapse; +} + +.settlement-summary td { + padding: 2pt 0 2pt 14pt; +} + +.settlement-summary .settlement-value { + text-align: right; + white-space: nowrap; +} + +.settlement-summary .settlement-total td { + font-weight: 700; + border-top: 1pt solid currentColor; +} diff --git a/templates/_shared/document_type.html b/templates/_shared/document_type.html new file mode 100644 index 00000000..bfd274f1 --- /dev/null +++ b/templates/_shared/document_type.html @@ -0,0 +1,137 @@ +{# + Deposit and final invoice presentation, shared by every invoice template. + + A Schlussrechnung must state the full contract amount with its VAT and then + deduct the gross amounts already invoiced as deposits — getting that wrong + overstates what the client owes. Defining it once here means the seven + templates cannot drift apart on the part that carries legal weight. + + Templates skin these blocks through their own stylesheets; the markup only + provides the class names that `document-type.css` styles. +#} + +{% macro banner(l, is_reminder=False, is_deposit=False, is_final=False, reminder_title="") %} +{%- if is_reminder %} +
{{ reminder_title }}
+{%- elif is_deposit %} +
{{ l.deposit_invoice }}
+{%- elif is_final %} +
{{ l.final_invoice }}
+{%- endif %} +{% endmacro %} + +{% macro document_title(l, invoice, is_reminder=False, is_deposit=False, is_final=False, reminder_title="") -%} +{%- if is_reminder -%}{{ reminder_title }} – {{ invoice.number }} +{%- elif is_deposit -%}{{ l.deposit_invoice }} {{ invoice.number }} +{%- elif is_final -%}{{ l.final_invoice }} {{ invoice.number }} +{%- else -%}{{ l.invoice_no }} {{ invoice.number }} +{%- endif -%} +{%- endmacro %} + +{% macro number_label(l, is_deposit=False, is_final=False) -%} +{%- if is_deposit -%}{{ l.deposit_invoice }} +{%- elif is_final -%}{{ l.final_invoice }} +{%- else -%}{{ l.invoice_no }} +{%- endif -%} +{%- endmacro %} + +{# What a deposit bills: which contract, which milestone, and of what total. #} +{% macro deposit_context(l, contract_title="", milestone_title="", milestone_percentage=None, contract_total=None) %} +{%- if contract_title or milestone_title %} +
+ {%- if contract_title %} +
{{ l.in_respect_of }} {{ contract_title }}
+ {%- endif %} + {%- if milestone_title %} +
{{ l.payment_milestone }} {{ milestone_title }}{% if milestone_percentage %} ({{ milestone_percentage }}%){% endif %}
+ {%- endif %} + {%- if contract_total %} +
{{ l.contract_total }} {{ contract_total | as_currency }}
+ {%- endif %} +
+{%- endif %} +{% endmacro %} + +{# + Settlement lines for a final invoice, 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. +#} +{% macro settlement_lines_div(l, invoice, deposit_deductions, line="totals-line", amount="totals-amount", label="totals-label") %} +
+ {{ l.gross }} + {{ invoice.total | as_currency }} +
+{%- for dep in deposit_deductions %} +
+ {{ l.less_deposit }} {{ dep.invoice_number }} + −{{ dep.gross | as_currency }} +
+
+ ({{ l.vat_included_therein }}: {{ dep.vat | as_currency }}) + +
+{%- endfor %} +{% endmacro %} + +{# The same settlement lines for templates whose totals block is a
. #} +{% macro settlement_lines_table(l, invoice, deposit_deductions, label="totals-label", value="totals-value") %} + + + + +{%- for dep in deposit_deductions %} + + + + + + + + +{%- endfor %} +{% endmacro %} + +{# + A self-contained settlement breakdown, for templates whose totals are laid + out horizontally and so have nowhere to append the deduction lines. +#} +{% macro settlement_summary(l, invoice, deposit_deductions, remaining_balance) %} +
{{ l.gross }}{{ invoice.total | as_currency }}
{{ l.less_deposit }} {{ dep.invoice_number }}−{{ dep.gross | as_currency }}
({{ l.vat_included_therein }}: {{ dep.vat | as_currency }})
+ + + + + {%- for dep in deposit_deductions %} + + + + + + + + + {%- endfor %} + + + + +
{{ l.gross }}{{ invoice.total | as_currency }}
{{ l.less_deposit }} {{ dep.invoice_number }}−{{ dep.gross | as_currency }}
({{ l.vat_included_therein }}: {{ dep.vat | as_currency }})
{{ l.remaining_balance }}{{ remaining_balance | as_currency }}
+{% endmacro %} + +{# The bottom line: remaining balance on a settlement, total due otherwise. #} +{% macro amount_due_label(l, is_deposit=False, is_final=False) -%} +{%- if is_final -%}{{ l.remaining_balance }} +{%- elif is_deposit -%}{{ l.deposit_due }} +{%- else -%}{{ l.total_due }} +{%- endif -%} +{%- endmacro %} + +{% macro closing_text(l, is_reminder=False, is_deposit=False, notes="") -%} +{%- if is_reminder -%}{{ l.reminder_closing }} +{%- elif is_deposit -%}{{ l.deposit_closing }} +{%- elif notes -%}{{ notes }} +{%- else -%}{{ l.closing }} +{%- endif -%} +{%- endmacro %} diff --git a/templates/invoice-anvil/invoice.html b/templates/invoice-anvil/invoice.html index e5b6dc9b..11829a64 100644 --- a/templates/invoice-anvil/invoice.html +++ b/templates/invoice-anvil/invoice.html @@ -1,14 +1,16 @@ +{% import "document_type.html" as doc %} - Invoice No. {{ invoice.number }} + {{ doc.document_title(l, invoice, is_reminder, is_deposit, is_final, reminder_title) }} {% if style == "anvil" %} + {% else %} @@ -74,17 +76,18 @@
- {% if is_reminder %} - {{ reminder_title }}
+ {% if is_reminder or is_deposit or is_final %} + {{ doc.document_title(l, invoice, is_reminder, is_deposit, is_final, reminder_title) }}
{% endif %} Invoice Date: {{ invoice.date }}
- Invoice Number: {{ invoice.number }} + {{ doc.number_label(l, is_deposit, is_final) }}: {{ invoice.number }} + {% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %} @@ -101,7 +104,7 @@ {% for item in invoice.items %} - + @@ -120,7 +123,7 @@ - + @@ -132,11 +135,13 @@ - +
{{ item.start_date }} - {{ item.end_date }}{{ item.start_date | as_date_short }} - {{ item.end_date | as_date_short }} {{ item.description }} {{ item.quantity }} {{ item.unit | unit_label(item.quantity) }}Payment Info Due By Total VATTotal Due{{ doc.amount_due_label(l, is_deposit, is_final) }}
{{ invoice.effective_due_date }} {% if invoice.is_outside_scope %}—{% else %}{{ invoice.VAT_total | as_currency }}{% endif %}{{ invoice.total | as_currency }}{{ remaining_balance | as_currency }}
+ {% if is_final %}{{ doc.settlement_summary(l, invoice, deposit_deductions, remaining_balance) }}{% endif %} + {% if invoice.is_outside_scope %}
{{ l.outside_scope_note }}
{% endif %} @@ -144,7 +149,7 @@


- {% if is_reminder %}Please settle the outstanding amount by the new due date.{% elif notes %}{{ notes }}{% else %}Thank you for your business{% endif %} + {% if is_reminder %}Please settle the outstanding amount by the new due date.{% elif is_deposit %}{{ l.deposit_closing }}{% elif notes %}{{ notes }}{% else %}Thank you for your business{% endif %}



diff --git a/templates/invoice-bold/invoice.css b/templates/invoice-bold/invoice.css index c6a40b12..248f5a04 100644 --- a/templates/invoice-bold/invoice.css +++ b/templates/invoice-bold/invoice.css @@ -108,7 +108,10 @@ body { vertical-align: baseline; } -.meta-label { +/* Matches the specificity of `.meta-table td` above, which would otherwise + * reset the gutter and leave a long label (ABSCHLAGSRECHNUNG) touching its + * value. */ +.meta-table td.meta-label { font-size: 7pt; font-weight: 700; text-transform: uppercase; diff --git a/templates/invoice-bold/invoice.html b/templates/invoice-bold/invoice.html index f4f6036a..e2f53b7f 100644 --- a/templates/invoice-bold/invoice.html +++ b/templates/invoice-bold/invoice.html @@ -1,9 +1,11 @@ +{% import "document_type.html" as doc %} - {% if is_reminder %}{{ reminder_title }} – {{ invoice.number }}{% else %}{{ l.invoice_no }} {{ invoice.number }}{% endif %} + {{ doc.document_title(l, invoice, is_reminder, is_deposit, is_final, reminder_title) }} + {% if accent_color %}{% endif %} @@ -26,6 +28,9 @@
+ {{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }} + {% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %} +
@@ -54,7 +59,7 @@ {% else %} - {{ l.invoice_no }} + {{ doc.number_label(l, is_deposit, is_final) }} {{ invoice.number }} {% endif %} @@ -114,7 +119,7 @@
- {{ l.subtotal }} + {% if is_final %}{{ l.total_fee }}{% else %}{{ l.subtotal }}{% endif %} {{ invoice.sum | as_currency }}
{% if not invoice.is_outside_scope %} @@ -129,9 +134,10 @@ {{ invoice.reminder_fee | as_currency }}
{% endif %} + {% if is_final %}{{ doc.settlement_lines_div(l, invoice, deposit_deductions) }}{% endif %}
- {{ l.total_due }} - {{ invoice.total | as_currency }} + {{ doc.amount_due_label(l, is_deposit, is_final) }} + {{ remaining_balance | as_currency }}
@@ -141,7 +147,7 @@ {% endif %}
-

{% if is_reminder %}{{ l.reminder_closing }}{% else %}{{ l.closing }}{% endif %}

+

{{ doc.closing_text(l, is_reminder, is_deposit, notes) }}

{% if include_signature and user.signature %} {% else %} diff --git a/templates/invoice-classic/invoice.css b/templates/invoice-classic/invoice.css index 2b3d2960..d6bf882e 100644 --- a/templates/invoice-classic/invoice.css +++ b/templates/invoice-classic/invoice.css @@ -76,7 +76,10 @@ body { font-size: 9pt; } -.meta-label { +/* Matches the specificity of `.meta-table td` above, which would otherwise + * reset the gutter and leave a long label (ABSCHLAGSRECHNUNG) touching its + * value. */ +.meta-table td.meta-label { text-align: right; color: #999; padding-right: 12pt; diff --git a/templates/invoice-classic/invoice.html b/templates/invoice-classic/invoice.html index 8b426e75..995323e8 100644 --- a/templates/invoice-classic/invoice.html +++ b/templates/invoice-classic/invoice.html @@ -1,9 +1,11 @@ +{% import "document_type.html" as doc %} - Invoice No. {{ invoice.number }} + {{ doc.document_title(l, invoice, is_reminder, is_deposit, is_final, reminder_title) }} + @@ -17,19 +19,19 @@
{{ user.subtitle }}
-
{% if is_reminder %}{{ reminder_title }}{% else %}Invoice{% endif %}
+
{% if is_reminder %}{{ reminder_title }}{% elif is_deposit %}{{ l.deposit_invoice }}{% elif is_final %}{{ l.final_invoice }}{% else %}{{ l.invoice }}{% endif %}
- + - - + + - - + +
Number{{ doc.number_label(l, is_deposit, is_final) }} {{ invoice.number }}
Date{{ invoice.date }}{{ l.date }}{{ invoice.date | as_date_short }}
Due{{ invoice.effective_due_date }}{{ l.due_date }}{{ invoice.effective_due_date | as_date_short }}
@@ -37,9 +39,11 @@
+ {% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %} +
- From + {{ l.from }}
{{ user.name }}
{{ user.address.html }} @@ -49,7 +53,7 @@
- Bill To + {{ l.bill_to }}
{{ invoice.contract.client.name }}
{% if invoice.contract.client.invoicing_contact %} @@ -77,7 +81,7 @@ {% for item in invoice.items %} - {{ item.start_date }} – {{ item.end_date }} + {{ item.start_date | as_date_short }} – {{ item.end_date | as_date_short }} {{ item.description }} {{ item.quantity }} {{ item.unit | unit_label(item.quantity) }} @@ -91,7 +95,7 @@
- Payment Details + {{ l.payment_details }}
{% if bank_account %}IBAN: {{ bank_account.IBAN }} {% if bank_account.BIC %}
BIC: {{ bank_account.BIC }}{% endif %}{% endif %} @@ -99,25 +103,26 @@
- Subtotal + {% if is_final %}{{ l.total_fee }}{% else %}{{ l.subtotal }}{% endif %} {{ invoice.sum | as_currency }}
{% if not invoice.is_outside_scope %}
- VAT + {{ l.vat }} {{ invoice.VAT_total | as_currency }}
{% endif %} {% if is_reminder and invoice.reminder_fee %}
- Reminder Fee + {{ l.reminder_fee }} {{ invoice.reminder_fee | as_currency }}
{% endif %} + {% if is_final %}{{ doc.settlement_lines_div(l, invoice, deposit_deductions) }}{% endif %}
- Total Due - {{ invoice.total | as_currency }} + {{ doc.amount_due_label(l, is_deposit, is_final) }} + {{ remaining_balance | as_currency }}
@@ -127,7 +132,7 @@ {% endif %}
-

{% if is_reminder %}Please settle the outstanding amount by the new due date.{% elif notes %}{{ notes }}{% else %}Thank you for your business.{% endif %}

+

{{ doc.closing_text(l, is_reminder, is_deposit, notes) }}

{{ user.name }}
diff --git a/templates/invoice-grayshades/invoice.css b/templates/invoice-grayshades/invoice.css index efea3036..63eb142f 100644 --- a/templates/invoice-grayshades/invoice.css +++ b/templates/invoice-grayshades/invoice.css @@ -117,7 +117,10 @@ body { vertical-align: baseline; } -.meta-label { +/* Matches the specificity of `.meta-table td` above, which would otherwise + * reset the gutter and leave a long label (ABSCHLAGSRECHNUNG) touching its + * value. */ +.meta-table td.meta-label { font-size: 7pt; font-weight: 600; text-transform: uppercase; diff --git a/templates/invoice-grayshades/invoice.html b/templates/invoice-grayshades/invoice.html index bef1e2ea..f0ec11ae 100644 --- a/templates/invoice-grayshades/invoice.html +++ b/templates/invoice-grayshades/invoice.html @@ -1,9 +1,11 @@ +{% import "document_type.html" as doc %} - {% if is_reminder %}{{ reminder_title }} – {{ invoice.number }}{% else %}{{ l.invoice_no }} {{ invoice.number }}{% endif %} + {{ doc.document_title(l, invoice, is_reminder, is_deposit, is_final, reminder_title) }} + @@ -43,11 +45,11 @@
- {% if is_reminder %} -
{{ reminder_title }}
- {% endif %} + {{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }} + +

{{ doc.number_label(l, is_deposit, is_final) }} {{ invoice.number }}

-

{{ l.invoice_no }} {{ invoice.number }}

+ {% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %} @@ -89,7 +91,7 @@

{{ l.invoice_no }} {{ invoice.number }}

- + {% if not invoice.is_outside_scope %} @@ -104,9 +106,10 @@

{{ l.invoice_no }} {{ invoice.number }}

{% endif %} + {% if is_final %}{{ doc.settlement_lines_table(l, invoice, deposit_deductions) }}{% endif %} - - + +
{{ l.subtotal }}{% if is_final %}{{ l.total_fee }}{% else %}{{ l.subtotal }}{% endif %} {{ invoice.sum | as_currency }}
{{ invoice.reminder_fee | as_currency }}
{{ l.total_due }} (€){{ invoice.total | as_currency }}{{ doc.amount_due_label(l, is_deposit, is_final) }} (€){{ remaining_balance | as_currency }}
@@ -122,7 +125,7 @@

{{ l.invoice_no }} {{ invoice.number }}

{% endif %}
-

{% if is_reminder %}{{ l.reminder_closing }}{% elif notes %}{{ notes }}{% else %}{{ l.closing }}{% endif %}

+

{{ doc.closing_text(l, is_reminder, is_deposit, notes) }}

{% if include_signature and user.signature %} {% else %} diff --git a/templates/invoice-minimal/invoice.css b/templates/invoice-minimal/invoice.css index d43fc335..23baabb9 100644 --- a/templates/invoice-minimal/invoice.css +++ b/templates/invoice-minimal/invoice.css @@ -117,7 +117,10 @@ body { vertical-align: baseline; } -.meta-label { +/* Matches the specificity of `.meta-table td` above, which would otherwise + * reset the gutter and leave a long label (ABSCHLAGSRECHNUNG) touching its + * value. */ +.meta-table td.meta-label { font-size: 7pt; font-weight: 500; text-transform: uppercase; diff --git a/templates/invoice-minimal/invoice.html b/templates/invoice-minimal/invoice.html index b0546ea0..c43d91d2 100644 --- a/templates/invoice-minimal/invoice.html +++ b/templates/invoice-minimal/invoice.html @@ -1,9 +1,11 @@ +{% import "document_type.html" as doc %} - {% if is_reminder %}{{ reminder_title }} – {{ invoice.number }}{% else %}{{ l.invoice_no }} {{ invoice.number }}{% endif %} + {{ doc.document_title(l, invoice, is_reminder, is_deposit, is_final, reminder_title) }} + @@ -44,13 +46,12 @@
- {% if is_reminder %} -
{{ reminder_title }}
- {% endif %} + {{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }} + {% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %} - + {% if is_reminder %} @@ -103,7 +104,7 @@
- {{ l.subtotal }} + {% if is_final %}{{ l.total_fee }}{% else %}{{ l.subtotal }}{% endif %} {{ invoice.sum | as_currency }}
{% if not invoice.is_outside_scope %} @@ -118,10 +119,11 @@ {{ invoice.reminder_fee | as_currency }}
{% endif %} + {% if is_final %}{{ doc.settlement_lines_div(l, invoice, deposit_deductions, amount="totals-value") }}{% endif %}
- {{ l.total_due }} - {{ invoice.total | as_currency }} + {{ doc.amount_due_label(l, is_deposit, is_final) }} + {{ remaining_balance | as_currency }}
@@ -141,7 +143,7 @@ {% endif %}
-

{% if is_reminder %}{{ l.reminder_closing }}{% elif notes %}{{ notes }}{% else %}{{ l.closing }}{% endif %}

+

{{ doc.closing_text(l, is_reminder, is_deposit, notes) }}

{% if include_signature and user.signature %} {% else %} diff --git a/templates/invoice-modern/invoice.css b/templates/invoice-modern/invoice.css index c4dadfbf..6481e639 100644 --- a/templates/invoice-modern/invoice.css +++ b/templates/invoice-modern/invoice.css @@ -114,7 +114,10 @@ body { vertical-align: baseline; } -.meta-label { +/* Matches the specificity of `.meta-table td` above, which would otherwise + * reset the gutter and leave a long label (ABSCHLAGSRECHNUNG) touching its + * value. */ +.meta-table td.meta-label { font-size: 7pt; font-weight: 600; text-transform: uppercase; @@ -327,50 +330,5 @@ body { color: #555; } -/* ── Document type (deposit / final / reminder) ─ */ - -.document-type-banner { - font-size: 13pt; - font-weight: 700; - letter-spacing: 0.6pt; - text-transform: uppercase; - padding: 9pt 12pt; - margin-bottom: 10pt; -} - -.document-type-banner.deposit { - background: #e8f0fe; - color: #1e40af; - border-left: 4pt solid #2563eb; -} - -.document-type-banner.final { - background: #f3e8ff; - color: #6b21a8; - border-left: 4pt solid #7c3aed; -} - -.document-type-banner.reminder { - background: #fef3c7; - color: #92400e; - border-left: 4pt solid #f59e0b; -} - -.deposit-context { - font-size: 9pt; - color: #444; - margin-bottom: 12pt; - line-height: 1.65; - padding: 8pt 10pt; - background: #f9fafb; - border: 0.5pt solid #e5e7eb; -} - -.deposit-context .label { - color: var(--invoice-accent); - font-weight: 600; - text-transform: uppercase; - font-size: 7pt; - letter-spacing: 0.4pt; - margin-right: 4pt; -} +/* Deposit / final / reminder styling lives in the shared + * document-type.css, linked ahead of this file. */ diff --git a/templates/invoice-modern/invoice.html b/templates/invoice-modern/invoice.html index 6381810c..9ab6528a 100644 --- a/templates/invoice-modern/invoice.html +++ b/templates/invoice-modern/invoice.html @@ -1,9 +1,11 @@ +{% import "document_type.html" as doc %} - {% if is_reminder %}{{ reminder_title }} – {{ invoice.number }}{% elif is_deposit %}{{ l.deposit_invoice }} {{ invoice.number }}{% elif is_final %}{{ l.final_invoice }} {{ invoice.number }}{% else %}{{ l.invoice_no }} {{ invoice.number }}{% endif %} + {{ doc.document_title(l, invoice, is_reminder, is_deposit, is_final, reminder_title) }} + {% if accent_color %}{% endif %} @@ -45,31 +47,12 @@
- {% if is_reminder %} -
{{ reminder_title }}
- {% elif is_deposit %} -
{{ l.deposit_invoice }}
- {% elif is_final %} -
{{ l.final_invoice }}
- {% endif %} - - {% if is_deposit and (contract_title or milestone_title) %} -
- {% if contract_title %} -
{{ l.in_respect_of }} {{ contract_title }}
- {% endif %} - {% if milestone_title %} -
{{ l.payment_milestone }} {{ milestone_title }}{% if milestone_percentage %} ({{ milestone_percentage }}%){% endif %}
- {% endif %} - {% if contract_total %} -
{{ l.contract_total }} {{ contract_total | as_currency }}
- {% endif %} -
- {% endif %} + {{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }} + {% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %}
{{ l.invoice_no }}{{ doc.number_label(l, is_deposit, is_final) }} {{ invoice.number }}
- + {% if is_reminder %} @@ -148,33 +131,12 @@ {{ invoice.reminder_fee | as_currency }} {% endif %} - {% if is_final %} -
- {{ l.gross }} - {{ invoice.total | as_currency }} -
- {% for dep in deposit_deductions %} -
- {{ l.less_deposit }} {{ dep.invoice_number }} - −{{ dep.gross | as_currency }} -
-
- ({{ l.vat_included_therein }}: {{ dep.vat | as_currency }}) - -
- {% endfor %} + {% if is_final %}{{ doc.settlement_lines_div(l, invoice, deposit_deductions) }}{% endif %}
- {{ l.remaining_balance }} + {{ doc.amount_due_label(l, is_deposit, is_final) }} {{ remaining_balance | as_currency }}
- {% else %} -
-
- {% if is_deposit %}{{ l.deposit_due }}{% else %}{{ l.total_due }}{% endif %} - {{ invoice.total | as_currency }} -
- {% endif %} @@ -183,7 +145,7 @@ {% endif %}
-

{% if is_reminder %}{{ l.reminder_closing }}{% elif is_deposit %}{{ l.deposit_closing }}{% elif notes %}{{ notes }}{% else %}{{ l.closing }}{% endif %}

+

{{ doc.closing_text(l, is_reminder, is_deposit, notes) }}

{% if include_signature and user.signature %} {% else %} diff --git a/templates/invoice/invoice.html b/templates/invoice/invoice.html index 4a5bb397..f45b8541 100644 --- a/templates/invoice/invoice.html +++ b/templates/invoice/invoice.html @@ -1,3 +1,4 @@ +{% import "document_type.html" as doc %} @@ -24,13 +25,15 @@ {% endif %} - Invoice + + + {{ doc.document_title(l, invoice, is_reminder, is_deposit, is_final, reminder_title) }}
-

{% if is_reminder %}{{ reminder_title }}{% else %}Invoice No. {{ invoice.number }}{% endif %}

+

{% if is_reminder %}{{ reminder_title }}{% else %}{{ doc.document_title(l, invoice, is_reminder, is_deposit, is_final, reminder_title) }}{% endif %}

@@ -58,11 +61,13 @@

{% if is_reminder %}{{ reminder_title }}{% else %}Invoice No. {{ invoice.num

- Invoice number: {{ invoice.number }}
+ {{ doc.number_label(l, is_deposit, is_final) }}: {{ invoice.number }}
Date: {{ invoice.date }}

+ {% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %} +

{% if is_deposit %}{{ l.deposit_invoice }}{% elif is_final %}{{ l.final_invoice }}{% else %}{{ l.invoice_no }}{% endif %}{{ doc.number_label(l, is_deposit, is_final) }} {{ invoice.number }}
@@ -80,7 +85,7 @@

{% if is_reminder %}{{ reminder_title }}{% else %}Invoice No. {{ invoice.num

{% for item in invoice.items %} - + @@ -97,22 +102,26 @@

{% if is_reminder %}{{ reminder_title }}{% else %}Invoice No. {{ invoice.num
{{ l.outside_scope_note }}
{% endif %} + {% if is_final %}{{ doc.settlement_summary(l, invoice, deposit_deductions, remaining_balance) }}{% endif %} +

{{ item.date }}{{ item.start_date | as_date_short }} – {{ item.end_date | as_date_short }} {{ item.description }} {{ item.quantity }} {{ item.unit | unit_label(item.quantity) }}
- + - +
Due by Account numberTotal due{{ doc.amount_due_label(l, is_deposit, is_final) }}
{{ invoice.effective_due_date }} {% if bank_account %}{{ bank_account.IBAN }}{% endif %}{{ invoice.total | as_currency }}{{ remaining_balance | as_currency }}
+ + {% if is_deposit %}

{{ l.deposit_closing }}

{% endif %}
diff --git a/tuttle/app/invoicing/data_source.py b/tuttle/app/invoicing/data_source.py index 818f19fd..360c0c0e 100644 --- a/tuttle/app/invoicing/data_source.py +++ b/tuttle/app/invoicing/data_source.py @@ -4,7 +4,7 @@ import sqlmodel from loguru import logger -from ...model import Invoice, InvoiceItem, Timesheet +from ...model import Invoice, InvoiceItem, PaymentMilestone, Timesheet from ..core.abstractions import SQLModelDataSourceMixin from ..core.intent_result import IntentResult @@ -175,6 +175,53 @@ def get_billed_charge_ids(self, contract_id: int) -> Set[int]: ).all() return {row for row in rows if row is not None} + def link_deposits_to_final(self, final_invoice_id: int, deposit_ids: List[int]) -> IntentResult[None]: + """Point each deposit at the final invoice that settles it. + + Written through a single session rather than ``save_invoice`` per + deposit: that merges each invoice's whole object graph, and a contract + loaded before the settlement existed carries a stale invoice + collection whose cascade writes the old ``deposit_for_id`` back — + silently dropping deposits from the deduction list. + """ + try: + with self.create_session() as session: + for deposit_id in deposit_ids: + deposit = session.get(Invoice, deposit_id) + if deposit is None: + continue + deposit.deposit_for_id = final_invoice_id + session.add(deposit) + session.commit() + return IntentResult(was_intent_successful=True) + except Exception as ex: + return IntentResult( + was_intent_successful=False, + error_msg="The deposits could not be linked to the final invoice.", + log_message=f"InvoicingDataSource.link_deposits_to_final({final_invoice_id}, {deposit_ids}): {ex}", + exception=ex, + ) + + def mark_milestones_invoiced(self, milestone_ids: List[int]) -> IntentResult[None]: + """Flag milestones as invoiced without touching the rest of their graph.""" + try: + with self.create_session() as session: + for milestone_id in milestone_ids: + milestone = session.get(PaymentMilestone, milestone_id) + if milestone is None: + continue + milestone.invoiced = True + session.add(milestone) + session.commit() + return IntentResult(was_intent_successful=True) + except Exception as ex: + return IntentResult( + was_intent_successful=False, + error_msg="The payment schedule could not be updated.", + log_message=f"InvoicingDataSource.mark_milestones_invoiced({milestone_ids}): {ex}", + exception=ex, + ) + def get_deposit_invoices(self, contract_id: int, project_id: int) -> IntentResult[List[Invoice]]: """Deposit invoices of one project, oldest first, ready to be settled. diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index b7436ca4..331298b8 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -195,7 +195,7 @@ def create_deposit( item.validate_vat() self._invoicing_data_source.save_invoice(invoice) - self._mark_milestone_invoiced(milestone) + self._mark_milestones_invoiced([milestone]) invoice, warnings = self._render_saved_invoice(invoice.id, "deposit invoice") return IntentResult( @@ -256,15 +256,14 @@ def create_final( # ``generate_final_invoice`` cannot set deposit_for_id before the # final invoice has an id, so the chain is linked after the insert. self._invoicing_data_source.save_invoice(invoice) - for dep in deposit_invoices: - dep.deposit_for_id = invoice.id - self._invoicing_data_source.save_invoice(dep) + self._invoicing_data_source.link_deposits_to_final( + invoice.id, + [dep.id for dep in deposit_invoices if dep.id is not None], + ) # The settlement bills whatever the deposits left, so no milestone # of this contract is still open once it exists. - for milestone in contract.payment_milestones: - if not milestone.invoiced: - self._mark_milestone_invoiced(milestone) + self._mark_milestones_invoiced([m for m in contract.payment_milestones if not m.invoiced]) invoice, warnings = self._render_saved_invoice(invoice.id, "final invoice") @@ -286,9 +285,13 @@ def create_final( error_msg=f"Failed to create final invoice: {ex}", ) - def _mark_milestone_invoiced(self, milestone) -> None: - milestone.invoiced = True - self._invoicing_data_source.store(milestone) + def _mark_milestones_invoiced(self, milestones) -> None: + ids = [m.id for m in milestones if m.id is not None] + if not ids: + return + self._invoicing_data_source.mark_milestones_invoiced(ids) + for milestone in milestones: + milestone.invoiced = True def _next_invoice_number(self, invoice_date) -> str: app_db = AppDatabase() @@ -337,8 +340,13 @@ 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 + return { "language": language, + "e_invoice_profile": e_invoice_profile or None, "template_name": _pref( self._preferences_intent.get_preferred_invoice_template, DEFAULT_INVOICE_TEMPLATE, diff --git a/tuttle/demo.py b/tuttle/demo.py index 387e0d86..826bf6a3 100644 --- a/tuttle/demo.py +++ b/tuttle/demo.py @@ -530,6 +530,82 @@ def create_usd_security_data(user: User) -> tuple[Project, Invoice, ClientContac ) +def create_modernisation_milestone_data( + client: Client, + user: User, + today: date, +) -> tuple[Contract, Project, List[Invoice]]: + """A fixed-price contract halfway through its payment schedule. + + Sam Lowry's contract shows a schedule that has run its course; this one + shows one in progress — the first instalment invoiced as a deposit, two + still open — which is the state the create-invoice dialog and the schedule + badge are there to make legible. + """ + contract = Contract( + title="Heating System Modernisation – Central Services", + client=client, + signature_date=today - timedelta(days=120), + start_date=today - timedelta(days=100), + type=ContractType.fixed_price, + rate=None, + fixed_price=Decimal("24000"), + currency="EUR", + VAT_rate=Decimal("0.19"), + unit=TimeUnit.hour, + units_per_workday=8, + volume=320, + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) + project = Project( + title="Heating Modernisation", + tag="#modernisation", + description="Replace the plant room and rebalance the risers, billed in three instalments", + is_completed=False, + start_date=contract.start_date, + end_date=today + timedelta(days=120), + contract=contract, + ) + + schedule = [ + PaymentMilestone(contract=contract, title=title, percentage=percentage, position=position) + for position, (title, percentage) in enumerate( + ( + ("On signing", Decimal("40")), + ("Plant room commissioned", Decimal("40")), + ("Handover and balancing report", Decimal("20")), + ) + ) + ] + schedule[0].invoiced = True + + deposit_date = today - timedelta(days=90) + deposit = invoicing.generate_deposit_invoice( + contract=contract, + project=project, + milestone=schedule[0], + number=f"{deposit_date.strftime('%Y-%m-%d')}-{next(invoice_number_counter)}", + date=deposit_date, + ) + deposit.milestone = schedule[0] + deposit.sent = True + deposit.paid = True + + try: + rendering.render_invoice( + user=user, + invoice=deposit, + out_dir=get_data_dir() / "Invoices", + only_final=True, + ) + logger.info("✅ rendered deposit invoice for Heating Modernisation") + except Exception as ex: + logger.error(f"❌ Error rendering deposit invoice for Heating Modernisation: {ex}") + + return contract, project, [deposit] + + def create_heating_data( user: User, n: int = 4, @@ -741,6 +817,15 @@ def create_heating_data( ) ) + # -- a fixed-price contract still working through its schedule ------------- + modernisation_contract, modernisation_project, modernisation_invoices = create_modernisation_milestone_data( + client=central_services, + user=user, + today=datetime.date.today(), + ) + contracts.append(modernisation_contract) + projects.append(modernisation_project) + # -- one US client invoiced in USD ---------------------------------------- # Harry is taxed in Germany but bills this one in dollars: the mixed-currency # case. B2B to a non-EU recipient, so the supply is outside the scope of @@ -750,14 +835,17 @@ def create_heating_data( client_contacts.append(us_client_contact) today = datetime.date.today() - invoices = [us_invoice] + invoices = [us_invoice, *modernisation_invoices] sam_lowry_project = None - # The last project is the US one, already invoiced above. Sam Lowry's is - # billed through the milestone schedule further down, not as a lump sum. + # The last project is the US one, already invoiced above. The two + # milestone-billed projects are settled through their payment schedules, + # not as a lump sum. for i, project in enumerate(projects[:-1]): if project.contract is sam_lowry_contract: sam_lowry_project = project continue + if project is modernisation_project: + continue if i < 2: inv_date = today - timedelta(days=random.randint(30, 60)) inv = create_fake_invoice( @@ -894,11 +982,14 @@ def create_historical_invoices( """Create invoices spread across the past n_months for dashboard history.""" today = datetime.date.today() invoices = [] + # A contract with a payment schedule is billed through its instalments; a + # lump-sum invoice alongside them would bill the same work twice. + billable = [p for p in projects if not (p.contract and p.contract.payment_milestones)] for months_ago in range(n_months, 0, -1): # First day of that month inv_date = (today - timedelta(days=30 * months_ago)).replace(day=15) # Pick 1-2 random projects to invoice each month - month_projects = random.sample(projects, k=min(random.randint(1, 2), len(projects))) + month_projects = random.sample(billable, k=min(random.randint(1, 2), len(billable))) for project in month_projects: inv = create_fake_invoice( fake, diff --git a/tuttle/einvoice.py b/tuttle/einvoice.py index 353c75f7..7e955bd9 100644 --- a/tuttle/einvoice.py +++ b/tuttle/einvoice.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta, timezone from decimal import Decimal +from typing import Optional import pycountry from drafthorse.models.accounting import ApplicableTradeTax @@ -127,6 +128,29 @@ def _vat_in_tax_currency(invoice: Invoice, total_tax: Decimal, currency: str, ta } +# -- Supported document types -------------------------------------------------- + + +def unsupported_reason(invoice: Invoice) -> Optional[str]: + """Why *invoice* cannot be expressed as ZUGFeRD XML, or None if it can. + + The builder below writes a plain commercial invoice (type code 380) with + the full document totals. EN16931 settles instalments differently: a + deposit is a prepayment invoice (type code 386) and a final invoice + subtracts what was prepaid via BT-113, leaving a smaller amount due. + Emitting 380 with full totals for either would state an amount the client + does not owe, so those documents ship as PDF without embedded XML until + the prepayment model is implemented. + """ + if invoice.is_reminder: + return "a payment reminder is not an invoice in the EN16931 sense" + if invoice.is_deposit: + return "deposit invoices require the prepayment type code (386), which Tuttle does not emit yet" + if invoice.is_final_invoice: + return "final invoices require EN16931 prepaid-amount settlement (BT-113), which Tuttle does not emit yet" + return None + + # -- Core builder ------------------------------------------------------------- @@ -350,7 +374,14 @@ def embed_zugferd_in_pdf( invoice: A Tuttle Invoice with loaded relationships. user: The freelancer / seller. profile: ZUGFeRD profile level. + + Raises: + ValueError: If the invoice is of a document type the builder cannot + represent faithfully (see `unsupported_reason`). """ + reason = unsupported_reason(invoice) + if reason: + raise ValueError(f"Cannot embed e-invoice XML for invoice {invoice.number or invoice.id}: {reason}.") xml = serialize_zugferd_xml(invoice, user, profile=profile) with open(pdf_path, "rb") as f: original_pdf = f.read() diff --git a/tuttle/rendering.py b/tuttle/rendering.py index b057918f..a6a4c7d1 100644 --- a/tuttle/rendering.py +++ b/tuttle/rendering.py @@ -177,6 +177,16 @@ def get_template_path(template_name) -> str: return template_path +def get_shared_template_path() -> Path: + """Directory of partials and stylesheets every document template shares. + + Lets the templates import one definition of the parts that must not drift + between skins — notably the deposit/final settlement layout, where the + markup carries the legal statement of what has been deducted. + """ + return Path(__file__).parent.parent.resolve() / "templates" / "_shared" + + def convert_html_to_pdf( in_path, out_path, @@ -294,7 +304,8 @@ def unit_label(raw_unit, quantity=None): return singular if abs(q - 1) < 1e-9 else plural template_path = get_template_path(template_name) - template_env = jinja2.Environment(loader=jinja2.FileSystemLoader(template_path)) + shared_path = get_shared_template_path() + template_env = jinja2.Environment(loader=jinja2.FileSystemLoader([template_path, shared_path])) template_env.filters["as_currency"] = as_currency template_env.filters["as_date"] = as_date @@ -377,7 +388,10 @@ def unit_label(raw_unit, quantity=None): with open(invoice_path, "w", encoding="utf-8") as invoice_file: invoice_file.write(html) - # Copy all CSS files and subdirectories from the template + # Copy all CSS files and subdirectories from the template. Shared + # stylesheets go first so a template's own rules can override them. + for css in shared_path.glob("*.css"): + shutil.copy(css, invoice_dir / css.name) for item in template_path.iterdir(): dest = invoice_dir / item.name if item.is_file() and item.suffix == ".css": @@ -393,15 +407,22 @@ def unit_label(raw_unit, quantity=None): css_paths=css_paths, out_path=pdf_out, ) - if e_invoice_profile and not invoice.is_reminder: - from .einvoice import embed_zugferd_in_pdf - - embed_zugferd_in_pdf( - pdf_path=str(pdf_out), - invoice=invoice, - user=user, - profile=e_invoice_profile, - ) + if e_invoice_profile: + from .einvoice import embed_zugferd_in_pdf, unsupported_reason + + reason = unsupported_reason(invoice) + if reason: + logger.warning( + f"Skipping e-invoice XML for {invoice.number or invoice.id}: {reason}. " + "The PDF is written without embedded XML." + ) + else: + embed_zugferd_in_pdf( + pdf_path=str(pdf_out), + invoice=invoice, + user=user, + profile=e_invoice_profile, + ) if only_final: final_output_path = out_dir / Path(f"{invoice.prefix}.{document_format}") if document_format == "pdf": diff --git a/tuttle_tests/test_deposit_invoices.py b/tuttle_tests/test_deposit_invoices.py new file mode 100644 index 00000000..433ddd28 --- /dev/null +++ b/tuttle_tests/test_deposit_invoices.py @@ -0,0 +1,323 @@ +"""Deposit (Abschlagsrechnung) and final (Schlussrechnung) invoices. + +The settlement arithmetic is the part with legal weight: a Schlussrechnung +states the full contract amount with its VAT and then deducts the *gross* +amounts already invoiced as deposits. Getting that wrong overstates what the +client owes, so it is pinned down here with the worked example from issue #326. +""" + +import datetime +from decimal import Decimal + +import pytest + +from tuttle import invoicing +from tuttle.app.contracts.intent import ContractsIntent +from tuttle.einvoice import unsupported_reason +from tuttle.model import ( + Address, + Client, + Contract, + Invoice, + PaymentMilestone, + Project, + TaxCategory, +) +from tuttle.time import ContractType, Cycle + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _fixed_price_project(fixed_price: Decimal, tag: str = "#deposit") -> Project: + client = Client( + name="Sam Lowry", + address=Address( + street="Shangrila Towers", + number="1", + postal_code="00000", + city="Brazil", + country="Germany", + ), + ) + contract = Contract( + title="Central Heating Overhaul", + client=client, + start_date=datetime.date(2026, 1, 10), + type=ContractType.fixed_price, + fixed_price=fixed_price, + currency="EUR", + VAT_rate=Decimal("0.19"), + billing_cycle=Cycle.monthly, + ) + return Project( + title="Heating Overhaul", + description="Ductwork", + tag=tag, + contract=contract, + start_date=datetime.date(2026, 1, 10), + end_date=datetime.date(2026, 6, 30), + ) + + +def _milestones(project: Project, *percentages: str) -> list[PaymentMilestone]: + schedule = [ + PaymentMilestone( + title=f"Instalment {position + 1}", + percentage=Decimal(pct), + position=position, + contract=project.contract, + ) + for position, pct in enumerate(percentages) + ] + project.contract.payment_milestones = schedule + return schedule + + +def _settlement(fixed_price: str, *percentages: str) -> tuple[list[Invoice], Invoice]: + """Issue a deposit per milestone, then the final invoice deducting them.""" + project = _fixed_price_project(Decimal(fixed_price)) + schedule = _milestones(project, *percentages) + deposits = [ + invoicing.generate_deposit_invoice( + contract=project.contract, + project=project, + milestone=milestone, + number=f"2026-{position + 1:03d}", + date=datetime.date(2026, 1, 15), + ) + for position, milestone in enumerate(schedule) + ] + final = invoicing.generate_final_invoice( + contract=project.contract, + project=project, + deposit_invoices=deposits, + number="2026-999", + date=datetime.date(2026, 6, 30), + ) + return deposits, final + + +# --------------------------------------------------------------------------- +# Settlement arithmetic +# --------------------------------------------------------------------------- + + +class TestSettlementMath: + """The worked example from issue #326: 10,000 net at 19% VAT, 50/50.""" + + def test_deposit_bills_its_share_of_the_contract(self): + deposits, _ = _settlement("10000", "50", "50") + assert deposits[0].sum == Decimal("5000.00") + assert deposits[0].VAT_total == Decimal("950.00") + assert deposits[0].total == Decimal("5950.00") + + def test_final_invoice_states_the_whole_contract(self): + _, final = _settlement("10000", "50", "50") + assert final.sum == Decimal("10000") + assert final.VAT_total == Decimal("1900.00") + assert final.total == Decimal("11900.00") + + def test_deductions_carry_gross_and_the_vat_within(self): + deposits, final = _settlement("10000", "50", "50") + assert final.deposit_deductions == [ + { + "invoice_number": deposits[0].number, + "gross": Decimal("5950.00"), + "vat": Decimal("950.00"), + "net": Decimal("5000.00"), + }, + { + "invoice_number": deposits[1].number, + "gross": Decimal("5950.00"), + "vat": Decimal("950.00"), + "net": Decimal("5000.00"), + }, + ] + + def test_remaining_balance_is_the_total_less_the_deposits(self): + deposits, final = _settlement("10000", "50", "50") + final.deposits = deposits[:1] + assert final.remaining_balance == Decimal("5950.00") + + def test_a_fully_deposited_contract_leaves_nothing_to_pay(self): + _, final = _settlement("10000", "50", "50") + assert final.remaining_balance == Decimal("0.00") + + def test_thirds_round_to_cents_and_the_settlement_absorbs_the_rest(self): + """A schedule of thirds bills 3,333.33 three times; the last cent of + the contract total surfaces as remaining balance on the settlement.""" + deposits, final = _settlement("10000", "33.34", "33.33", "33.33") + assert [d.sum for d in deposits] == [ + Decimal("3334.00"), + Decimal("3333.00"), + Decimal("3333.00"), + ] + assert final.remaining_balance == Decimal("0.00") + + def test_deposit_inherits_the_contracts_tax_treatment(self): + deposits, final = _settlement("10000", "100") + assert deposits[0].items[0].VAT_category is TaxCategory.standard + assert final.items[0].VAT_category is TaxCategory.standard + + def test_deposit_links_to_its_milestone_and_the_settlement(self): + deposits, final = _settlement("10000", "50", "50") + assert all(d.is_deposit for d in deposits) + assert final.is_final_invoice + assert final.deposits == deposits + + def test_an_ordinary_invoice_owes_its_full_total(self): + """`remaining_balance` is the amount due on every document type, so a + template can print it unconditionally.""" + project = _fixed_price_project(Decimal("1000")) + invoice = invoicing.generate_fixed_price_invoice( + contract=project.contract, + project=project, + number="2026-001", + date=datetime.date(2026, 2, 1), + ) + assert not invoice.is_deposit and not invoice.is_final_invoice + assert invoice.remaining_balance == invoice.total == Decimal("1190.00") + + @pytest.mark.parametrize("kind", ["deposit", "final"]) + def test_a_time_based_contract_cannot_be_settled_in_instalments(self, kind): + project = _fixed_price_project(Decimal("10000")) + project.contract.fixed_price = None + milestone = _milestones(project, "100")[0] + with pytest.raises(ValueError): + if kind == "deposit": + invoicing.generate_deposit_invoice( + contract=project.contract, + project=project, + milestone=milestone, + number="2026-001", + ) + else: + invoicing.generate_final_invoice( + contract=project.contract, + project=project, + deposit_invoices=[], + number="2026-001", + ) + + +# --------------------------------------------------------------------------- +# Payment schedule validation +# --------------------------------------------------------------------------- + + +def _row(title="Instalment", percentage=None, amount=None, existing=None, position=0): + return { + "existing": existing, + "title": title, + "percentage": Decimal(percentage) if percentage is not None else None, + "amount": Decimal(amount) if amount is not None else None, + "position": position, + } + + +def _validate(contract, rows, existing=()): + existing_by_id = {m.id: m for m in existing} + incoming_ids = {row["existing"].id for row in rows if row["existing"] is not None} + return ContractsIntent._validate_milestone_schedule(contract, rows, existing_by_id, incoming_ids) + + +class TestMilestoneScheduleValidation: + """A schedule that does not add up to the contract would mis-bill.""" + + @pytest.fixture + def contract(self): + return _fixed_price_project(Decimal("10000")).contract + + def test_percentages_summing_to_100_are_accepted(self, contract): + rows = [_row(percentage="40"), _row(percentage="60", position=1)] + assert _validate(contract, rows) is None + + def test_percentages_must_sum_to_100(self, contract): + rows = [_row(percentage="40"), _row(percentage="40", position=1)] + assert "sum to 100%" in _validate(contract, rows) + + def test_amounts_must_sum_to_the_fixed_price(self, contract): + rows = [_row(amount="4000"), _row(amount="4000", position=1)] + assert "sum to the contract fixed price" in _validate(contract, rows) + + def test_amounts_summing_to_the_fixed_price_are_accepted(self, contract): + rows = [_row(amount="4000"), _row(amount="6000", position=1)] + assert _validate(contract, rows) is None + + def test_amounts_need_a_fixed_price_to_check_against(self, contract): + contract.fixed_price = None + rows = [_row(amount="4000"), _row(amount="6000", position=1)] + assert "require a fixed-price contract" in _validate(contract, rows) + + def test_percentages_and_amounts_cannot_be_mixed(self, contract): + rows = [_row(percentage="50"), _row(amount="5000", position=1)] + assert "either a percentage or an amount" in _validate(contract, rows) + + def test_a_schedule_needs_titles(self, contract): + rows = [_row(title="", percentage="50"), _row(title=" ", percentage="50", position=1)] + assert "needs a title" in _validate(contract, rows) + + def test_an_empty_schedule_clears_the_contract(self, contract): + assert _validate(contract, []) is None + + def test_an_invoiced_milestone_cannot_be_removed(self, contract): + invoiced = PaymentMilestone(id=1, title="Upfront", percentage=Decimal("50"), position=0, invoiced=True) + rows = [_row(percentage="100")] + assert "already been invoiced" in _validate(contract, rows, existing=[invoiced]) + + def test_an_invoiced_milestone_cannot_be_repriced(self, contract): + invoiced = PaymentMilestone(id=1, title="Upfront", percentage=Decimal("50"), position=0, invoiced=True) + rows = [ + _row(percentage="70", existing=invoiced), + _row(percentage="30", position=1), + ] + assert "Cannot change the amount" in _validate(contract, rows, existing=[invoiced]) + + def test_an_invoiced_milestone_may_be_kept_unchanged(self, contract): + invoiced = PaymentMilestone(id=1, title="Upfront", percentage=Decimal("50"), position=0, invoiced=True) + rows = [ + _row(title="Upfront", percentage="50", existing=invoiced), + _row(percentage="50", position=1), + ] + assert _validate(contract, rows, existing=[invoiced]) is None + + +# --------------------------------------------------------------------------- +# E-invoicing guard +# --------------------------------------------------------------------------- + + +class TestEInvoiceGuard: + """Tuttle writes type code 380 with full totals, which misstates an + instalment; deposits and settlements therefore ship as PDF only.""" + + def test_a_deposit_is_not_embedded(self): + deposits, _ = _settlement("10000", "50", "50") + assert "prepayment type code" in unsupported_reason(deposits[0]) + + def test_a_final_invoice_is_not_embedded(self): + _, final = _settlement("10000", "50", "50") + assert "prepaid-amount settlement" in unsupported_reason(final) + + def test_an_ordinary_invoice_is_embedded(self): + project = _fixed_price_project(Decimal("1000")) + invoice = invoicing.generate_fixed_price_invoice( + contract=project.contract, + project=project, + number="2026-001", + date=datetime.date(2026, 2, 1), + ) + assert unsupported_reason(invoice) is None + + def test_embedding_a_deposit_is_refused_outright(self, tmp_path): + """The guard also protects direct callers of the einvoice module.""" + from tuttle.einvoice import embed_zugferd_in_pdf + from tuttle.model import User + + deposits, _ = _settlement("10000", "50", "50") + pdf = tmp_path / "deposit.pdf" + pdf.write_bytes(b"%PDF-1.4") + with pytest.raises(ValueError, match="Cannot embed e-invoice XML"): + embed_zugferd_in_pdf(pdf_path=str(pdf), invoice=deposits[0], user=User(name="Harry Tuttle")) diff --git a/tuttle_tests/test_rpc_dispatch.py b/tuttle_tests/test_rpc_dispatch.py index d3a81fdf..7f8428e2 100644 --- a/tuttle_tests/test_rpc_dispatch.py +++ b/tuttle_tests/test_rpc_dispatch.py @@ -28,6 +28,7 @@ ContractType, Invoice, Project, + TaxCategory, User, ) @@ -396,9 +397,16 @@ def test_all_rpc_computed_props_survive_session_close(self, rpc_env): for item in items: assert prop in item, f"{model_cls.__name__} missing computed prop '{prop}' after serialisation via {route}" - def test_deposit_and_final_invoice_serialize(self, rpc_env): - """A final invoice with linked deposits must serialise without - DetachedInstanceError when invoicing.get_all runs.""" + def test_deposit_and_final_invoice_lifecycle(self, rpc_env): + """Walk a fixed-price contract through its whole payment schedule: + two deposits, the final invoice deducting both, and settling the chain. + + The serialisation assertions guard against DetachedInstanceError, which + is what a deposit chain provokes when `invoicing.get_all` runs. The + second deposit matters on its own: the settlement once dropped every + deposit but the newest, because writing the milestone flags merged a + stale contract graph over the links that had just been made. + """ dispatch("db.ensure", {}) engine = sqlmodel.create_engine(f"sqlite:///{abstractions._active_db_path}") @@ -413,6 +421,10 @@ def test_deposit_and_final_invoice_serialize(self, rpc_env): contract.type = ContractType.fixed_price contract.rate = None contract.fixed_price = Decimal("10000") + # Pinned rather than inherited: the deduction amounts asserted below + # are only meaningful against a known VAT treatment. + contract.VAT_rate = Decimal("0.19") + contract.VAT_category = TaxCategory.standard sess.add(contract) sess.commit() contract_id = contract.id @@ -435,8 +447,9 @@ def test_deposit_and_final_invoice_serialize(self, rpc_env): { "contract_id": contract_id, "milestones": [ - {"title": "Upfront", "percentage": 50, "position": 0}, - {"title": "On delivery", "percentage": 50, "position": 1}, + {"title": "Upfront", "percentage": 40, "position": 0}, + {"title": "On commissioning", "percentage": 40, "position": 1}, + {"title": "On delivery", "percentage": 20, "position": 2}, ], }, ) @@ -452,58 +465,81 @@ def test_deposit_and_final_invoice_serialize(self, rpc_env): ) assert ms_list["ok"], f"get_milestones failed: {ms_list.get('error')}" milestones = ms_list["data"] - assert len(milestones) == 2 - - deposit_res = dispatch( - "invoicing.create_deposit", - { - "project_id": project_id, - "milestone_id": milestones[0]["id"], - "invoice_date": "2026-06-28", - }, - ) - assert deposit_res["ok"], f"create_deposit failed: {deposit_res.get('error')}" - - reset_all() + assert len(milestones) == 3 + + for milestone in milestones[:2]: + deposit_res = dispatch( + "invoicing.create_deposit", + { + "project_id": project_id, + "milestone_id": milestone["id"], + "invoice_date": "2026-06-28", + }, + ) + assert deposit_res["ok"], f"create_deposit failed: {deposit_res.get('error')}" + reset_all() result = dispatch("invoicing.get_all", {}) assert result["ok"], f"invoicing.get_all failed after deposit creation: {result.get('error')}" data = result["data"] - deposit = next((i for i in data if i.get("document_type") == "deposit"), None) - assert deposit is not None, "Deposit invoice not in get_all results" - assert deposit.get("deposit_deductions") is not None - assert deposit.get("remaining_balance") is not None - try: - json.dumps(deposit) - except (TypeError, ValueError) as exc: - pytest.fail(f"Deposit invoice not JSON-serializable: {exc}") - - reset_all() + deposits = [i for i in data if i.get("document_type") == "deposit" and i["project_id"] == project_id] + assert len(deposits) == 2, "Both deposit invoices should be in get_all results" + for deposit in deposits: + # 40% of 10,000 at 19% VAT. + assert Decimal(str(deposit["remaining_balance"])) == Decimal("4760") + assert deposit.get("deposit_deductions") == [] + try: + json.dumps(deposit) + except (TypeError, ValueError) as exc: + pytest.fail(f"Deposit invoice not JSON-serializable: {exc}") - deposit2_res = dispatch( + final_res = dispatch( "invoicing.create_deposit", { "project_id": project_id, - "milestone_id": milestones[1]["id"], + "milestone_id": milestones[2]["id"], "invoice_date": "2026-06-28", }, ) - assert deposit2_res["ok"], f"create_deposit (last milestone / final) failed: {deposit2_res.get('error')}" + assert final_res["ok"], f"create_deposit (last milestone / final) failed: {final_res.get('error')}" reset_all() result2 = dispatch("invoicing.get_all", {}) assert result2["ok"], f"invoicing.get_all failed after final invoice creation: {result2.get('error')}" data2 = result2["data"] - final = next((i for i in data2 if i.get("document_type") == "final"), None) + # Project-scoped: the demo data ships a settled milestone contract of + # its own, whose final invoice would otherwise be picked up here. + final = next( + (i for i in data2 if i.get("document_type") == "final" and i["project_id"] == project_id), + None, + ) assert final is not None, "Final invoice not in get_all results — last milestone should auto-create a final invoice" - assert isinstance(final.get("deposit_deductions"), list) - assert final.get("remaining_balance") is not None + deductions = final.get("deposit_deductions") + assert isinstance(deductions, list) + assert len(deductions) == 2, "The settlement must deduct every deposit of the contract" + # 11,900 gross less two 4,760 deposits leaves the closing 20% instalment. + assert Decimal(str(final["remaining_balance"])) == Decimal("2380") try: json.dumps(final) except (TypeError, ValueError) as exc: pytest.fail(f"Final invoice not JSON-serializable: {exc}") + reset_all() + + # Settling the Schlussrechnung settles the contract: its remaining + # balance is what is left after the deposits, so paying it means the + # deposits were paid too. + paid_res = dispatch("invoicing.toggle_paid", {"id": final["id"]}) + assert paid_res["ok"], f"toggle_paid failed: {paid_res.get('error')}" + + reset_all() + + data3 = assert_ok(dispatch("invoicing.get_all", {}))["data"] + chain = [i for i in data3 if i["id"] == final["id"] or i.get("deposit_for_id") == final["id"]] + assert len(chain) == 3, "Expected the final invoice and its two deposits" + assert all(i["paid"] for i in chain), "Paying the final invoice must settle its deposits" + def test_full_response_is_json_serializable(self, rpc_env): for method in [ "projects.get_all", diff --git a/ui/scripts/smoke-deposit.ts b/ui/scripts/smoke-deposit.ts new file mode 100644 index 00000000..8ac098ad --- /dev/null +++ b/ui/scripts/smoke-deposit.ts @@ -0,0 +1,130 @@ +/** + * Smoke test for the deposit / final invoice flow (issue #326). + * + * Usage (from ui/): + * TUTTLE_DATA_DIR=../artifacts/smoke-data npx tsx scripts/smoke-deposit.ts ../artifacts/ui + */ + +import { _electron as electron } from "playwright"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function main() { + const outDir = path.resolve(process.argv[2] || "../artifacts/ui"); + fs.mkdirSync(outDir, { recursive: true }); + const uiDir = path.resolve(__dirname, ".."); + const shot = async (win: any, name: string) => { + const p = path.join(outDir, `${name}.png`); + await win.screenshot({ path: p, type: "png" }); + console.log(` ✓ ${p}`); + }; + + const app = await electron.launch({ + args: [path.join(uiDir, "dist-electron/main.js")], + cwd: uiDir, + env: { ...process.env, NODE_ENV: "production" }, + }); + + const win = await app.firstWindow(); + await win.setViewportSize({ width: 1280, height: 860 }); + await win.evaluate(() => { + localStorage.setItem("tuttle-theme", "dark"); + document.documentElement.classList.add("dark"); + }); + await win.waitForLoadState("networkidle"); + await win.waitForTimeout(2500); + + const demoButton = win.locator("text=Try with demo data"); + if (await demoButton.isVisible({ timeout: 2000 }).catch(() => false)) { + console.log("Onboarding — activating demo user"); + await demoButton.click(); + await win.waitForTimeout(6000); + } else { + await win.evaluate(async () => { + const t = (window as any).tuttle; + await t.rpc("users.ensure_demo", {}); + await t.rpc("users.switch", { db_file: "harry-tuttle.db" }); + }); + await win.waitForTimeout(1000); + await win.reload(); + await win.waitForLoadState("networkidle"); + await win.waitForTimeout(3000); + } + + await win.locator("nav").first().waitFor({ state: "visible", timeout: 20000 }); + await win.evaluate(() => { + localStorage.setItem("tuttle-theme", "dark"); + document.documentElement.classList.add("dark"); + }); + + // ── Contracts: the payment schedule editor and its invoiced markers ─────── + console.log("Contracts view"); + await win.locator("nav button", { hasText: "Contracts" }).click(); + await win.waitForTimeout(1500); + await win.locator("text=Heating System Modernisation").first().click(); + await win.waitForTimeout(1200); + await shot(win, "01-contract-payment-schedule"); + + // ── Invoicing: the deposit chain, badges, and schedule status ───────────── + console.log("Invoicing view"); + await win.locator("nav button", { hasText: "Invoicing" }).click(); + await win.waitForTimeout(2000); + await shot(win, "02-invoicing-list"); + + // ── Create dialog: document type picker and open-milestone selector ─────── + console.log("Create Invoice dialog"); + await win.locator("button", { hasText: "Create Invoice" }).first().click(); + await win.waitForTimeout(1000); + await win.locator("select").first().selectOption({ label: "Heating Modernisation" }); + await win.waitForTimeout(1200); + await shot(win, "03-create-dialog-document-type"); + + await win.locator("button", { hasText: /^\s*Deposit\s*$/ }).click(); + await win.waitForTimeout(500); + await win.locator("select").nth(1).selectOption({ index: 1 }); + await win.waitForTimeout(500); + await shot(win, "04-create-dialog-milestone-selected"); + + console.log("Creating the deposit invoice"); + await win.locator("button", { hasText: /Create Deposit Invoice/ }).click(); + await win.waitForTimeout(12000); + await shot(win, "05-invoicing-after-deposit"); + + // ── Settle the schedule: the last open milestone becomes the final invoice ─ + console.log("Creating the final invoice"); + await win.locator("button", { hasText: "Create Invoice" }).first().click(); + await win.waitForTimeout(1000); + await win.locator("select").first().selectOption({ label: "Heating Modernisation" }); + await win.waitForTimeout(1200); + await win.locator("button", { hasText: /^\s*Deposit\s*$/ }).click(); + await win.waitForTimeout(500); + await win.locator("select").nth(1).selectOption({ index: 1 }); + await win.waitForTimeout(500); + await shot(win, "06-create-dialog-last-milestone"); + await win.locator("button", { hasText: /Create Final Invoice/ }).click(); + await win.waitForTimeout(12000); + await shot(win, "07-invoicing-after-final"); + + // ── The chain view: final invoice with its deposits nested underneath ────── + const finalRow = win.locator("text=Final").first(); + if (await finalRow.isVisible({ timeout: 2000 }).catch(() => false)) { + await finalRow.click(); + await win.waitForTimeout(1500); + await shot(win, "08-final-invoice-detail"); + } + + const errors = await win.locator("text=/Failed|Error|could not/i").allTextContents(); + if (errors.length) console.log("⚠ on-screen messages:", errors.slice(0, 5)); + + await app.close(); + console.log("done"); +} + +main().catch((err) => { + console.error("Smoke test failed:", err); + process.exit(1); +}); diff --git a/ui/src/api/entity.ts b/ui/src/api/entity.ts index e8e49a0e..231607b0 100644 --- a/ui/src/api/entity.ts +++ b/ui/src/api/entity.ts @@ -227,6 +227,9 @@ export function milestoneScheduleStatus( const deposits = chainDepositInvoices(root, nestedDeposits); const hasFinal = isFinalInvoice(root); + // An ordinary invoice on a contract that happens to have a schedule is not + // part of that schedule, so it must not advertise the schedule's progress. + if (!hasFinal && deposits.length === 0) return null; const issued = hasFinal ? [...deposits, root] : deposits; const paidCount = issued.filter((inv) => invoiceStatus(inv) === "Paid").length; From 43ea09e92848f54de1f9fe849a6806ab0fce6fff Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 8 Aug 2026 13:58:25 +0200 Subject: [PATCH 3/5] style(templates): set deposit/final presentation into each template's own idiom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coloured banner, boxed context and all-caps number label announced the document type at full volume in every template, fighting the design of skins that are deliberately quiet. An invoice already says what it is in its heading and number — the type only needs to register, not to shout. - Banners now appear for reminders only. Deposit and final carry the type as a "document type" meta-table row (modern, minimal, bold), a qualifier in the heading (grayshades, anvil, base) or their existing title block (classic). - The deposit context loses its grey box and accent-coloured micro-labels; it reads as a short key/value list between two hairlines. - The remaining banner keeps a single left rule in the template's accent colour instead of a filled, type-coded strip. Co-authored-by: Cursor --- templates/_shared/document-type.css | 64 ++++++++++------------- templates/_shared/document_type.html | 9 ++-- templates/invoice-anvil/invoice.html | 5 +- templates/invoice-bold/invoice.html | 10 +++- templates/invoice-classic/invoice.html | 2 +- templates/invoice-grayshades/invoice.html | 4 +- templates/invoice-minimal/invoice.html | 10 +++- templates/invoice-modern/invoice.html | 10 +++- templates/invoice/invoice.html | 3 +- tuttle/rendering.py | 3 ++ tuttle_tests/test_rendering.py | 3 +- 11 files changed, 71 insertions(+), 52 deletions(-) diff --git a/templates/_shared/document-type.css b/templates/_shared/document-type.css index 31fae0ce..841a5509 100644 --- a/templates/_shared/document-type.css +++ b/templates/_shared/document-type.css @@ -1,52 +1,46 @@ /* Deposit / final / reminder presentation, shared by every invoice template. * - * Neutral by design: colours come from each template's own palette through - * --invoice-accent, and a template can override any rule because its own - * stylesheet is linked after this one. */ + * Understated by design: an invoice is already the document it says it is in + * its heading and number, so these blocks only annotate. Colour comes from + * the template's own palette through --invoice-accent; a template can + * override any rule because its own stylesheet is linked after this one. */ +/* A banner only where a template explicitly places one; most templates + * instead render a quiet "document type" meta row, or carry the type in the + * heading as a qualifier. */ .document-type-banner { - font-size: 13pt; + font-size: 9pt; font-weight: 700; - letter-spacing: 0.6pt; + letter-spacing: 0.8pt; text-transform: uppercase; - padding: 9pt 12pt; - margin-bottom: 10pt; -} - -.document-type-banner.deposit { - background: #e8f0fe; - color: #1e40af; - border-left: 4pt solid #2563eb; + padding: 3pt 8pt; + margin-bottom: 8pt; + color: var(--invoice-accent, #374151); + border-left: 2pt solid var(--invoice-accent, #374151); } -.document-type-banner.final { - background: #f3e8ff; - color: #6b21a8; - border-left: 4pt solid #7c3aed; -} - -.document-type-banner.reminder { - background: #fef3c7; - color: #92400e; - border-left: 4pt solid #f59e0b; +/* The type in the heading ("Rechnung 2026-042 · Schlussrechnung"), set a + * step down so it informs without competing with the number. */ +.document-type-qualifier { + font-size: 0.6em; + font-weight: 400; + color: #555; } +/* What a deposit bills: which contract, which milestone, of what total. A + * short key/value list, set off only by the rules above and below it. */ .deposit-context { - font-size: 9pt; - color: #444; - margin-bottom: 12pt; - line-height: 1.65; - padding: 8pt 10pt; - background: #f9fafb; - border: 0.5pt solid #e5e7eb; + font-size: 8.5pt; + line-height: 1.6; + margin: 6pt 0 12pt; + padding: 5pt 0; + border-top: 0.5pt solid #d1d5db; + border-bottom: 0.5pt solid #d1d5db; } .deposit-context .label { - color: var(--invoice-accent, #1e40af); + color: var(--invoice-accent, #6b7280); font-weight: 600; - text-transform: uppercase; - font-size: 7pt; - letter-spacing: 0.4pt; margin-right: 4pt; } @@ -54,7 +48,7 @@ * gross amount above it rather than as another charge. */ .deposit-deduction, .deposit-deduction td { - color: #666; + color: #555; } .deposit-deduction-vat, diff --git a/templates/_shared/document_type.html b/templates/_shared/document_type.html index bfd274f1..b8e3e2b4 100644 --- a/templates/_shared/document_type.html +++ b/templates/_shared/document_type.html @@ -10,13 +10,14 @@ provides the class names that `document-type.css` styles. #} +{# + Banner only for reminders. Deposit and final invoices announce themselves in + the meta table and heading of every template — a full-width coloured strip + on top of that would be noise. +#} {% macro banner(l, is_reminder=False, is_deposit=False, is_final=False, reminder_title="") %} {%- if is_reminder %}
{{ reminder_title }}
-{%- elif is_deposit %} -
{{ l.deposit_invoice }}
-{%- elif is_final %} -
{{ l.final_invoice }}
{%- endif %} {% endmacro %} diff --git a/templates/invoice-anvil/invoice.html b/templates/invoice-anvil/invoice.html index 11829a64..d42255dc 100644 --- a/templates/invoice-anvil/invoice.html +++ b/templates/invoice-anvil/invoice.html @@ -76,11 +76,12 @@
- {% if is_reminder or is_deposit or is_final %} + {% if is_reminder %} {{ doc.document_title(l, invoice, is_reminder, is_deposit, is_final, reminder_title) }}
{% endif %} Invoice Date: {{ invoice.date }}
- {{ doc.number_label(l, is_deposit, is_final) }}: {{ invoice.number }} + {{ l.invoice_no }}: {{ invoice.number }}{% if is_deposit or is_final %}
+ {{ l.document_type }}: {% if is_deposit %}{{ l.deposit_invoice }}{% else %}{{ l.final_invoice }}{% endif %}{% endif %} diff --git a/templates/invoice-bold/invoice.html b/templates/invoice-bold/invoice.html index e2f53b7f..ed5b79d5 100644 --- a/templates/invoice-bold/invoice.html +++ b/templates/invoice-bold/invoice.html @@ -28,7 +28,7 @@
- {{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }} + {% if is_reminder %}{{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }}{% endif %} {% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %} @@ -59,9 +59,15 @@ {% else %} - {{ doc.number_label(l, is_deposit, is_final) }} + {{ l.invoice_no }} {{ invoice.number }} + {% if is_deposit or is_final %} + + {{ l.document_type }} + {% if is_deposit %}{{ l.deposit_invoice }}{% else %}{{ l.final_invoice }}{% endif %} + + {% endif %} {% endif %} {{ l.date }} diff --git a/templates/invoice-classic/invoice.html b/templates/invoice-classic/invoice.html index 995323e8..de5deffe 100644 --- a/templates/invoice-classic/invoice.html +++ b/templates/invoice-classic/invoice.html @@ -22,7 +22,7 @@
{% if is_reminder %}{{ reminder_title }}{% elif is_deposit %}{{ l.deposit_invoice }}{% elif is_final %}{{ l.final_invoice }}{% else %}{{ l.invoice }}{% endif %}
- + diff --git a/templates/invoice-grayshades/invoice.html b/templates/invoice-grayshades/invoice.html index f0ec11ae..b7442a76 100644 --- a/templates/invoice-grayshades/invoice.html +++ b/templates/invoice-grayshades/invoice.html @@ -45,9 +45,9 @@ - {{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }} + {% if is_reminder %}{{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }}{% endif %} -

{{ doc.number_label(l, is_deposit, is_final) }} {{ invoice.number }}

+

{{ l.invoice_no }} {{ invoice.number }}{% if is_deposit or is_final %} ({% if is_deposit %}{{ l.deposit_invoice }}{% else %}{{ l.final_invoice }}{% endif %}){% endif %}

{% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %} diff --git a/templates/invoice-minimal/invoice.html b/templates/invoice-minimal/invoice.html index c43d91d2..cdf8fc17 100644 --- a/templates/invoice-minimal/invoice.html +++ b/templates/invoice-minimal/invoice.html @@ -46,14 +46,20 @@
- {{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }} + {% if is_reminder %}{{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }}{% endif %} {% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %}
{{ doc.number_label(l, is_deposit, is_final) }}{{ l.invoice_no }} {{ invoice.number }}
- + + {% if is_deposit or is_final %} + + + + + {% endif %} {% if is_reminder %} diff --git a/templates/invoice-modern/invoice.html b/templates/invoice-modern/invoice.html index 9ab6528a..b59f605a 100644 --- a/templates/invoice-modern/invoice.html +++ b/templates/invoice-modern/invoice.html @@ -47,14 +47,20 @@
- {{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }} + {% if is_reminder %}{{ doc.banner(l, is_reminder, is_deposit, is_final, reminder_title) }}{% endif %} {% if is_deposit %}{{ doc.deposit_context(l, contract_title, milestone_title, milestone_percentage, contract_total) }}{% endif %}
{{ doc.number_label(l, is_deposit, is_final) }}{{ l.invoice_no }} {{ invoice.number }}
{{ l.document_type }}{% if is_deposit %}{{ l.deposit_invoice }}{% else %}{{ l.final_invoice }}{% endif %}
{{ l.original_invoice }}
- + + {% if is_deposit or is_final %} + + + + + {% endif %} {% if is_reminder %} diff --git a/templates/invoice/invoice.html b/templates/invoice/invoice.html index f45b8541..c82baf2a 100644 --- a/templates/invoice/invoice.html +++ b/templates/invoice/invoice.html @@ -61,7 +61,8 @@

{% if is_reminder %}{{ reminder_title }}{% else %}{{ doc.document_title(l, i

- {{ doc.number_label(l, is_deposit, is_final) }}: {{ invoice.number }}
+ {{ l.invoice_no }}: {{ invoice.number }}{% if is_deposit or is_final %}
+ {{ l.document_type }}: {% if is_deposit %}{{ l.deposit_invoice }}{% else %}{{ l.final_invoice }}{% endif %}{% endif %}
Date: {{ invoice.date }}

diff --git a/tuttle/rendering.py b/tuttle/rendering.py index a6a4c7d1..b9c09d42 100644 --- a/tuttle/rendering.py +++ b/tuttle/rendering.py @@ -53,6 +53,7 @@ "reminder_n": "{n}. Payment Reminder", "reminder_fee": "Reminder Fee", "original_invoice": "Original Invoice", + "document_type": "Document type", "reminder_closing": "Please settle the outstanding amount by the new due date.", "deposit_invoice": "Deposit Invoice", "final_invoice": "Final Invoice", @@ -100,6 +101,7 @@ "reminder_n": "{n}. Mahnung", "reminder_fee": "Mahngebühr", "original_invoice": "Ursprungsrechnung", + "document_type": "Belegart", "reminder_closing": "Bitte begleichen Sie den offenen Betrag bis zum neuen Fälligkeitsdatum.", "deposit_invoice": "Abschlagsrechnung", "final_invoice": "Schlussrechnung", @@ -147,6 +149,7 @@ "reminder_n": "{n}.º recordatorio de pago", "reminder_fee": "Cargo por recordatorio", "original_invoice": "Factura original", + "document_type": "Tipo de documento", "reminder_closing": "Le rogamos abone el importe pendiente antes de la nueva fecha de vencimiento.", "deposit_invoice": "Factura de anticipo", "final_invoice": "Factura final", diff --git a/tuttle_tests/test_rendering.py b/tuttle_tests/test_rendering.py index 6e8c806f..60321cc7 100644 --- a/tuttle_tests/test_rendering.py +++ b/tuttle_tests/test_rendering.py @@ -90,7 +90,8 @@ def test_deposit_invoice_html_shows_document_type(self, fake): only_final=False, ) - assert "document-type-banner deposit" in html + # A deposit announces itself in the meta table, not through a banner. + assert "document-type-banner" not in html assert "Deposit Invoice" in html assert "Deposit due" in html From 83e71edd9ea3826e6e3f423192da02b4ecbd8025 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 23 Aug 2026 14:54:46 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(invoicing):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20schedule=20disclosure,=20settlement-aware=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from aaronspring on #349: - Contract form: collapsing the payment schedule no longer makes it vanish. "Schedule enabled" and "collapsed" are now separate state; the collapsed disclosure reads "> N milestones" and re-expands in place, and saving no longer depends on the visual collapse state. - Invoice details: a final invoice now leads with the settlement — contract total, deposits deducted, and the remaining balance due — instead of the same Subtotal/VAT/Total cards a deposit shows. A deposit invoice's details name the milestone it bills (with its percentage of the contract) and the contract total for reference. - Re-parent the deposit/final migration onto the multiple-bank-accounts head that landed on main since the last rebase. Deposit invoices stay fixed-price-only by design: an Abschlagsrechnung bills a share of an agreed total, which a time-based contract does not have — its analogue is the ordinary periodic invoice. Enforced in the create dialog, the intents, and the generators. Co-authored-by: Cursor --- ...1100c34b90c6_deposit_and_final_invoices.py | 4 +- tuttle/model.py | 18 ++- ui/src/components/business/ContractsView.tsx | 105 ++++++++++-------- ui/src/components/invoicing/InvoicingView.tsx | 36 +++++- 4 files changed, 110 insertions(+), 53 deletions(-) diff --git a/tuttle/migrations/versions/1100c34b90c6_deposit_and_final_invoices.py b/tuttle/migrations/versions/1100c34b90c6_deposit_and_final_invoices.py index 0fb4b913..1c556ee4 100644 --- a/tuttle/migrations/versions/1100c34b90c6_deposit_and_final_invoices.py +++ b/tuttle/migrations/versions/1100c34b90c6_deposit_and_final_invoices.py @@ -1,7 +1,7 @@ """deposit and final invoices Revision ID: 1100c34b90c6 -Revises: 34dd17917a18 +Revises: f87515d1d068 Create Date: 2026-06-28 10:36:58.702409 ====================================================================== @@ -47,7 +47,7 @@ from alembic import op revision: str = "1100c34b90c6" -down_revision: Union[str, Sequence[str], None] = "9cad5ae77a79" +down_revision: Union[str, Sequence[str], None] = "f87515d1d068" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/tuttle/model.py b/tuttle/model.py index b1a80e84..ddba5e3b 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -519,7 +519,7 @@ class Contract(RpcMixin, VatCategoryMixin, SQLModel, table=True): "bank_account": None, "payment_milestones": None, } - __rpc_computed__ = ("unit_abbrev", "is_fixed_price", "has_milestones") + __rpc_computed__ = ("unit_abbrev", "is_fixed_price", "has_milestones", "fixed_price_formatted") id: Optional[int] = Field(default=None, primary_key=True) title: str = Field( @@ -649,6 +649,12 @@ def is_fixed_price(self) -> bool: def has_milestones(self) -> bool: return bool(self.payment_milestones) + @property + def fixed_price_formatted(self) -> str: + if self.fixed_price is None: + return "" + return fmt_currency(self.fixed_price, self.currency) + @property def unit_abbrev(self) -> str: """Short display label for the billing unit, e.g. 'h' or 'd'.""" @@ -1059,6 +1065,7 @@ class Invoice(RpcMixin, SQLModel, table=True): "timesheet_pdf_path", "remaining_balance", "remaining_balance_formatted", + "deposits_deducted_formatted", "deposit_deductions", ) @@ -1334,6 +1341,15 @@ def remaining_balance_formatted(self) -> str: currency = self.contract.currency if self.contract else "EUR" return fmt_currency(self.remaining_balance, currency) + @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: + return "" + currency = self.contract.currency if self.contract else "EUR" + deducted = sum(d["gross"] for d in self.deposit_deductions) + return fmt_currency(deducted, currency) + @property def client(self): return self.contract.client diff --git a/ui/src/components/business/ContractsView.tsx b/ui/src/components/business/ContractsView.tsx index 862d3eea..b269ff65 100644 --- a/ui/src/components/business/ContractsView.tsx +++ b/ui/src/components/business/ContractsView.tsx @@ -622,11 +622,14 @@ function ContractForm({ contract, clients, defaultCurrency, currencies, bankAcco const isNew = !contract; const isFixed = pricingMode === "fixed_price"; - const [milestonesOpen, setMilestonesOpen] = useState(() => { + // Whether this contract carries a payment schedule at all — distinct from + // whether the milestone list is collapsed, which is purely visual. + const [scheduleEnabled, setScheduleEnabled] = useState(() => { if (!contract) return false; const ms = entityList(contract, "payment_milestones"); return ms.length > 0; }); + const [scheduleCollapsed, setScheduleCollapsed] = useState(false); const [milestones, setMilestones] = useState(() => { if (!contract) return []; return entityList(contract, "payment_milestones").map((m) => ({ @@ -709,7 +712,7 @@ function ContractForm({ contract, clients, defaultCurrency, currencies, bankAcco return; } const schedule = milestones.filter((m) => !isBlankMilestone(m)); - if (isFixed && milestonesOpen && schedule.length > 0) { + if (isFixed && scheduleEnabled && schedule.length > 0) { if (schedule.some((m) => !m.title.trim())) { setValidationError("Give every payment milestone a title"); return; @@ -723,7 +726,7 @@ function ContractForm({ contract, clients, defaultCurrency, currencies, bankAcco setSaving(true); const ok = await onSave( { ...form, charges }, - isFixed && milestonesOpen ? { open: true, milestones: schedule } : undefined, + isFixed && scheduleEnabled ? { open: true, milestones: schedule } : undefined, ); setSaving(false); if (!ok) return; @@ -925,7 +928,7 @@ function ContractForm({ contract, clients, defaultCurrency, currencies, bankAcco {isFixed && (
- {!milestonesOpen ? ( + {!scheduleEnabled ? (

Split a fixed-price contract into instalments for deposit and final invoices. @@ -933,7 +936,8 @@ function ContractForm({ contract, clients, defaultCurrency, currencies, bankAcco

) : (
- -
- {milestones.map((m, idx) => ( -
- setMilestones((prev) => prev.map((ms, i) => i === idx ? { ...ms, title: e.target.value } : ms))} - disabled={m.invoiced} - className={`flex-1 ${inputCls} ${m.invoiced ? "opacity-50" : ""}`} /> -
- setMilestones((prev) => prev.map((ms, i) => i === idx ? { ...ms, percentage: e.target.value } : ms))} - disabled={m.invoiced} - className={`w-20 ${inputCls} ${m.invoiced ? "opacity-50" : ""}`} /> - % -
- {m.invoiced ? ( - Invoiced - ) : ( - - )} + {!scheduleCollapsed && ( + <> +
+ {milestones.map((m, idx) => ( +
+ setMilestones((prev) => prev.map((ms, i) => i === idx ? { ...ms, title: e.target.value } : ms))} + disabled={m.invoiced} + className={`flex-1 ${inputCls} ${m.invoiced ? "opacity-50" : ""}`} /> +
+ setMilestones((prev) => prev.map((ms, i) => i === idx ? { ...ms, percentage: e.target.value } : ms))} + disabled={m.invoiced} + className={`w-20 ${inputCls} ${m.invoiced ? "opacity-50" : ""}`} /> + % +
+ {m.invoiced ? ( + Invoiced + ) : ( + + )} +
+ ))}
- ))} -
- - {milestones.length > 0 && (() => { - const total = milestones.reduce((s, m) => s + (parseFloat(m.percentage) || 0), 0); - const ok = Math.abs(total - 100) < 0.01; - return ( -
- Total: {total.toFixed(1)}%{!ok && " (must be 100%)"} -
- ); - })()} + + {milestones.length > 0 && (() => { + const total = milestones.reduce((s, m) => s + (parseFloat(m.percentage) || 0), 0); + const ok = Math.abs(total - 100) < 0.01; + return ( +
+ Total: {total.toFixed(1)}%{!ok && " (must be 100%)"} +
+ ); + })()} + + )}
)}
diff --git a/ui/src/components/invoicing/InvoicingView.tsx b/ui/src/components/invoicing/InvoicingView.tsx index f3ab4dfa..7e6a6be7 100644 --- a/ui/src/components/invoicing/InvoicingView.tsx +++ b/ui/src/components/invoicing/InvoicingView.tsx @@ -1138,10 +1138,22 @@ 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. */}
- - - + {isFinalInvoice(invoice) ? ( + <> + + + + + ) : ( + <> + + + + + )}
{/* Actions group */} @@ -1311,6 +1323,24 @@ function InvoiceDetail({ invoice, allInvoices, onToggleSent, onTogglePaid, onTog {isRem && num(invoice, "reminder_fee") > 0 && ( } label="Reminder Fee" value={String(num(invoice, "reminder_fee"))} /> )} + {/* A deposit bills one milestone of the contract's schedule — + say which, and how large a share of the contract it is. */} + {isDeposit(invoice) && (() => { + const contract = subEntity(invoice, "contract"); + const milestoneId = num(invoice, "milestone_id"); + const milestone = contract + ? entityList(contract, "payment_milestones").find((m) => m.id === milestoneId) + : undefined; + const pct = milestone ? num(milestone, "percentage") : 0; + return ( + <> + } label="Milestone" + value={depositLabel ? `${depositLabel}${pct ? ` — ${pct}%` : ""}` : "—"} /> + } label="Contract total" + value={contract ? str(contract, "fixed_price_formatted") || "—" : "—"} /> + + ); + })()} From 7323c3faa4af711e3bb72c2447e9a76d1928ad5d Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 23 Aug 2026 15:02:14 +0200 Subject: [PATCH 5/5] fix(forecasting): skip fixed-price contracts in calendar revenue; widen invoice list Fixed-price contracts have rate=None, so revenue_from_calendar and contract_revenue_forecast crashed with "unsupported operand type(s) for *: 'decimal.Decimal' and 'NoneType'" when the demo data introduced milestone-billed contracts with calendar events. Guard both call sites. Widen the invoice list panel from 480px to 520px so deposit chain rows, milestone labels, and amount columns have room to breathe. Co-authored-by: Cursor --- tuttle/forecasting.py | 4 ++++ ui/src/components/shared/ToolbarButtons.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tuttle/forecasting.py b/tuttle/forecasting.py index 495a943a..92c09bbf 100644 --- a/tuttle/forecasting.py +++ b/tuttle/forecasting.py @@ -50,6 +50,8 @@ def monthly_revenue_from_contracts( else: billable_units = workdays_in_month * contract.units_per_workday + if not contract.rate: + continue monthly_revenue = Decimal(str(billable_units)) * contract.rate project_title = contract.projects[0].title if contract.projects else contract.title @@ -229,6 +231,8 @@ def revenue_from_calendar( if not project: continue contract = project.contract + if not contract.rate: + continue unit_hours = contract.units_per_workday if contract.unit == TimeUnit.day else 1 billable_units = row["hours"] / unit_hours revenue = float(Decimal(str(billable_units)) * contract.rate) diff --git a/ui/src/components/shared/ToolbarButtons.tsx b/ui/src/components/shared/ToolbarButtons.tsx index 8e8cd286..f015b4f9 100644 --- a/ui/src/components/shared/ToolbarButtons.tsx +++ b/ui/src/components/shared/ToolbarButtons.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; /* ── Layout constants ─────────────────────────────────────────────────── */ -export const LIST_PANEL_WIDTH = "w-[480px]"; +export const LIST_PANEL_WIDTH = "w-[520px]"; export const LIST_ROW_PADDING = "px-4 py-3.5"; /* ── List / Detail split layout ───────────────────────────────────────── */

{{ doc.number_label(l, is_deposit, is_final) }}{{ l.invoice_no }} {{ invoice.number }}
{{ l.document_type }}{% if is_deposit %}{{ l.deposit_invoice }}{% else %}{{ l.final_invoice }}{% endif %}
{{ l.original_invoice }}