Skip to content

fix(contracts): make pricing type an explicit discriminator#357

Merged
clstaudt merged 1 commit into
mainfrom
fix/contract-pricing-type-invariant
Jun 30, 2026
Merged

fix(contracts): make pricing type an explicit discriminator#357
clstaudt merged 1 commit into
mainfrom
fix/contract-pricing-type-invariant

Conversation

@clstaudt

@clstaudt clstaudt commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Summary

An AI document import produced a contradictory contract: it was both fixed-price (Fixed Price 2000 EUR) and time-based (Rate 1000 EUR per day). A contract must be exactly one of the two.

Root cause was structural: the pricing type was inferred from which of two independent nullable columns (rate, fixed_price) happened to be set, so nothing prevented both. The document-import path (commit_import_save_entity) builds Contract rows directly, bypassing ContractsIntent validation, and the import review UI showed a rate field while the LLM-extracted fixed_price rode along invisibly.

This makes type (time_based | fixed_price) the single source of truth — exactly the model the frontend already used internally.

  • Model: new ContractType enum + non-null Contract.type; is_fixed_price derives from type. validate_pricing() requires the matching value column and clears the other, so an ambiguous row can't be persisted.
  • Migration (8de1ce679065): backfills type from existing data (fixed price wins on any legacy both-set row) and nulls the off-column to clean up existing contradictions.
  • Write paths: importer derives type when absent and validates on create/update (with enum-string coercion); ContractsIntent._validated_save delegates to validate_pricing().
  • LLM + UI: extraction emits type; both contract editors (manual + document import) use an exclusive Time-Based/Fixed-Price toggle that shows Rate or Fixed Price and clears the other.

Note for reviewers

For legacy rows (and LLM output) that had both values set, fixed price wins during backfill — the rate is cleared. A contract that should actually be time-based can be switched via the new toggle in the editor.

Test plan

  • uv run pytest — 340 passed
  • just check-migrations — applies clean, no drift
  • UI tsc --noEmit — clean
  • New TestContractPricingInvariant: both-set import normalises to one type, type derived when omitted, model self-heals / rejects missing value
  • Manual: import a contract document that mentions both a fixed price and a day rate; confirm the review toggle and that the committed contract is unambiguous

Made with Cursor

A contract's pricing was inferred from which of two independent nullable
columns (rate, fixed_price) happened to be set, so nothing prevented both
from being populated. The document-import path built Contract rows
directly (bypassing ContractsIntent validation) and let an LLM-extracted
contract carry both a rate and a fixed price into the database.

Make `type` (time_based | fixed_price) the single source of truth:
- Add ContractType enum + non-null Contract.type column; is_fixed_price
  now derives from type. validate_pricing() requires the matching value
  column and clears the other, so an ambiguous row cannot be persisted.
- Migration backfills type from existing data (fixed_price wins on any
  legacy both-set row) and nulls the off-column to clean up contradictions.
- Importer derives type when absent and validates on create/update, with
  enum-string coercion. ContractsIntent delegates to validate_pricing().
- LLM extraction emits type; both contract editors (manual + document
  import) use an exclusive Time-Based/Fixed-Price toggle.

Adds regression coverage for the reported both-set case.

Co-authored-by: Cursor <cursoragent@cursor.com>
@clstaudt clstaudt added this pull request to the merge queue Jun 30, 2026
Merged via the queue into main with commit bdc225a Jun 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant