Skip to content
Merged
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
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -878,12 +878,36 @@ See `docs/CONFIGURATION.md` for complete reference.
- **Payment tests**: `tests/payment/` - Domain, matchers, services (≥80% coverage required)
- Use `conftest.py` fixtures for database session, sample data, mock providers

**Database fixtures (testing architecture):**
- `db_session` / `sample_*` (root `conftest.py`): an in-memory session for direct
ORM assertions. Does NOT wire the global session factory — use it when the test
itself owns the queries.
- `runtime_db` / `runtime_session` / `seed_cliente` / `seed_fattura`: point an
isolated, file-backed SQLite DB at the GLOBAL session factory AND at
`get_settings()` (cedente/PEC/`database_url` via env). Use these whenever the
code under test opens its own session — CLI commands (`with db_session() as db:`
/ `init_db(settings.database_url)`), web services (`db_session_scope`), or agents
— so production code and the test share one real database. Prefer seeding real
rows over mocking SQLAlchemy query chains. Mock only true external boundaries
(LLM providers, PEC/SMTP, LND/gRPC). The `tests/ai` package has an autouse
in-memory DB; opt a node test out of session mocking with `@pytest.mark.real_db`.
- CLI output is i18n (default locale Italian). Tests that assert on rendered text
pin the locale with a module-level autouse `_english_locale` fixture and assert
the English strings; Rich tables truncate at 80 cols, so use a `_WideCliRunner`
(COLUMNS=220) when asserting wide table cells. Typer/Click route usage/argument
errors to `result.stderr` (streams are split), not `result.stdout`.

**Test Markers:**
```python
@pytest.mark.streaming # Streaming-capable components
@pytest.mark.e2e # End-to-end tests requiring external services
@pytest.mark.ollama # Tests requiring Ollama LLM service
@pytest.mark.performance # Wall-clock/benchmark tests
@pytest.mark.real_db # Opt out of session mocking, use the real in-memory DB
```
The default `pytest` gate deselects `performance`/`benchmark`/`slow`/`e2e`/`ollama`
(see `addopts`) so it stays fast and deterministic; run those tiers explicitly,
e.g. `uv run pytest -m performance` or `uv run pytest -m "ollama and e2e"`.

## Code Style

Expand Down
131 changes: 69 additions & 62 deletions openfatture/cli/commands/cliente.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rich.console import Console
from rich.prompt import Confirm, Prompt
from rich.table import Table
from sqlalchemy.exc import SQLAlchemyError

from openfatture.cli.lifespan import get_event_bus
from openfatture.core.events import ClientCreatedEvent, ClientDeletedEvent
Expand Down Expand Up @@ -109,71 +110,77 @@ def add_cliente(
raise typer.Exit(1)

# Create client
with db_session() as db:
# Parse name into first and last name if possible
nome = None
cognome = None
if denominazione and " " in denominazione:
# Try to split the name into first and last name
parts = denominazione.split(" ", 1)
if len(parts) == 2:
nome = parts[0]
cognome = parts[1]

cliente = Cliente(
denominazione=denominazione,
nome=nome,
cognome=cognome,
partita_iva=partita_iva,
codice_fiscale=codice_fiscale,
codice_destinatario=codice_destinatario,
pec=pec,
indirizzo=indirizzo if interactive else None,
numero_civico=numero_civico if interactive else None,
cap=cap if interactive else None,
comune=comune if interactive else None,
provincia=provincia if interactive else None,
nazione="IT", # Default to Italy
email=email if interactive else None,
telefono=telefono if interactive else None,
note=note if interactive else None,
)

db.add(cliente)
db.commit()
db.refresh(cliente)

# Publish ClientCreatedEvent
event_bus = get_event_bus()
if event_bus:
event_bus.publish(
ClientCreatedEvent(
client_id=cliente.id,
client_name=cliente.denominazione,
partita_iva=cliente.partita_iva,
codice_fiscale=cliente.codice_fiscale,
codice_destinatario=cliente.codice_destinatario,
pec=cliente.pec,
)
try:
with db_session() as db:
# Parse name into first and last name if possible
nome = None
cognome = None
if denominazione and " " in denominazione:
# Try to split the name into first and last name
parts = denominazione.split(" ", 1)
if len(parts) == 2:
nome = parts[0]
cognome = parts[1]

cliente = Cliente(
denominazione=denominazione,
nome=nome,
cognome=cognome,
partita_iva=partita_iva,
codice_fiscale=codice_fiscale,
codice_destinatario=codice_destinatario,
pec=pec,
indirizzo=indirizzo if interactive else None,
numero_civico=numero_civico if interactive else None,
cap=cap if interactive else None,
comune=comune if interactive else None,
provincia=provincia if interactive else None,
nazione="IT", # Default to Italy
email=email if interactive else None,
telefono=telefono if interactive else None,
note=note if interactive else None,
)

console.print(_("cli-cliente-added-success", id=cliente.id))

# Show summary
table = Table(title=_("cli-cliente-table-title", name=denominazione))
table.add_column(_("cli-cliente-table-field"), style="cyan")
table.add_column(_("cli-cliente-table-value"), style="white")

if partita_iva:
table.add_row(_("cli-cliente-label-piva"), partita_iva)
if codice_fiscale:
table.add_row(_("cli-cliente-label-cf"), codice_fiscale)
if codice_destinatario:
table.add_row(_("cli-cliente-label-sdi"), codice_destinatario)
if pec:
table.add_row(_("cli-cliente-label-pec"), pec)
db.add(cliente)
db.commit()
db.refresh(cliente)

# Publish ClientCreatedEvent
event_bus = get_event_bus()
if event_bus:
event_bus.publish(
ClientCreatedEvent(
client_id=cliente.id,
client_name=cliente.denominazione,
partita_iva=cliente.partita_iva,
codice_fiscale=cliente.codice_fiscale,
codice_destinatario=cliente.codice_destinatario,
pec=cliente.pec,
)
)

console.print(table)
console.print(_("cli-cliente-added-success", id=cliente.id))

# Show summary
table = Table(title=_("cli-cliente-table-title", name=denominazione))
table.add_column(_("cli-cliente-table-field"), style="cyan")
table.add_column(_("cli-cliente-table-value"), style="white")

if partita_iva:
table.add_row(_("cli-cliente-label-piva"), partita_iva)
if codice_fiscale:
table.add_row(_("cli-cliente-label-cf"), codice_fiscale)
if codice_destinatario:
table.add_row(_("cli-cliente-label-sdi"), codice_destinatario)
if pec:
table.add_row(_("cli-cliente-label-pec"), pec)

console.print(table)
except (SQLAlchemyError, ValueError) as exc:
# db_session() has already rolled back; convert the failure into a
# clean error message and non-zero exit instead of a raw traceback.
console.print(_("cli-cliente-add-error", error=str(exc)))
raise typer.Exit(1) from exc
Comment on lines +113 to +183

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope this handler to the transactional failure path.

db.commit() completes before event publication and summary rendering, but this except still converts any later ValueError/SQLAlchemyError into cli-cliente-add-error. That can tell the user the save failed after the client is already persisted, which makes retrying dangerous, and error=str(exc) also exposes raw DB details to the CLI. Catch the insert/commit failure separately, then handle post-commit publish/render errors without claiming the create failed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openfatture/cli/commands/cliente.py` around lines 113 - 183, The current
try/except around the whole block treats errors during post-commit work
(event_bus.publish, table rendering) as persistence failures and leaks DB
details; split the error handling so the transactional steps (creating Cliente,
db.add, db.commit, db.refresh) are wrapped in their own try/except that catches
SQLAlchemyError/ValueError and logs/prints the cli-cliente-add-error without raw
DB exception text, then allow post-commit operations
(get_event_bus()/event_bus.publish(ClientCreatedEvent(...)), console.print table
rendering) to run in a separate try/except that handles non-transactional errors
differently (e.g., report a post-create publication/render failure without
claiming the save failed and avoid including raw DB error strings). Reference
symbols: db_session(), Cliente, db.add/db.commit/db.refresh,
get_event_bus()/event_bus.publish, ClientCreatedEvent, and console.print.



@app.command("list")
Expand Down
6 changes: 4 additions & 2 deletions openfatture/cli/commands/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Configuration management commands."""

from pathlib import Path
from typing import Any

import typer
Expand Down Expand Up @@ -273,8 +274,9 @@ def set_config(

setattr(settings, key, new_val)

# Save to TOML
config_path = dirs.user_config_dir / "config.toml"
# Save to TOML. dirs.user_config_dir is a str (platformdirs), so wrap it
# in Path before joining.
config_path = Path(dirs.user_config_dir) / "config.toml"
save_config(settings, config_path)

console.print(_("cli-config-set-success", key=key, value=value))
Expand Down
29 changes: 25 additions & 4 deletions openfatture/cli/commands/fattura.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rich.console import Console
from rich.prompt import Confirm, FloatPrompt, IntPrompt, Prompt
from rich.table import Table
from sqlalchemy.exc import SQLAlchemyError

from openfatture.cli.lifespan import get_event_bus
from openfatture.core.events import (
Expand Down Expand Up @@ -56,6 +57,19 @@ def crea_fattura(

console.print(f"\n{_('cli-fattura-create-title')}\n")

try:
_crea_fattura_in_db(cliente_id)
except (SQLAlchemyError, ValueError) as exc:
# db_session() has already rolled back; convert a database/validation
# failure into a clean error message and non-zero exit instead of a raw
# traceback. typer.Exit raised inside (e.g. no clients) is not caught
# here and propagates unchanged.
console.print(_("cli-fattura-create-error", error=str(exc)))
raise typer.Exit(1) from exc
Comment on lines +60 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't map post-commit failures to "invoice creation failed".

_crea_fattura_in_db() commits before it publishes InvoiceCreatedEvent and renders the summary, so this outer handler can still emit cli-fattura-create-error after the invoice is already stored. That creates the same duplicate-retry hazard as cliente add, and error=str(exc) will surface raw DB/parser details to the user. Limit this handler to the transactional failure path and treat post-commit errors separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openfatture/cli/commands/fattura.py` around lines 60 - 68, The handler
currently maps any exception from _crea_fattura_in_db (which both commits and
performs post-commit work like publishing InvoiceCreatedEvent and rendering) to
"invoice creation failed", which can hide cases where the DB commit succeeded;
fix by separating transactional failures from post-commit failures: refactor so
_crea_fattura_in_db either only does transactional work (including commit) and
returns the created invoice/id, or have it raise distinct exceptions for
pre-commit vs post-commit phases; then wrap only the transactional call in the
try/except that catches SQLAlchemyError/ValueError and prints the existing
cli-fattura-create-error + typer.Exit, and perform publishing/rendering
(InvoiceCreatedEvent, summary rendering) outside that except with its own error
handling that emits a different, non-misleading message (and does not claim
creation failed or implicitly retry the DB write). Ensure references to
_crea_fattura_in_db and InvoiceCreatedEvent are used to locate and adjust the
code.



def _crea_fattura_in_db(cliente_id: int | None) -> None:
"""Run the invoice-creation transaction. Wrapped by ``crea_fattura``."""
with db_session() as db:
# Select client
if not cliente_id:
Expand All @@ -80,8 +94,16 @@ def crea_fattura(

console.print(f"{_('cli-fattura-client-selected', client_name=cliente.denominazione)}\n")

# Invoice details
anno = date.today().year
# Invoice details. Derive the year from the entered issue date so the
# stored ``anno`` (used for FatturaPA numbering/progressivo) stays
# consistent with ``data_emissione`` even when the user back-dates or
# forward-dates the invoice into another year.
data_emissione_input = Prompt.ask(
"Issue date (YYYY-MM-DD)", default=date.today().isoformat()
)
Comment on lines +101 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the translated issue-date prompt here.

This new prompt is hard-coded in English, so localized CLI sessions will still see English at the first new step of the wizard. Reuse the existing translation key instead of a literal string.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openfatture/cli/commands/fattura.py` around lines 101 - 103, The prompt text
for data_emissione_input is hard-coded in English; replace the literal "Issue
date (YYYY-MM-DD)" in the Prompt.ask call with the project's translation lookup
(the same translation key used for other CLI prompts) so the prompt is localized
— i.e., change the Prompt.ask invocation for variable data_emissione_input to
call the translation function with the existing issue-date translation key
instead of the literal string.

data_emissione = date.fromisoformat(data_emissione_input)
anno = data_emissione.year

ultimo_numero = (
db.query(Fattura).filter(Fattura.anno == anno).order_by(Fattura.numero.desc()).first()
)
Expand All @@ -92,13 +114,12 @@ def crea_fattura(
prossimo_numero = 1

numero = Prompt.ask("Invoice number", default=str(prossimo_numero))
data_emissione = Prompt.ask("Issue date (YYYY-MM-DD)", default=date.today().isoformat())

# Create invoice
fattura = Fattura(
numero=numero,
anno=anno,
data_emissione=date.fromisoformat(data_emissione),
data_emissione=data_emissione,
cliente_id=cliente.id,
tipo_documento=TipoDocumento.TD01,
stato=StatoFattura.BOZZA,
Expand Down
4 changes: 4 additions & 0 deletions openfatture/cli/commands/lightning.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ def compliance_check(
)
console.print()

except typer.Exit:
# Control-flow exit (e.g. compliance issues found): do not report it as
# an unexpected error.
raise
except Exception as e:
console.print(_("cli-lightning-compliance-error", error=str(e)))
raise typer.Exit(code=1)
Expand Down
49 changes: 45 additions & 4 deletions openfatture/cli/formatters/stream_json.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""Streaming JSON Lines formatter."""

import json
from typing import Any
from typing import TYPE_CHECKING, Any

from openfatture.ai.domain.response import AgentResponse
from openfatture.cli.formatters.base import BaseFormatter

if TYPE_CHECKING:
from openfatture.ai.streaming.events import StreamEvent


class StreamJSONFormatter(BaseFormatter):
"""Formatter that outputs responses as JSON Lines (JSONL) format.
Expand Down Expand Up @@ -84,28 +87,66 @@ def format_response(self, response: AgentResponse) -> str:

return "\n".join(lines)

def format_stream_chunk(self, chunk: str) -> str:
def format_stream_chunk(self, chunk: "str | StreamEvent") -> str:
"""Format a streaming chunk as JSON Line.

Each chunk is formatted as a separate JSON object with:
- type: "chunk"
- content: the chunk text
- index: sequential chunk number

Accepts either a plain string (raw text chunk) or a
:class:`~openfatture.ai.streaming.events.StreamEvent`, which is what
streaming agents such as ``ChatAgent.execute_stream()`` actually yield.
For a ``StreamEvent`` the textual payload is extracted (``data`` for
content/string events, or its JSON form for structured events) so the
emitted JSON line is always serializable.

Args:
chunk: A chunk of response content
chunk: A chunk of response content (string or StreamEvent)

Returns:
JSONL formatted chunk with newline
"""
content = self._extract_chunk_content(chunk)
chunk_obj = {
"type": "chunk",
"content": chunk,
"content": content,
"index": self.chunk_index,
}
self.chunk_index += 1
return json.dumps(chunk_obj, ensure_ascii=self.ensure_ascii) + "\n"

@staticmethod
def _extract_chunk_content(chunk: "str | StreamEvent") -> str:
"""Normalize a stream chunk into a JSON-serializable string.

Streaming agents yield ``StreamEvent`` objects rather than raw strings.
A ``StreamEvent`` is not JSON-serializable on its own, so extract its
textual payload: the ``data`` field for string payloads (content,
thinking, status, ...) or a compact JSON encoding for structured
payloads (tool events, metrics, ...).

Args:
chunk: A raw string chunk or a StreamEvent.

Returns:
A plain string suitable for embedding in the JSON line.
"""
if isinstance(chunk, str):
return chunk

# Lazy import keeps the CLI startup free of the AI stack.
from openfatture.ai.streaming.events import StreamEvent

if isinstance(chunk, StreamEvent):
data = chunk.data
if isinstance(data, str):
return data
return json.dumps(data, ensure_ascii=False)
Comment on lines +142 to +146

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle Decimal and datetime payloads when serializing structured stream events.

json.dumps(data) will still raise TypeError for the codebase's normal Decimal/date/datetime values, so this path can still break streaming for structured events. Use a serializer hook here before embedding the payload into the JSONL record.

Proposed fix
         if isinstance(chunk, StreamEvent):
             data = chunk.data
             if isinstance(data, str):
                 return data
-            return json.dumps(data, ensure_ascii=False)
+            return json.dumps(data, ensure_ascii=False, default=str)

As per coding guidelines "Use Decimal for all currency and amount values (never float)" and "Use datetime.date for invoice dates and datetime.datetime for timestamps".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openfatture/cli/formatters/stream_json.py` around lines 142 - 146, The branch
handling StreamEvent currently calls json.dumps(data, ensure_ascii=False) which
will raise TypeError for Decimal/date/datetime payloads; update the
serialization to pass a default serializer function to json.dumps that converts
Decimal to str (to preserve precision) and date/datetime to ISO strings (using
.isoformat()), then call json.dumps(data, ensure_ascii=False,
default=serializer) in the StreamEvent branch (referencing StreamEvent and the
existing code that returns json.dumps(data, ...)). Ensure the serializer handles
decimal.Decimal, datetime.date, and datetime.datetime types.


return str(chunk)

def format_stream_complete(
self,
status: str = "success",
Expand Down
7 changes: 5 additions & 2 deletions openfatture/i18n/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ def _load_bundle(locale: str) -> FluentBundle:
if not locale_dir.exists():
raise FileNotFoundError(f"Locale directory not found: {locale_dir}")

# Create FluentBundle
bundle = FluentBundle([locale])
# Create FluentBundle.
# use_isolating=False: this app renders Fluent output through Rich markup and
# plain CLI/email text, where the Unicode bidi isolation marks (U+2068/U+2069)
# that Fluent inserts around placeables corrupt markup and pollute output.
bundle = FluentBundle([locale], use_isolating=False)

# Load all .ftl files in the locale directory
ftl_files = sorted(locale_dir.glob("*.ftl"))
Expand Down
Loading
Loading