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
9 changes: 5 additions & 4 deletions tuttle/app/contracts/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ def get_default_currency(self) -> IntentResult:

def _validated_save(self, contract: Contract) -> IntentResult:
is_updating = contract.id is not None
has_rate = contract.rate is not None and contract.rate > 0
has_fixed = contract.fixed_price is not None and contract.fixed_price > 0
if not has_rate and not has_fixed:
try:
contract.validate_pricing()
except ValueError as e:
return IntentResult(
was_intent_successful=False,
error_msg="A contract needs either a rate or a fixed price.",
error_msg=str(e),
log_message=f"ContractsIntent._validated_save: {e}",
)
try:
contract.VAT_rate = normalize_vat_rate(contract.VAT_rate)
Expand Down
27 changes: 27 additions & 0 deletions tuttle/app/imports/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import base64
import datetime
import enum
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, List, Optional
Expand All @@ -13,6 +14,7 @@
from ..core.intent_result import IntentResult
from ...data_dir import get_data_dir
from ...model import Address, Contact, Client, Contract, Project, Invoice, InvoiceItem
from ...time import ContractType


# Fields that are internal to the import workflow, not part of the model
Expand Down Expand Up @@ -289,6 +291,8 @@ def _save_entity(
fields = _model_fields(item, model_cls)
for k, v in fields.items():
setattr(entity, k, v)
if isinstance(entity, Contract):
_finalize_contract(entity, fields)
summary["updated"].append(label)
else:
summary["linked"].append(label)
Expand All @@ -313,12 +317,32 @@ def _save_entity(

clean = _model_fields(fields, model_cls)
entity = model_cls(**clean, **nested_objects)
if isinstance(entity, Contract):
_finalize_contract(entity, clean)
session.add(entity)
session.flush()
_bind_ref(ref, entity.id, ref_to_id)
summary["created"].append(label)


def _finalize_contract(entity: Contract, provided: dict) -> None:
"""Pin a contract's pricing ``type`` and enforce the type invariant.

If the import payload did not specify ``type`` explicitly, derive it
from whichever value column is populated. Then ``validate_pricing``
becomes the single guard: it requires the value column for the chosen
type and clears the other, so an ambiguous contract can never be
committed regardless of what the LLM extracted.
"""
if "type" not in provided:
entity.type = (
ContractType.fixed_price
if entity.fixed_price and not entity.rate
else ContractType.time_based
)
entity.validate_pricing()


def _coerce_value(value: Any, annotation: Any) -> Any:
"""Coerce a JSON value to the expected Python type."""
if value is None or value == "":
Expand All @@ -338,6 +362,9 @@ def _coerce_value(value: Any, annotation: Any) -> Any:
):
if isinstance(value, (int, float, str)):
return Decimal(str(value))
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
if isinstance(value, str):
return annotation(value)
return value


Expand Down
3 changes: 3 additions & 0 deletions tuttle/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ class _ContactExtract(_ContactScalarExtract): # type: ignore[valid-type]
Contract,
include=[
"title",
"type",
"rate",
"fixed_price",
"currency",
Expand Down Expand Up @@ -450,6 +451,7 @@ def _map_contracts(result: ContractExtractionResult) -> List[Dict[str, Any]]:
c = item.contract
unit = getattr(c, "unit", None)
billing_cycle = getattr(c, "billing_cycle", None)
ctype = getattr(c, "type", None)
rate = getattr(c, "rate", None)
vat = getattr(c, "VAT_rate", None)
vat_normalized: Optional[float] = None
Expand All @@ -463,6 +465,7 @@ def _map_contracts(result: ContractExtractionResult) -> List[Dict[str, Any]]:
results.append(
{
"title": getattr(c, "title", "") or "",
"type": ctype.value if ctype else None,
"rate": float(rate) if rate is not None else None,
"fixed_price": float(fixed_price) if fixed_price is not None else None,
"currency": getattr(c, "currency", "") or "",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""add type discriminator to contract

Revision ID: 8de1ce679065
Revises: 34dd17917a18
Create Date: 2026-06-28 14:38:25.581818

======================================================================
FROZEN HISTORICAL SNAPSHOT — NOT THE SCHEMA SOURCE OF TRUTH.

The source of truth is tuttle/model.py. This file captures the schema
DELTA from the previous revision to this point in history. It is
APPEND-ONLY: once committed, never edit it. To change the schema, edit
tuttle/model.py and run `just migrate "<msg>"` to ADD a new revision.

Reading this file to learn the current schema is a MISTAKE — it is a
point-in-time snapshot. Read tuttle/model.py instead.
======================================================================

MANDATORY REVIEW CHECKLIST before committing this file:

1. RENAMES — autogenerate emits drop_column + add_column for renames,
which DESTROYS DATA. If you intended a rename, replace the pair with
op.alter_column(<table>, <old>, new_column_name=<new>).

2. NO MODEL IMPORTS — never `from tuttle.model import ...` here.
Model classes drift over time; this script must be pinned to the
schema at this point in history. For data transformations, declare
a local sa.table(...) snapshot with only the columns this revision
touches.

3. BATCH MODE — render_as_batch=True rebuilds tables for SQLite. After
a batch op on a table with foreign keys, verify integrity inside the
migration: op.execute("PRAGMA foreign_key_check").

See tuttle/migrations/README.md.
----------------------------------------------------------------------
"""
# pyright: reportAttributeAccessIssue=false
# sqlmodel.sql.sqltypes is a submodule resolved at runtime; basedpyright
# does not statically expose `sql` as an attribute of `sqlmodel`.
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel
import sqlmodel.sql.sqltypes # noqa: F401 — ensures runtime resolution of AutoString


revision: str = "8de1ce679065"
down_revision: Union[str, Sequence[str], None] = "34dd17917a18"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema.

Adds the ``type`` discriminator (defaults all rows to ``time_based``),
then backfills it from existing data and removes any ambiguity:

- A contract with a positive ``fixed_price`` becomes ``fixed_price``.
(If a legacy row had BOTH a rate and a fixed price — the bug this
revision closes — fixed_price wins, as the stronger commitment.)
- The value column that does not match the chosen type is nulled, so
no row carries both ``rate`` and ``fixed_price`` afterwards.
"""
with op.batch_alter_table("contract", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"type",
sa.Enum("time_based", "fixed_price", name="contracttype"),
server_default="time_based",
nullable=False,
)
)

contract = sa.table(
"contract",
sa.column("id", sa.Integer),
sa.column("type", sa.String),
sa.column("rate", sa.Numeric),
sa.column("fixed_price", sa.Numeric),
)
op.execute(
contract.update()
.where(contract.c.fixed_price.isnot(None))
.where(contract.c.fixed_price > 0)
.values(type="fixed_price")
)
op.execute(
contract.update().where(contract.c.type == "fixed_price").values(rate=None)
)
op.execute(
contract.update()
.where(contract.c.type == "time_based")
.values(fixed_price=None)
)
op.execute("PRAGMA foreign_key_check")


def downgrade() -> None:
"""Downgrades are not supported.

Tuttle is a single-user desktop app. Rolling back schema is destructive
(data in dropped columns is lost) and offers nothing over restoring a
timestamped backup from ensure_schema()'s pre-upgrade snapshot.

If you need to iterate on a migration during development:
1. Delete this revision file (versions/8de1ce679065_*.py)
2. Run `just reset` to wipe ~/.tuttle
3. Edit model.py, run `just migrate` again
"""
raise NotImplementedError(
"Downgrades are not supported. Restore from a .bak-<ts> snapshot instead."
)
45 changes: 37 additions & 8 deletions tuttle/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from .app.core.formatting import fmt_currency
from .data_dir import get_data_dir
from .dev import deprecated
from .time import Cycle, TimeUnit
from .time import ContractType, Cycle, TimeUnit

DocumentType = Literal["invoice", "reminder"]

Expand Down Expand Up @@ -461,14 +461,24 @@ class Contract(RpcMixin, SQLModel, table=True):
foreign_key="client.id",
ondelete="RESTRICT",
)
type: ContractType = Field(
description="Whether the contract is time-based (rate per unit) or fixed-price. "
"The authoritative discriminator: a contract is exactly one type.",
sa_column=sqlalchemy.Column(
sqlalchemy.Enum(ContractType),
nullable=False,
server_default=ContractType.time_based.value,
),
default=ContractType.time_based,
)
rate: Optional[condecimal(decimal_places=2)] = Field(
default=None,
description="Rate of remuneration per billing unit (for time-based contracts).",
description="Rate of remuneration per billing unit. Set iff type is time_based.",
)
fixed_price: Optional[Decimal] = Field(
default=None,
sa_column=sqlalchemy.Column(sqlalchemy.Numeric(12, 2), nullable=True),
description="Total agreed price (for fixed-price contracts).",
description="Total agreed price. Set iff type is fixed_price.",
)
is_completed: bool = Field(
default=False, description="flag marking if contract has been completed"
Expand Down Expand Up @@ -510,7 +520,7 @@ class Contract(RpcMixin, SQLModel, table=True):

@property
def is_fixed_price(self) -> bool:
return self.fixed_price is not None
return self.type == ContractType.fixed_price

@property
def unit_abbrev(self) -> str:
Expand Down Expand Up @@ -567,10 +577,29 @@ def get_status(self, default: str = "All") -> str:
# default
return default

# NOTE: pydantic-v1-style @validator decorators do not run on
# SQLModel(table=True) classes. VAT_rate normalisation lives in
# ``ContractsIntent._validated_save`` (canonical write path) and
# ``normalize_vat_rate`` is used on every ingress (LLM mapping,
def validate_pricing(self) -> None:
"""Enforce that the contract's pricing matches its ``type``.

``type`` is the single source of truth. This requires the value
column for that type and **clears the other column**, so an
ambiguous row (both ``rate`` and ``fixed_price`` set) can never be
persisted. Raises ``ValueError`` if the required value is missing.

Called on every write path (intent save + document-import commit)
because pydantic-v1 ``@validator`` hooks do not run on
``SQLModel(table=True)`` classes.
"""
if self.type == ContractType.fixed_price:
if not (self.fixed_price is not None and self.fixed_price > 0):
raise ValueError("A fixed-price contract needs a fixed price.")
self.rate = None
else:
if not (self.rate is not None and self.rate > 0):
raise ValueError("A time-based contract needs a rate.")
self.fixed_price = None

# NOTE: VAT_rate normalisation lives in ``ContractsIntent._validated_save``
# and ``normalize_vat_rate`` is used on every ingress (LLM mapping,
# manual scripts).


Expand Down
13 changes: 13 additions & 0 deletions tuttle/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
import datetime


class ContractType(enum.Enum):
"""How a contract is priced. The single discriminator that decides
whether a contract is time-based (a ``rate`` per unit) or fixed-price
(a single ``fixed_price``). A contract is always exactly one of these.
"""

time_based = "time_based"
fixed_price = "fixed_price"

def __str__(self):
return str(self.value)


class Cycle(enum.Enum):
hourly = "hourly"
daily = "daily"
Expand Down
Loading