Skip to content
Open
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
4 changes: 2 additions & 2 deletions tuttle/app/invoicing/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ def send_reminder_by_mail(self, invoice: Invoice) -> IntentResult[None]:
try:
user = self._user_data_source.get_user()
client = invoice.contract.client
contact = client.invoicing_contact if client else None
contact = client.invoice_recipient_contact if client else None
greeting = contact.name if contact and contact.name else client.name
recipient = contact.email if contact and contact.email else None
if not recipient:
Expand Down Expand Up @@ -643,7 +643,7 @@ def send_invoice_by_mail(self, invoice: Invoice) -> IntentResult[None]:
user = self._user_data_source.get_user()
# open email client with message pre-filled
client = invoice.contract.client
contact = client.invoicing_contact if client else None
contact = client.invoice_recipient_contact if client else None
greeting = contact.name if contact and contact.name else client.name
recipient = contact.email if contact and contact.email else None

Expand Down
2 changes: 1 addition & 1 deletion tuttle/invoicing.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ def generate_invoice_email(
Returns None when no contact email is available.
"""
client = invoice.client
contact = client.invoicing_contact if client else None
contact = client.invoice_recipient_contact if client else None
greeting = contact.first_name if contact and contact.first_name else client.name
recipient = contact.email if contact and contact.email else None

Expand Down
24 changes: 20 additions & 4 deletions tuttle/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,18 +372,34 @@ class Client(RpcMixin, SQLModel, table=True):
sa_relationship_kwargs={"lazy": "subquery", "cascade": "all, delete-orphan"},
)

@property
def invoice_recipient_contact(self) -> Optional[Contact]:
"""Contact to use for invoice delivery.

Prefer the legacy direct invoicing contact, but also support the
many-to-many contact association used by the current contact UI.
"""
if self.invoicing_contact:
return self.invoicing_contact
for association in self.client_contacts:
if (association.role or "").strip().casefold() == "invoicing":
return association.contact
return None

@property
def invoice_recipient_name(self) -> str:
"""Name to use on invoices: contact name if available, else client name."""
if self.invoicing_contact and self.invoicing_contact.name:
return self.invoicing_contact.name
contact = self.invoice_recipient_contact
if contact and contact.name:
return contact.name
return self.name

@property
def invoice_recipient_address(self) -> Optional["Address"]:
"""Address for invoices: prefer contact address, fall back to client address."""
if self.invoicing_contact and self.invoicing_contact.address:
return self.invoicing_contact.address
contact = self.invoice_recipient_contact
if contact and contact.address:
return contact.address
return self.address


Expand Down
28 changes: 28 additions & 0 deletions tuttle_tests/test_invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@
from tuttle.model import (
Address,
Client,
ClientContact,
Contact,
Contract,
ContractCharge,
Invoice,
InvoiceItem,
InvoiceNote,
Project,
TaxCategory,
User,
)
from tuttle.time import ChargeBasis, ContractType, Cycle, TimeUnit

Expand Down Expand Up @@ -63,6 +66,31 @@ def test_invoice():
assert the_invoice.total == Decimal(1800)


def test_generate_invoice_email_uses_invoicing_role_contact():
"""Clients with many-to-many invoicing contacts can still send invoices."""
contact = Contact(first_name="Jill", last_name="Invoice", email="billing@example.com")
client = Client(name="Client Corp")
client.client_contacts.append(ClientContact(client=client, contact=contact, role=" invoicing "))
contract = Contract(
title="Retainer",
client=client,
rate=Decimal("100"),
currency="EUR",
unit=TimeUnit.hour,
units_per_workday=8,
term_of_payment=14,
billing_cycle=Cycle.monthly,
)
invoice = Invoice(number="INV-1", date=datetime.date(2026, 7, 29), contract=contract)
user = User(first_name="Harry", last_name="Tuttle")

email = invoicing.generate_invoice_email(invoice, user)

assert email is not None
assert email["recipient"] == "billing@example.com"
assert "Dear Jill" in email["body"]


def test_generate_invoice(
demo_projects,
demo_calendar_timetracking,
Expand Down
Loading