diff --git a/tuttle/app/invoicing/intent.py b/tuttle/app/invoicing/intent.py index 2c50d82a..2bc45c84 100644 --- a/tuttle/app/invoicing/intent.py +++ b/tuttle/app/invoicing/intent.py @@ -714,11 +714,25 @@ def _create_reminder( return IntentResult(was_intent_successful=False, error_msg="Invoice not found.") predecessor = result.data + # Walk to root via explicit queries to avoid DetachedInstanceError + root = predecessor + while root.reminder_for_id is not None: + parent_result = self._invoicing_data_source.get_invoice_by_id(root.reminder_for_id) + if not parent_result.was_intent_successful or not parent_result.data: + break + root = parent_result.data + if predecessor.paid: return IntentResult( was_intent_successful=False, error_msg="Cannot create a reminder for a paid invoice.", ) + # A cancelled invoice is owed no more, whichever reminder of it is chased. + if root.cancelled: + return IntentResult( + was_intent_successful=False, + error_msg=f"Invoice {root.number} is cancelled. Restore it first if the client still owes it.", + ) if predecessor.cancelled: return IntentResult( was_intent_successful=False, @@ -730,14 +744,6 @@ def _create_reminder( user = self._user_data_source.get_user() new_level = predecessor.reminder_level + 1 - # Walk to root via explicit queries to avoid DetachedInstanceError - root = predecessor - while root.reminder_for_id is not None: - parent_result = self._invoicing_data_source.get_invoice_by_id(root.reminder_for_id) - if not parent_result.was_intent_successful or not parent_result.data: - break - root = parent_result.data - items = [ InvoiceItem( start_date=item.start_date, @@ -1018,18 +1024,14 @@ def toggle_invoice_paid_status(self, invoice: Invoice) -> IntentResult[Invoice]: ) def toggle_invoice_cancelled_status(self, invoice: Invoice) -> IntentResult[Invoice]: - """ - Toggles the "cancelled" status of an invoice and updates it in the data source. + """Toggle cancelled status. Propagates from an invoice to its reminders. - Parameters: - invoice (Invoice): - The invoice object whose "cancelled" status will be toggled. - - Returns: - IntentResult[Invoice]: - An IntentResult object containing the updated invoice, or old invoice if update was not successful. + A cancelled invoice is owed no more, so its reminders are void with it + and come back when it is restored. A reminder can be cancelled on its + own, but not restored while its invoice is cancelled. """ - if invoice.is_deposit and not invoice.cancelled and invoice.deposit_for_id is not None: + new_cancelled = not invoice.cancelled + if invoice.is_deposit and new_cancelled and invoice.deposit_for_id is not None: # The final invoice deducts this deposit. Voided on its own, the deposit # would stay deducted there and the client would be asked for too little. final = self._invoicing_data_source.get_invoice_by_id(invoice.deposit_for_id).data @@ -1041,14 +1043,28 @@ def toggle_invoice_cancelled_status(self, invoice: Invoice) -> IntentResult[Invo "Cancel the final invoice first, then the deposit." ), ) + chain_result = self._invoicing_data_source.get_reminder_chain(invoice.id) + chain = chain_result.data if chain_result.was_intent_successful and chain_result.data else [invoice] + head = chain[0] + if invoice.is_reminder: + if not new_cancelled and head.id != invoice.id and head.cancelled: + return IntentResult( + was_intent_successful=False, + error_msg=f"Invoice {head.number} is cancelled. Restore the invoice to restore its reminders.", + ) + chain = [invoice] try: - invoice.cancelled = not invoice.cancelled - self._invoicing_data_source.save_invoice(invoice) + for inv in chain: + fresh = self._invoicing_data_source.get_invoice_by_id(inv.id) + if fresh.was_intent_successful and fresh.data: + fresh.data.cancelled = new_cancelled + self._invoicing_data_source.save_invoice(fresh.data) if invoice.is_deposit or invoice.is_final_invoice: self._invoicing_data_source.reconcile_milestones_invoiced(invoice.contract_id) + result = self._invoicing_data_source.get_invoice_by_id(invoice.id) return IntentResult( was_intent_successful=True, - data=invoice, + data=result.data if result.was_intent_successful and result.data else invoice, ) except Exception as ex: logger.error(f"Error toggling invoice cancelled status: {ex}") diff --git a/tuttle_tests/test_cancel_reminders_with_invoice.py b/tuttle_tests/test_cancel_reminders_with_invoice.py new file mode 100644 index 00000000..bb3d7bd5 --- /dev/null +++ b/tuttle_tests/test_cancel_reminders_with_invoice.py @@ -0,0 +1,140 @@ +"""A reminder chain shares the cancelled state of the invoice it chases. + +A cancelled invoice is owed no more, so its reminders must not stay overdue, +and no new reminder may chase it. A single reminder can still be withdrawn. +""" + +import datetime +from decimal import Decimal + +import pytest +from sqlmodel import Session, SQLModel, create_engine + +from tuttle.app.core.abstractions import SQLModelDataSourceMixin +from tuttle.app.invoicing.intent import InvoicingIntent +from tuttle.model import Address, Client, Contract, Invoice, Project, User +from tuttle.time import ContractType, Cycle + +DATE = datetime.date(2026, 2, 1) + + +def _ok(result): + assert result.was_intent_successful, result.error_msg + return result.data + + +@pytest.fixture +def chain(monkeypatch) -> tuple[InvoicingIntent, Invoice, Invoice, Invoice]: + """An invoice with a first and a second reminder.""" + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + monkeypatch.setattr(SQLModelDataSourceMixin, "create_session", lambda self: Session(engine)) + with Session(engine) as session: + # Rendering the PDF after creation reads the user's preferences. + session.add( + User( + name="Harry Tuttle", + subtitle="Heating Engineer", + email="mail@tuttle.example", + address=Address(street="Main Street", number="450", postal_code="55555", city="Sao Paolo", country="Brazil"), + ) + ) + project = Project( + title="Boiler Repair", + description="Burner", + tag="#boiler", + start_date=datetime.date(2026, 1, 10), + contract=Contract( + VAT_rate=Decimal("0.19"), + term_of_payment=14, + title="Boiler Repair", + client=Client(name="Sam Lowry"), + start_date=datetime.date(2026, 1, 10), + type=ContractType.fixed_price, + fixed_price=Decimal("800"), + currency="EUR", + billing_cycle=Cycle.monthly, + ), + ) + session.add(project) + session.commit() + project_id = project.id + + intent = InvoicingIntent() + invoice = _ok(intent.create(project_id, DATE, DATE, DATE)) + _ok(intent.toggle_sent(invoice.id)) + first = _ok(intent.create_reminder(invoice.id, "2026-03-01", "2026-03-15", "5.00")) + _ok(intent.toggle_sent(first.id)) + second = _ok(intent.create_reminder(first.id, "2026-04-01", "2026-04-15", "10.00")) + return intent, invoice, first, second + + +def _cancelled(intent: InvoicingIntent, *invoices: Invoice) -> list[bool]: + return [_ok(intent._invoicing_data_source.get_invoice_by_id(inv.id)).cancelled for inv in invoices] + + +class TestCancellingTheInvoice: + def test_cancels_its_reminders(self, chain): + intent, invoice, first, second = chain + + _ok(intent.toggle_cancelled(invoice.id)) + + assert _cancelled(intent, invoice, first, second) == [True, True, True] + + def test_restoring_it_restores_its_reminders(self, chain): + intent, invoice, first, second = chain + _ok(intent.toggle_cancelled(invoice.id)) + + _ok(intent.toggle_cancelled(invoice.id)) + + assert _cancelled(intent, invoice, first, second) == [False, False, False] + + +class TestCancellingAReminder: + def test_cancels_only_that_reminder(self, chain): + intent, invoice, first, second = chain + + _ok(intent.toggle_cancelled(first.id)) + + assert _cancelled(intent, invoice, first, second) == [False, True, False] + + def test_it_can_be_restored_while_the_invoice_stands(self, chain): + intent, invoice, first, second = chain + _ok(intent.toggle_cancelled(first.id)) + + _ok(intent.toggle_cancelled(first.id)) + + assert _cancelled(intent, invoice, first, second) == [False, False, False] + + def test_it_cannot_be_restored_while_the_invoice_is_cancelled(self, chain): + intent, invoice, first, second = chain + _ok(intent.toggle_cancelled(invoice.id)) + + result = intent.toggle_cancelled(first.id) + + assert not result.was_intent_successful + assert result.error_msg == f"Invoice {invoice.number} is cancelled. Restore the invoice to restore its reminders." + assert _cancelled(intent, first) == [True] + + +class TestNewReminder: + def test_is_refused_for_a_cancelled_invoice_even_from_a_live_reminder(self, chain): + """Reminders that an older version left live when it cancelled their invoice.""" + intent, invoice, first, second = chain + invoice_row = _ok(intent._invoicing_data_source.get_invoice_by_id(invoice.id)) + invoice_row.cancelled = True + intent._invoicing_data_source.save_invoice(invoice_row) + + result = intent.create_reminder(second.id, "2026-05-01", "2026-05-15") + + assert not result.was_intent_successful + assert result.error_msg == f"Invoice {invoice.number} is cancelled. Restore it first if the client still owes it." + + def test_is_allowed_again_once_the_invoice_is_restored(self, chain): + intent, invoice, first, second = chain + _ok(intent.toggle_cancelled(invoice.id)) + _ok(intent.toggle_cancelled(invoice.id)) + + third = _ok(intent.create_reminder(second.id, "2026-05-01", "2026-05-15")) + + assert third.reminder_level == 3