From ab5844ff1235e518acb10452cee0061de0353d74 Mon Sep 17 00:00:00 2001 From: Emre K <110906681+kocaemre@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:58:27 +0200 Subject: [PATCH] fix: use invoicing role contact for invoice email Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com> --- tuttle/app/invoicing/intent.py | 4 ++-- tuttle/invoicing.py | 2 +- tuttle/model.py | 24 ++++++++++++++++++++---- tuttle_tests/test_invoice.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index 2aba3d9c..1c04d218 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -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: @@ -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 diff --git a/tuttle/invoicing.py b/tuttle/invoicing.py index 93aef25b..656b05ad 100644 --- a/tuttle/invoicing.py +++ b/tuttle/invoicing.py @@ -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 diff --git a/tuttle/model.py b/tuttle/model.py index 559ece7e..cf61a0cd 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -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 diff --git a/tuttle_tests/test_invoice.py b/tuttle_tests/test_invoice.py index 186f8217..a71a29a7 100644 --- a/tuttle_tests/test_invoice.py +++ b/tuttle_tests/test_invoice.py @@ -13,6 +13,8 @@ from tuttle.model import ( Address, Client, + ClientContact, + Contact, Contract, ContractCharge, Invoice, @@ -20,6 +22,7 @@ InvoiceNote, Project, TaxCategory, + User, ) from tuttle.time import ChargeBasis, ContractType, Cycle, TimeUnit @@ -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,