diff --git a/.cursor/rules/python-imports.mdc b/.cursor/rules/python-imports.mdc index 9eaf38f3..95e9a5c5 100644 --- a/.cursor/rules/python-imports.mdc +++ b/.cursor/rules/python-imports.mdc @@ -1,7 +1,7 @@ --- description: Python import conventions — no imports inside function bodies globs: "**/*.py" -alwaysApply: false +alwaysApply: true --- # Python Imports diff --git a/templates/invoice-modern/invoice.css b/templates/invoice-modern/invoice.css index 928a4c4d..a5c78f96 100644 --- a/templates/invoice-modern/invoice.css +++ b/templates/invoice-modern/invoice.css @@ -304,3 +304,51 @@ body { .footer span { color: #888; } + +/* ── 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 07a7a9bd..4bc79ee1 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 %} @@ -110,7 +128,7 @@
- {{ l.subtotal }} + {% if is_final %}{{ l.total_fee }}{% else %}{{ l.subtotal }}{% endif %} {{ invoice.sum | as_currency }}
@@ -123,16 +141,38 @@ {{ 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 %}
-

{% 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 %}

{{ user.name }}
diff --git a/tuttle/app/contracts/intent.py b/tuttle/app/contracts/intent.py index ae141f97..4a400ee3 100644 --- a/tuttle/app/contracts/intent.py +++ b/tuttle/app/contracts/intent.py @@ -1,9 +1,11 @@ +from decimal import Decimal + from ..clients.intent import ClientsIntent from ..contacts.intent import ContactsIntent from ..core.abstractions import CrudIntent from ..core.intent_result import IntentResult -from ...model import Client, Contract, User, normalize_vat_rate +from ...model import Client, Contract, PaymentMilestone, User, normalize_vat_rate from ...tax import get_tax_system @@ -15,7 +17,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__() @@ -87,3 +89,104 @@ 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: + """Save payment milestones for a contract. + + Replaces all existing milestones with the provided list. + Each entry is a dict with keys: title, percentage, amount, position. + """ + 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} + incoming_ids = set() + new_milestones = [] + + for i, m in enumerate(milestones): + mid = m.get("id") + pct = m.get("percentage") + amt = m.get("amount") + if mid and mid in existing_by_id: + incoming_ids.add(mid) + ms = existing_by_id[mid] + ms.title = m.get("title", ms.title) + ms.percentage = Decimal(str(pct)) if pct is not None else None + ms.amount = Decimal(str(amt)) if amt is not None else None + ms.position = i + new_milestones.append(ms) + else: + ms = PaymentMilestone( + contract_id=contract_id, + title=m.get("title", ""), + percentage=Decimal(str(pct)) if pct is not None else None, + amount=Decimal(str(amt)) if amt is not None else None, + position=i, + invoiced=False, + ) + new_milestones.append(ms) + + if new_milestones: + if all(ms.percentage is not None for ms in new_milestones): + total_pct = sum(Decimal(str(ms.percentage)) for ms in new_milestones) + if total_pct != Decimal("100"): + return IntentResult( + was_intent_successful=False, + error_msg=f"Milestone percentages must sum to 100% (currently {total_pct}%).", + ) + elif all(ms.amount is not None for ms in new_milestones): + if contract.fixed_price is None: + return IntentResult( + was_intent_successful=False, + error_msg="Fixed-price contract required for amount-based milestones.", + ) + total_amt = sum(Decimal(str(ms.amount)) for ms in new_milestones) + fixed = Decimal(str(contract.fixed_price)) + if total_amt != fixed: + return IntentResult( + was_intent_successful=False, + error_msg=( + f"Milestone amounts must sum to the contract fixed price " + f"({fixed}, currently {total_amt})." + ), + ) + else: + return IntentResult( + was_intent_successful=False, + error_msg="Each milestone must use either percentage or amount consistently.", + ) + + # Delete removed milestones (only if not yet invoiced) + for old_id, old_ms in existing_by_id.items(): + if old_id not in incoming_ids: + if old_ms.invoiced: + return IntentResult( + was_intent_successful=False, + error_msg=f"Cannot remove milestone '{old_ms.title}' — it has already been invoiced.", + ) + self.delete_by_id(PaymentMilestone, old_id) + + for ms in new_milestones: + self.store(ms) + + return IntentResult(was_intent_successful=True) + + 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/intent.py b/tuttle/app/invoicing/intent.py index 6b1367ff..9fdbb148 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -29,7 +29,7 @@ from ..timetracking.intent import TimeTrackingIntent from ...app_db import AppDatabase from ... import invoicing, mail, rendering, timetracking -from ...model import Invoice, InvoiceItem, Project, Timesheet, User +from ...model import Invoice, InvoiceItem, PaymentMilestone, Project, Timesheet, User from .data_source import InvoicingDataSource @@ -153,6 +153,241 @@ def _to_date(v): template_name=template_name, ) + def create_deposit( + self, + project_id, + milestone_id, + invoice_date, + ) -> IntentResult: + """RPC entry-point for creating a deposit invoice.""" + + def _to_date(v): + return v if isinstance(v, date) else _dt.date.fromisoformat(v) + + proj_result = self._projects_intent.get_by_id(project_id) + if not proj_result.was_intent_successful: + return proj_result + + project = proj_result.data + contract = project.contract + + if not contract.is_fixed_price: + return IntentResult( + was_intent_successful=False, + error_msg="Deposit invoices require a fixed-price contract.", + ) + + milestone = None + for m in contract.payment_milestones: + if m.id == int(milestone_id): + milestone = m + break + 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 and open_milestones[0].id == milestone.id: + result = self.create_final(project_id, invoice_date) + if result.was_intent_successful: + milestone.invoiced = True + self._invoicing_data_source.store(milestone) + return result + + app_db = AppDatabase() + language = app_db.get_setting(PreferencesStorageKeys.language_key.value) or "en" + template_name = ( + app_db.get_setting(PreferencesStorageKeys.invoice_template_key.value) + or DEFAULT_INVOICE_TEMPLATE + ) + number_scheme = ( + app_db.get_setting(PreferencesStorageKeys.invoice_number_scheme_key.value) + or DEFAULT_INVOICE_NUMBER_SCHEME + ) + + try: + user = self._user_data_source.get_user() + invoice_number = self._invoicing_data_source.generate_invoice_number( + _to_date(invoice_date), scheme=number_scheme + ) + invoice = invoicing.generate_deposit_invoice( + contract=contract, + project=project, + milestone=milestone, + number=invoice_number, + date=_to_date(invoice_date), + ) + + render_warnings: list[str] = [] + resolved_template = template_name or DEFAULT_INVOICE_TEMPLATE + logo_result = self._preferences_intent.get_include_logo() + resolved_include_logo = ( + logo_result.data + if logo_result.was_intent_successful and logo_result.data is not None + else True + ) + try: + rendering.render_invoice( + user=user, + invoice=invoice, + out_dir=get_data_dir() / "Invoices", + template_name=resolved_template, + only_final=True, + language=language, + accent_color=user.accent_color or "", + include_logo=resolved_include_logo, + ) + except Exception as ex: + logger.error(f"Error rendering deposit invoice: {ex}") + logger.exception(ex) + render_warnings.append(f"Invoice PDF could not be generated: {ex}") + + milestone.invoiced = True + self._invoicing_data_source.save_invoice(invoice) + self._invoicing_data_source.store(milestone) + + warning_msg = "; ".join(render_warnings) if render_warnings else "" + return IntentResult( + was_intent_successful=True, + data=invoice, + warning=warning_msg, + ) + except Exception as ex: + logger.error("Failed to create deposit invoice.") + logger.exception(ex) + return IntentResult( + was_intent_successful=False, + error_msg="Failed to create deposit invoice.", + ) + + def create_final( + self, + project_id, + invoice_date, + ) -> IntentResult: + """RPC entry-point for creating a final invoice (Schlussrechnung).""" + + def _to_date(v): + return v if isinstance(v, date) else _dt.date.fromisoformat(v) + + proj_result = self._projects_intent.get_by_id(project_id) + if not proj_result.was_intent_successful: + return proj_result + + project = proj_result.data + contract = project.contract + + if not contract.is_fixed_price: + return IntentResult( + was_intent_successful=False, + error_msg="Final invoices require a fixed-price contract.", + ) + + all_invoices_result = self._invoicing_data_source.get_all_invoices() + if not all_invoices_result.was_intent_successful: + return all_invoices_result + + deposit_invoices = [ + inv + for inv in all_invoices_result.data + if inv.is_deposit + and inv.contract_id == contract.id + and inv.project_id == project.id + and not inv.cancelled + ] + + app_db = AppDatabase() + language = app_db.get_setting(PreferencesStorageKeys.language_key.value) or "en" + template_name = ( + app_db.get_setting(PreferencesStorageKeys.invoice_template_key.value) + or DEFAULT_INVOICE_TEMPLATE + ) + number_scheme = ( + app_db.get_setting(PreferencesStorageKeys.invoice_number_scheme_key.value) + or DEFAULT_INVOICE_NUMBER_SCHEME + ) + + try: + user = self._user_data_source.get_user() + invoice_number = self._invoicing_data_source.generate_invoice_number( + _to_date(invoice_date), scheme=number_scheme + ) + invoice = invoicing.generate_final_invoice( + contract=contract, + project=project, + deposit_invoices=deposit_invoices, + number=invoice_number, + date=_to_date(invoice_date), + ) + + self._invoicing_data_source.save_invoice(invoice) + + # Re-link deposits to the final invoice now that it has an id + for dep in deposit_invoices: + dep.deposit_for_id = invoice.id + self._invoicing_data_source.save_invoice(dep) + + # Re-load for rendering with populated deposits + reload = self._invoicing_data_source.get_invoice_by_id(invoice.id) + if reload.was_intent_successful and reload.data: + invoice = reload.data + + render_warnings: list[str] = [] + resolved_template = template_name or DEFAULT_INVOICE_TEMPLATE + logo_result = self._preferences_intent.get_include_logo() + resolved_include_logo = ( + logo_result.data + if logo_result.was_intent_successful and logo_result.data is not None + else True + ) + try: + rendering.render_invoice( + user=user, + invoice=invoice, + out_dir=get_data_dir() / "Invoices", + template_name=resolved_template, + only_final=True, + language=language, + accent_color=user.accent_color or "", + include_logo=resolved_include_logo, + ) + self._invoicing_data_source.save_invoice(invoice) + except Exception as ex: + logger.error(f"Error rendering final invoice: {ex}") + logger.exception(ex) + render_warnings.append(f"Invoice PDF could not be generated: {ex}") + + # Warn if any deposits are unpaid + 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) + render_warnings.append(f"Deposit invoices still unpaid: {nums}") + + final = self._invoicing_data_source.get_invoice_by_id(invoice.id) + if final.was_intent_successful and final.data: + invoice = final.data + + warning_msg = "; ".join(render_warnings) if render_warnings else "" + return IntentResult( + was_intent_successful=True, + data=invoice, + warning=warning_msg, + ) + except Exception as ex: + logger.error("Failed to create final invoice.") + logger.exception(ex) + return IntentResult( + was_intent_successful=False, + error_msg="Failed to create final invoice.", + ) + def toggle_sent(self, id) -> IntentResult: return self._toggle("sent", id) @@ -679,13 +914,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 @@ -693,7 +927,20 @@ 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 + + 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 4adf4123..8c6b4f4d 100644 --- a/tuttle/demo.py +++ b/tuttle/demo.py @@ -28,6 +28,7 @@ FinancialGoal, Invoice, InvoiceItem, + PaymentMilestone, Timesheet, TimeTrackingItem, Project, @@ -506,27 +507,42 @@ 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" + 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"), + 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 = { @@ -561,9 +577,12 @@ def create_heating_data( today = datetime.date.today() invoices = [] + sam_lowry_project = None for i, project in enumerate(projects): + 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, @@ -576,6 +595,61 @@ def create_heating_data( else: inv = create_fake_invoice(fake, project=project, user=user) invoices.append(inv) + + # -- deposit / final invoice workflow for Sam Lowry ----------------------- + milestones = [] + deposit_invoices = [] + if sam_lowry_contract and sam_lowry_project: + from tuttle.invoicing import generate_deposit_invoice, generate_final_invoice + + ms1 = PaymentMilestone( + contract=sam_lowry_contract, + title="Half upfront", + percentage=Decimal("50"), + position=0, + invoiced=True, + ) + ms2 = PaymentMilestone( + contract=sam_lowry_contract, + title="Half on delivery", + percentage=Decimal("50"), + position=1, + invoiced=True, + ) + milestones = [ms1, ms2] + + # Milestone 1 → deposit invoice (paid) + dep_date = today - timedelta(days=45) + dep_number = f"{dep_date.strftime('%Y-%m-%d')}-{next(invoice_number_counter)}" + dep = generate_deposit_invoice( + contract=sam_lowry_contract, + project=sam_lowry_project, + milestone=ms1, + number=dep_number, + date=dep_date, + ) + dep.sent = True + dep.paid = True + deposit_invoices.append(dep) + + # Milestone 2 (last) → final invoice that deducts the deposit + final_date = today - timedelta(days=7) + final_number = ( + f"{final_date.strftime('%Y-%m-%d')}-{next(invoice_number_counter)}" + ) + final_inv = generate_final_invoice( + contract=sam_lowry_contract, + project=sam_lowry_project, + deposit_invoices=deposit_invoices, + number=final_number, + date=final_date, + ) + final_inv.sent = True + final_inv.paid = False + final_inv.milestone_id = ms2.id + invoices.extend(deposit_invoices) + invoices.append(final_inv) + return projects, invoices, client_contacts @@ -781,6 +855,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 5033ec4b..9a774463 100644 --- a/tuttle/invoicing.py +++ b/tuttle/invoicing.py @@ -4,7 +4,7 @@ import datetime from decimal import Decimal -from .model import InvoiceItem, Invoice, Contract, User, Project +from .model import InvoiceItem, Invoice, Contract, PaymentMilestone, User, Project from .timetracking import Timesheet @@ -80,6 +80,99 @@ def generate_fixed_price_invoice( return invoice +def _safe_vat_rate(contract: Contract) -> Decimal: + """Normalize contract VAT rate to a [0, 1] fraction.""" + vat_rate = Decimal(str(contract.VAT_rate)) + while vat_rate > Decimal("1"): + vat_rate = vat_rate / Decimal("100") + return vat_rate + + +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.""" + if contract.fixed_price is None: + raise ValueError("Deposit invoices require a fixed-price contract.") + + total_price = Decimal(str(contract.fixed_price)) + if milestone.amount is not None: + deposit_amount = Decimal(str(milestone.amount)) + elif milestone.percentage is not None: + deposit_amount = ( + total_price * Decimal(str(milestone.percentage)) / Decimal("100") + ) + else: + raise ValueError("Milestone must have either a percentage or an amount.") + + vat_rate = _safe_vat_rate(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=deposit_amount, + VAT_rate=vat_rate, + 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(), +) -> Invoice: + """Create a final invoice (Schlussrechnung) that shows the full contract amount + and deducts prior deposits. The deduction lines are captured in the model's + ``deposit_deductions`` computed property for PDF rendering. + """ + if contract.fixed_price is None: + raise ValueError("Final invoices require a fixed-price contract.") + + vat_rate = _safe_vat_rate(contract) + total_price = Decimal(str(contract.fixed_price)) + + 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=total_price, + VAT_rate=vat_rate, + description=contract.title, + ) + invoice.items.append(item) + + 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..8f66d685 --- /dev/null +++ b/tuttle/migrations/versions/1100c34b90c6_deposit_and_final_invoices.py @@ -0,0 +1,117 @@ +"""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 + +from alembic import op +import sqlalchemy as sa +import sqlmodel +import sqlmodel.sql.sqltypes # noqa: F401 — ensures runtime resolution of AutoString + + +revision: str = "1100c34b90c6" +down_revision: Union[str, Sequence[str], None] = "34dd17917a18" +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 787fb5db..db43f564 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -41,7 +41,7 @@ from .dev import deprecated from .time import Cycle, TimeUnit -DocumentType = Literal["invoice", "reminder"] +DocumentType = Literal["invoice", "reminder", "deposit", "final"] class RpcMixin: @@ -433,8 +433,9 @@ class Contract(RpcMixin, SQLModel, table=True): "client": None, "projects": ("id", "title"), "invoices": ("id",), + "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( @@ -507,11 +508,23 @@ class Contract(RpcMixin, SQLModel, table=True): back_populates="contract", sa_relationship_kwargs={"lazy": "subquery", "passive_deletes": "all"}, ) + 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.fixed_price is not None + @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'.""" @@ -574,6 +587,37 @@ def get_status(self, default: str = "All") -> str: # manual scripts). +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.""" @@ -775,15 +819,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", @@ -797,9 +851,15 @@ class Invoice(RpcMixin, SQLModel, table=True): "total_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) @@ -812,8 +872,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) -------------- @@ -837,6 +897,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 @@ -881,6 +954,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) @@ -914,6 +1008,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.""" @@ -984,6 +1086,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 @@ -998,6 +1139,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 fab9f0b8..81a82485 100644 --- a/tuttle/rendering.py +++ b/tuttle/rendering.py @@ -47,6 +47,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"), @@ -77,6 +89,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"), @@ -107,6 +131,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"), @@ -227,6 +263,30 @@ def unit_label(raw_unit, quantity=None): tpl = labels.get("reminder_n", "{n}. Payment Reminder") reminder_title = tpl.format(n=n) + is_deposit = getattr(invoice, "is_deposit", False) + is_final = getattr(invoice, "is_final_invoice", False) + deposit_deductions = getattr(invoice, "deposit_deductions", []) if is_final else [] + remaining_balance = ( + getattr(invoice, "remaining_balance", invoice.total) + if is_final + else invoice.total + ) + + 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 + try: + milestone = invoice.milestone + if milestone is not None: + milestone_title = milestone.title or "" + milestone_percentage = milestone.percentage + except Exception: + pass + invoice_template = template_env.get_template("invoice.html") html = invoice_template.render( user=user, @@ -234,6 +294,14 @@ def unit_label(raw_unit, quantity=None): l=labels, 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, accent_color=accent_color or "", diff --git a/tuttle_tests/test_rendering.py b/tuttle_tests/test_rendering.py index c9fe2d4e..7be01225 100644 --- a/tuttle_tests/test_rendering.py +++ b/tuttle_tests/test_rendering.py @@ -78,6 +78,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 4f391fda..9b748546 100644 --- a/tuttle_tests/test_rpc_dispatch.py +++ b/tuttle_tests/test_rpc_dispatch.py @@ -9,14 +9,26 @@ """ import json +from decimal import Decimal from pathlib import Path import pytest +import sqlmodel import tuttle.app.core.abstractions as abstractions import tuttle.app_db as app_db_mod +from tuttle.app.core.abstractions import get_active_db from tuttle.app.core.dispatch import dispatch, _intents from tuttle.app.core.rpc_utils import reset_all +from tuttle.model import ( + User, + Contact, + Client, + Contract, + Project, + Invoice, + InvoiceItem, +) # --------------------------------------------------------------------------- @@ -217,6 +229,149 @@ 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}' " + f"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", {}) + + db_url = f"sqlite:///{get_active_db()}" + engine = sqlmodel.create_engine(db_url) + with sqlmodel.Session(engine) as sess: + contract = sess.exec(sqlmodel.select(Contract)).first() + assert contract is not None, "No contracts in demo DB" + 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: " + f"{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: " + f"{deposit2_res.get('error')}" + ) + + reset_all() + + result2 = dispatch("invoicing.get_all", {}) + assert result2["ok"], ( + f"invoicing.get_all failed after final invoice creation: " + f"{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..9cfd46bc 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,107 @@ 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 = { + total: number; + invoicedCount: number; + paidCount: number; + allInvoiced: boolean; + allDepositsPaid: boolean; + hasFinal: boolean; + /** All milestones invoiced and paid via deposits (legacy) or final invoice. */ + completeWithoutFinal: boolean; + settled: boolean; +}; + +function lastMilestoneId(contract: Entity): number | null { + const milestones = list(contract, "payment_milestones"); + if (milestones.length === 0) return null; + const sorted = [...milestones].sort( + (a, b) => num(a, "position") - num(b, "position") || num(a, "id") - num(b, "id"), + ); + return num(sorted[sorted.length - 1], "id") || null; +} + +/** Last milestone invoice — the final settlement (Schlussrechnung). */ +export function isSettlementDeposit(e: Entity): boolean { + if (!isDeposit(e)) return false; + const contract = entity(e, "contract"); + if (!contract) return false; + const lastId = lastMilestoneId(contract); + return lastId != null && int(e, "milestone_id") === lastId; +} + +export function displaysAsFinal(e: Entity): boolean { + return isFinalInvoice(e) || isSettlementDeposit(e); +} + +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 milestones for a grouped invoice chain. */ +export function milestoneScheduleStatus( + root: Entity, + nestedDeposits: Entity[], +): MilestoneScheduleStatus | null { + const contract = entity(root, "contract"); + if (!contract) return null; + const milestones = list(contract, "payment_milestones"); + if (milestones.length === 0) return null; + + const deposits = chainDepositInvoices(root, nestedDeposits); + const invoiceByMilestone = new Map(); + for (const d of deposits) { + const mid = int(d, "milestone_id"); + if (mid) invoiceByMilestone.set(mid, d); + } + const rootMid = int(root, "milestone_id"); + if (rootMid) invoiceByMilestone.set(rootMid, root); + + let invoicedCount = 0; + let paidCount = 0; + for (const m of milestones) { + if (bool(m, "invoiced")) invoicedCount++; + const inv = invoiceByMilestone.get(m.id); + if (inv && invoiceStatus(inv) === "Paid") paidCount++; + } + + const hasFinal = isFinalInvoice(root); + const allInvoiced = invoicedCount === milestones.length; + const allDepositsPaid = paidCount === milestones.length; + const completeWithoutFinal = allInvoiced && allDepositsPaid && !hasFinal; + const settled = + (hasFinal && invoiceStatus(root) === "Paid") || completeWithoutFinal; + + return { + total: milestones.length, + invoicedCount, + paidCount, + allInvoiced, + allDepositsPaid, + hasFinal, + completeWithoutFinal, + settled, + }; +} diff --git a/ui/src/components/business/ContractsView.tsx b/ui/src/components/business/ContractsView.tsx index b3cbcd8a..526630fe 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, Plus, Trash2, Save, X, DollarSign, Calendar, FileUp, Sparkles, Check, CheckCheck, Loader2, CheckCircle2, - FolderKanban, Receipt, ArrowRight, + FolderKanban, Receipt, ArrowRight, ChevronDown, ChevronRight, Milestone, } from "lucide-react"; import { rpc } from "../../api/rpc"; import { str, num, bool, entity as subEntity, list as entityList, displayName, formatDate } from "../../api/entity"; @@ -67,13 +67,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, @@ -91,9 +91,33 @@ export function ContractsView() { units_per_workday: data.unitsPerWorkday, }; 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.fixedPrice != null && data.fixedPrice > 0; + 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) { @@ -341,6 +365,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 */}
@@ -394,6 +444,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; @@ -417,7 +479,7 @@ function ContractForm({ contract, clients, defaultCurrency, onSave, onCancel, er contract?: Entity; clients: Record; defaultCurrency: string; - onSave: (data: ContractFormData) => void; + onSave: (data: ContractFormData, milestones?: MilestoneSavePayload) => Promise; onCancel: () => void; error?: string | null; }) { @@ -456,6 +518,21 @@ function ContractForm({ contract, clients, defaultCurrency, onSave, onCancel, er 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); function update(field: K, value: ContractFormData[K]) { @@ -484,9 +561,19 @@ function ContractForm({ contract, clients, defaultCurrency, onSave, onCancel, er if (form.endDate && form.startDate && form.endDate < form.startDate) { setValidationError("End date must be on or after start date"); return; } + if (isFixed && milestonesOpen && milestones.length > 0) { + const total = milestones.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); + const ok = await onSave(form, isFixed && milestonesOpen + ? { open: true, milestones } + : 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"; @@ -633,6 +720,80 @@ function ContractForm({ contract, clients, defaultCurrency, onSave, onCancel, er )}
+ + {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 086a94d8..09c3a643 100644 --- a/ui/src/components/invoicing/InvoicingView.tsx +++ b/ui/src/components/invoicing/InvoicingView.tsx @@ -3,9 +3,10 @@ import { FileText, Send, CheckCircle, XCircle, Mail, Trash2, Building2, FolderKanban, Calendar, Banknote, Eye, DollarSign, Plus, Clock, AlertTriangle, ChevronLeft, ChevronRight, Search, + 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, displaysAsFinal, isSettlementDeposit, reminderLevel, depositChainHeadId, depositMilestoneLabel, milestoneScheduleStatus, type MilestoneScheduleStatus } from "../../api/entity"; import { StatusBadge } from "../shared/StatusBadge"; import { ViewModeToggle } from "../shared/ViewModeToggle"; import { KanbanBoard, useStageStore, type BoardColumn } from "../shared/KanbanBoard"; @@ -13,7 +14,7 @@ import { Toolbar, ToolbarButtonPrimary, ToolbarFilterGroup, ListDetailLayout, LI import { useNavigation } from "../shared/NavigationContext"; 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" }, @@ -94,6 +95,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(); } @@ -159,10 +172,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) => ( + + )} />
)} @@ -270,8 +297,21 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr const [selectedNoteIds, setSelectedNoteIds] = useState>(new Set()); const [customNoteText, setCustomNoteText] = useState(""); + const [docType, setDocType] = useState<"invoice" | "deposit" | "final">("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 contractEntity = selectedProject ? subEntity(selectedProject, "contract") : null; + const milestones = contractEntity ? entityList(contractEntity, "payment_milestones") : []; + const hasMilestones = milestones.length > 0; + const openMilestones = milestones.filter((m) => !bool(m, "invoiced")); + const isLastOpenMilestone = + openMilestones.length === 1 && selectedMilestoneId === openMilestones[0]?.id; + + useEffect(() => { + if (hasMilestones && docType === "final") setDocType("deposit"); + }, [hasMilestones, docType]); useEffect(() => { (async () => { @@ -298,6 +338,8 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr setProjectId(newId); const proj = projects.find((p) => p.id === newId) ?? null; setLineItems([makeDefaultItem(proj)]); + setDocType("invoice"); + setSelectedMilestoneId(null); } function updateItem(idx: number, patch: Partial) { @@ -324,6 +366,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, @@ -379,8 +449,50 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr + {/* Document type (only when contract has milestones) */} + {hasMilestones && isFixedPrice && ( +
+ Document Type +
+ {(["invoice", "deposit"] as const).map((dt) => ( + + ))} +
+
+ )} + + {/* 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"; @@ -394,8 +506,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
@@ -417,8 +529,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
@@ -469,8 +581,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
@@ -558,7 +670,9 @@ function CreateInvoiceDialog({ onClose, onCreated }: { onClose: () => void; onCr
@@ -566,18 +680,95 @@ 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 { + const schedule = milestoneScheduleStatus(chain.root, chain.deposits); + if (isFinalInvoice(chain.root) || schedule?.completeWithoutFinal) return "border-l-2 border-l-blue-400"; + if (isDeposit(chain.root) || chain.deposits.length > 0) return "border-l-2 border-l-blue-400"; + return ""; +} + +function MilestoneScheduleBadge({ schedule }: { schedule: MilestoneScheduleStatus }) { + const { total, invoicedCount, paidCount, allInvoiced, allDepositsPaid, hasFinal, completeWithoutFinal, settled } = schedule; + + if (settled || completeWithoutFinal) { + return ( + + {completeWithoutFinal ? `${total}/${total} milestones complete` : "All settled"} + + ); + } + if (hasFinal) { + return ( + + Final invoice · {paidCount}/{total} deposits paid + + ); + } + if (allInvoiced) { + return ( + + {invoicedCount}/{total} invoiced · {paidCount}/{total} paid + + ); + } + return ( + + {invoicedCount}/{total} milestones invoiced + + ); +} + +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 settlement = isSettlementDeposit(invoice); + const showAsFinal = displaysAsFinal(invoice); return ( ); @@ -621,12 +813,94 @@ 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); + const settlement = isSettlementDeposit(invoice); + return ( + + ); +} + +function InvoiceChainCard({ chain, color, reminderCount, depositCount }: { + chain: InvoiceChain; color: string; reminderCount?: number; depositCount?: number; +}) { + const { root, deposits } = chain; + const isFinal = isFinalInvoice(root); + const schedule = milestoneScheduleStatus(root, deposits); + return ( +
+ + {deposits.length > 0 && ( +
+ {deposits.map((dep) => { + const depositLabel = depositMilestoneLabel(dep); + const settlement = isSettlementDeposit(dep); + return ( +
+
+
+ + {depositLabel || (settlement ? "Final settlement" : "Deposit")} + + {settlement ? : } + {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 settlement = isSettlementDeposit(invoice); + const showAsFinal = displaysAsFinal(invoice); return (
-
- {str(invoice, "number") || "Draft"} +
+ {str(invoice, "number") || "Draft"} + {isDeposit(invoice) && !settlement && depositLabel && ( + {depositLabel} + )} + {showAsFinal && depositLabel && ( + {depositLabel} + )} + {showAsFinal && !depositLabel && ( + Settlement + )} + {isDeposit(invoice) && !settlement && } + {showAsFinal && } + {(depositCount ?? 0) > 0 && !isDeposit(invoice) && ( + + {depositCount} + + )} {(reminderCount ?? 0) > 0 && ( {reminderCount} @@ -650,6 +924,11 @@ function InvoiceCard({ invoice, reminderCount }: { invoice: Entity; color: strin
{formatDate(str(invoice, "date"))}
+ {schedule && ( +
+ +
+ )}
); } @@ -669,6 +948,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; @@ -740,14 +1020,31 @@ function InvoiceDetail({ invoice, allInvoices, onToggleSent, onTogglePaid, onTog
{isRem ? + : displaysAsFinal(invoice) + ? + : isDeposit(invoice) + ? : }

- {isRem ? `Reminder ${reminderLevel(invoice)}` : str(invoice, "number") || "Draft"} + {isRem + ? `Reminder ${reminderLevel(invoice)}` + : isSettlementDeposit(invoice) + ? (depositLabel ? `Final · ${depositLabel}` : `Final ${str(invoice, "number") || "Draft"}`) + : 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) || isSettlementDeposit(invoice)) && depositLabel && ( + Inv. {str(invoice, "number") || "Draft"} + )} + {displaysAsFinal(invoice) && ( + Settlement invoice + )} {deepStr(invoice, "contract.client.name") || "No client"}
@@ -901,6 +1198,92 @@ 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, + ); + const paidDeposits = deposits.filter((d) => invoiceStatus(d) === "Paid").length; + const allMilestonesInvoiced = milestones.every((m) => bool(m, "invoiced")); + const allDepositsPaid = paidDeposits === milestones.length; + const completeWithoutFinal = + allMilestonesInvoiced && allDepositsPaid && !finalInv; + + return ( +
+
+ {milestones.map((m) => { + const dep = depositByMilestone.get(m.id); + const depStatus = dep ? invoiceStatus(dep) : null; + const settlement = dep ? isSettlementDeposit(dep) : false; + const state = !dep && !bool(m, "invoiced") + ? "open" + : depStatus === "Paid" + ? "paid" + : dep + ? "invoiced" + : "invoiced"; + return ( +
+
+ + {str(m, "title") || "Untitled"} + {settlement && ( + Final settlement + )} + {dep && ( + {str(dep, "number")} + )} +
+
+ {num(m, "percentage")}% + {state === "open" && ( + Open + )} + {state === "invoiced" && dep && ( + + )} + {state === "paid" && ( + Paid + )} +
+
+ ); + })} + {finalInv && ( +
+
+ + Final invoice + {str(finalInv, "number")} +
+ +
+ )} + {completeWithoutFinal && ( +
+ All milestones invoiced and paid. +
+ )} +
+
+ ); + })()} + {chain.length > 1 && (
@@ -925,6 +1308,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; + })()} +
)} @@ -1065,31 +1498,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 09e74c76..df56f8e3 100644 --- a/ui/src/components/layout/Shell.tsx +++ b/ui/src/components/layout/Shell.tsx @@ -21,7 +21,7 @@ import { NavigationContext, type NavigationFilter } from "../shared/NavigationCo import { rpc } from "../../api/rpc"; import { useThemeProvider, ThemeContext } from "../../hooks/useTheme"; -type BootState = "loading" | "welcome" | "ready"; +type BootState = "loading" | "welcome" | "ready" | "error"; type BootPhase = | "init" @@ -51,6 +51,7 @@ export function Shell() { const [allUsers, setAllUsers] = useState([]); const [regDialogOpen, setRegDialogOpen] = useState(false); const [regLoading, setRegLoading] = useState(false); + const [bootError, setBootError] = useState(null); const navigate = useCallback((view: string, filter?: NavigationFilter) => { setNavFilter(filter || {}); @@ -75,8 +76,14 @@ export function Shell() { useEffect(() => { (async () => { + setBootError(null); setBootPhase("registry"); - await rpc("db.ensure"); + const ensureRes = await rpc("db.ensure"); + if (!ensureRes.ok) { + setBootError(ensureRes.error || "Database migration failed. Your data may need repair."); + setBootState("error"); + return; + } setBootPhase("users"); const usersRes = await rpc("users.list"); const users = usersRes.ok && usersRes.data ? usersRes.data : []; @@ -173,6 +180,27 @@ export function Shell() { ); } + if (bootState === "error") { + return ( +
+
+

Could not open your database

+

{bootError}

+

+ This often happens after a failed schema migration during development. + Run just reset-dev to wipe dev data and start fresh, + or restore from a .bak-* backup in your data directory. +

+ +
+
+ ); + } + if (bootState === "welcome") { return (