Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor/rules/python-imports.mdc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
description: Python import conventions — no imports inside function bodies
globs: "**/*.py"
alwaysApply: false
alwaysApply: true
---

# Python Imports
Expand Down
48 changes: 48 additions & 0 deletions templates/invoice-modern/invoice.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
52 changes: 46 additions & 6 deletions templates/invoice-modern/invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% if is_reminder %}{{ reminder_title }} – {{ invoice.number }}{% else %}{{ l.invoice_no }} {{ invoice.number }}{% endif %}</title>
<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 %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="./invoice.css">
{% if accent_color %}<style>:root { --invoice-accent: {{ accent_color }}; }</style>{% endif %}
Expand Down Expand Up @@ -46,12 +46,30 @@
<div class="accent-rule"></div>

{% if is_reminder %}
<div class="reminder-badge" style="font-size:1.1em; font-weight:700; margin-bottom:.6em;">{{ reminder_title }}</div>
<div class="document-type-banner reminder">{{ reminder_title }}</div>
{% elif is_deposit %}
<div class="document-type-banner deposit">{{ l.deposit_invoice }}</div>
{% elif is_final %}
<div class="document-type-banner final">{{ l.final_invoice }}</div>
{% endif %}

{% if is_deposit and (contract_title or milestone_title) %}
<div class="deposit-context">
{% if contract_title %}
<div><span class="label">{{ l.in_respect_of }}</span> {{ contract_title }}</div>
{% endif %}
{% if milestone_title %}
<div><span class="label">{{ l.payment_milestone }}</span> {{ milestone_title }}{% if milestone_percentage %} ({{ milestone_percentage }}%){% endif %}</div>
{% endif %}
{% if contract_total %}
<div><span class="label">{{ l.contract_total }}</span> {{ contract_total | as_currency }}</div>
{% endif %}
</div>
{% endif %}

<table class="meta-table">
<tr>
<td class="meta-label">{{ l.invoice_no }}</td>
<td class="meta-label">{% if is_deposit %}{{ l.deposit_invoice }}{% elif is_final %}{{ l.final_invoice }}{% else %}{{ l.invoice_no }}{% endif %}</td>
<td class="meta-value">{{ invoice.number }}</td>
</tr>
{% if is_reminder %}
Expand Down Expand Up @@ -110,7 +128,7 @@
</div>
<div class="totals">
<div class="totals-line">
<span class="totals-label">{{ l.subtotal }}</span>
<span class="totals-label">{% if is_final %}{{ l.total_fee }}{% else %}{{ l.subtotal }}{% endif %}</span>
<span class="totals-amount">{{ invoice.sum | as_currency }}</span>
</div>
<div class="totals-line">
Expand All @@ -123,16 +141,38 @@
<span class="totals-amount">{{ invoice.reminder_fee | as_currency }}</span>
</div>
{% endif %}
{% if is_final %}
<div class="totals-line">
<span class="totals-label">{{ l.gross }}</span>
<span class="totals-amount">{{ invoice.total | as_currency }}</span>
</div>
{% for dep in deposit_deductions %}
<div class="totals-line" style="color:#666;">
<span class="totals-label">{{ l.less_deposit }} {{ dep.invoice_number }}</span>
<span class="totals-amount">−{{ dep.gross | as_currency }}</span>
</div>
<div class="totals-line" style="font-size:.85em; color:#888; padding-left:1em;">
<span class="totals-label">({{ l.vat_included_therein }}: {{ dep.vat | as_currency }})</span>
<span class="totals-amount"></span>
</div>
{% endfor %}
<div class="totals-rule"></div>
<div class="totals-line total-due">
<span class="totals-label">{{ l.total_due }}</span>
<span class="totals-label">{{ l.remaining_balance }}</span>
<span class="totals-amount">{{ remaining_balance | as_currency }}</span>
</div>
{% else %}
<div class="totals-rule"></div>
<div class="totals-line total-due">
<span class="totals-label">{% if is_deposit %}{{ l.deposit_due }}{% else %}{{ l.total_due }}{% endif %}</span>
<span class="totals-amount">{{ invoice.total | as_currency }}</span>
</div>
{% endif %}
</div>
</div>

<div class="closing">
<p>{% if is_reminder %}{{ l.reminder_closing }}{% elif notes %}<span style="white-space:pre-line">{{ notes }}</span>{% else %}{{ l.closing }}{% endif %}</p>
<p>{% if is_reminder %}{{ l.reminder_closing }}{% elif is_deposit %}{{ l.deposit_closing }}{% elif notes %}<span style="white-space:pre-line">{{ notes }}</span>{% else %}{{ l.closing }}{% endif %}</p>
<div class="sig-line"></div>
{{ user.name }}
</div>
Expand Down
107 changes: 105 additions & 2 deletions tuttle/app/contracts/intent.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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__()
Expand Down Expand Up @@ -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,
)
Loading