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
58 changes: 37 additions & 21 deletions tuttle/app/invoicing/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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}")
Expand Down
140 changes: 140 additions & 0 deletions tuttle_tests/test_cancel_reminders_with_invoice.py
Original file line number Diff line number Diff line change
@@ -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
Loading