diff --git a/data/inventory.json b/data/inventory.json index 43077ac..18bc048 100644 --- a/data/inventory.json +++ b/data/inventory.json @@ -15,7 +15,10 @@ }, "obsolete_forced_oos": 0, "out_of_stock": 1413, - "seed": 20260606, + "seed": 20260606, + "snapshot_as_of": "2026-06-08T10:00:00-04:00", + "source_id": "synthetic-inventory", + "source_version": "seed-20260606", "total": 9487 }, "records": { @@ -37968,4 +37971,4 @@ "qty_on_hand": 13 } } -} \ No newline at end of file +} diff --git a/data/m5_baseline_defects.json b/data/m5_baseline_defects.json index 4c31cb7..e4a86b1 100644 --- a/data/m5_baseline_defects.json +++ b/data/m5_baseline_defects.json @@ -3,11 +3,9 @@ "baseline_commit": "8001643fe503254ed9f12f0996894a49efe52342", "contract": "A probe passes only when its positive control works and the named baseline defect is behaviorally reproduced.", "defects": [ - {"id": "M5-DEF-05", "owner_wave": "W2", "test": "tests/test_m5_baseline_defects.py::test_defect_quote_availability_uses_default_quantity_one", "expected_reason": "fulfillment_called_with_quantity_one"}, {"id": "M5-DEF-07", "owner_wave": "W3", "test": "tests/test_m5_baseline_defects.py::test_defect_retry_does_not_repair_missing_queue_row", "expected_reason": "idempotent_retry_leaves_orphan_quote"}, {"id": "M5-DEF-08", "owner_wave": "W5", "test": "tests/test_m5_baseline_defects.py::test_defect_queue_claim_is_not_exclusive", "expected_reason": "same_pending_job_claimed_twice"}, {"id": "M5-DEF-09", "owner_wave": "W3", "test": "tests/test_m5_baseline_defects.py::test_defect_document_guard_accepts_cross_line_quantity_swap", "expected_reason": "flat_fact_set_loses_line_association"}, - {"id": "M5-DEF-10", "owner_wave": "W2", "test": "tests/test_m5_baseline_defects.py::test_defect_revision_lookup_ignores_tenant_when_account_ids_collide", "expected_reason": "cross_tenant_revision_opened"}, {"id": "M5-DEF-11", "owner_wave": "W7", "test": "tests/test_m5_baseline_defects.py::test_defect_latency_harness_rejects_chunked_readback", "expected_reason": "latency_harness_expects_immediate_quote_number"} ] } diff --git a/data/m5_legacy_schema_lock.json b/data/m5_legacy_schema_lock.json index 1e4dc8c..ff5dcf4 100644 --- a/data/m5_legacy_schema_lock.json +++ b/data/m5_legacy_schema_lock.json @@ -3,22 +3,24 @@ "adoption": "migrate_backfill_preserve_nonempty", "components": { "customer": { - "schema_sha256": "0194ba0765b1012184fde37fdc7c6a020bc41e8e958e343edb66af434bf84989", + "schema_sha256": "d925196576d2c375572f62e1b71340203c8daf7f6f92a4cbc30d051dd480e648", "tables": [ "accounts" ], "row_counts": { "accounts": 2 }, - "known_gap": "accounts table has no email or tenant_id column" + "known_gap": "legacy phone/email/contact verification remains explicit unknown until sourced" }, "pricebook": { - "schema_sha256": "0cc59931cad2b089968da0717c5f3b633cba61c00a3c775451fd9f931f4a830f", + "schema_sha256": "0362ad23826a75ac58cd09fa3e782185c81ad545164133ff66d05baf4ad8e461", "tables": [ + "pricebook_meta", "prices", "tiers" ], "row_counts": { + "pricebook_meta": 1, "prices": 1, "tiers": 1 } @@ -46,5 +48,5 @@ ] } }, - "combined_sha256": "6f2c894fc66dceef4f54d3038950458830d1bbef60fdb4f68bb5d508db0b2b85" + "combined_sha256": "6471bcd46b9fc33be420adf67e7183cb997364d36d8d94ec87113c6a32612de0" } diff --git a/scripts/m5_legacy_inventory.py b/scripts/m5_legacy_inventory.py index c8a97aa..9db5dad 100755 --- a/scripts/m5_legacy_inventory.py +++ b/scripts/m5_legacy_inventory.py @@ -16,7 +16,15 @@ from gateway import Account # noqa: E402 from gateway.db_adapters import SqliteCustomerDB, SqlitePriceBook # noqa: E402 -from quoting.models import QuoteDocument, QuoteHeader, QuoteLine, QuoteTerms # noqa: E402 +from quoting.models import ( # noqa: E402 + DeliveryPreflight, + QuoteDocument, + QuoteHeader, + QuoteLine, + QuotePresentation, + QuoteProvenance, + QuoteTerms, +) from quoting.store import SqliteQuoteStore, order_content_hash # noqa: E402 from quoting.totals import build_totals, extended_price # noqa: E402 @@ -42,6 +50,7 @@ def _sample_document(number: str) -> QuoteDocument: line = QuoteLine( line_no=1, sku="K5-24SBC", + tenant_id="tenant_001", catalog_description="chrome stack", requested_qty=10, uom="each", @@ -50,6 +59,7 @@ def _sample_document(number: str) -> QuoteDocument: account_id="1001", extended_price=extended_price(Decimal("24.99"), 10), availability_note="in stock", + availability_status="available", ship_date=now + timedelta(days=1), ship_date_as_of=now, resolution_source="translator:parser", @@ -70,6 +80,14 @@ def _sample_document(number: str) -> QuoteDocument: lines=(line,), totals=build_totals((line,)), terms=QuoteTerms(30, "Prices subject to change.", "Net 30."), + presentation=QuotePresentation( + "Legacy Seller", "", "legacy", "Legacy Customer", "1001"), + provenance=QuoteProvenance( + "legacy-build", "renderer-v1", "guard-v1", "agent-v1", + "legacy-catalog", ("legacy-price",), ("legacy-inventory",)), + delivery_preflight=DeliveryPreflight( + "email", "legacy-contact", "v1", "fingerprint", + "l***@example.test", True), ) @@ -112,7 +130,10 @@ def compute() -> dict: "schema_sha256": customer_hash, "tables": customer_tables, "row_counts": _row_counts(customers._conn, customer_tables), - "known_gap": "accounts table has no email or tenant_id column", + "known_gap": ( + "legacy phone/email/contact verification remains explicit " + "unknown until sourced" + ), }, "pricebook": { "schema_sha256": price_hash, diff --git a/src/fulfillment/engine.py b/src/fulfillment/engine.py index 43c917c..bd0def5 100644 --- a/src/fulfillment/engine.py +++ b/src/fulfillment/engine.py @@ -45,6 +45,9 @@ class InventoryRecord: sku: str qty_on_hand: int lead_time_days: int | None # None iff qty_on_hand > 0 + source_id: str = '' + source_version: str = '' + as_of: datetime | None = None def __post_init__(self) -> None: if self.qty_on_hand < 0: @@ -58,6 +61,9 @@ def __post_init__(self) -> None: raise ValueError( f'{self.sku}: out-of-stock record requires lead_time_days >= 1' ) + if self.as_of is not None and ( + self.as_of.tzinfo is None or self.as_of.utcoffset() is None): + raise ValueError(f'{self.sku}: inventory as_of must be timezone-aware') @dataclass(frozen=True) @@ -133,8 +139,14 @@ def load_inventory(path: str | Path) -> dict[str, InventoryRecord]: fails loudly here at startup rather than at quote time. """ raw = json.loads(Path(path).read_text()) + meta = raw.get('_meta', {}) + as_of_raw = meta.get('snapshot_as_of') + as_of = datetime.fromisoformat(as_of_raw) if as_of_raw else None return { sku: InventoryRecord(sku=sku, qty_on_hand=rec['qty_on_hand'], - lead_time_days=rec['lead_time_days']) + lead_time_days=rec['lead_time_days'], + source_id=meta.get('source_id', ''), + source_version=meta.get('source_version', ''), + as_of=as_of) for sku, rec in raw['records'].items() } diff --git a/src/gateway/answers.py b/src/gateway/answers.py index 2465b12..fb7ede5 100644 --- a/src/gateway/answers.py +++ b/src/gateway/answers.py @@ -19,6 +19,7 @@ PriceAnswer, ) from gateway.pricebook import PriceBook +from gateway.source_freshness import source_is_fresh _MONTHS = ('January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December') @@ -56,7 +57,8 @@ def availability(sku: str, *, inventory: dict[str, InventoryRecord], received_at, catalog_version: str, qty: int = 1 ) -> AvailabilityAnswer | None: rec = inventory.get(sku) - if rec is None: + if rec is None or not source_is_fresh( + rec.as_of, received_at, rec.source_id): return None try: res = ship_date(rec, qty, received_at, @@ -68,7 +70,9 @@ def availability(sku: str, *, inventory: dict[str, InventoryRecord], quantity_on_hand=rec.qty_on_hand, ship_by_iso='', basis='beyond_calendar_horizon', plain=f"I can't quote a ship date for {sku} that far out yet.", - catalog_version=catalog_version) + catalog_version=catalog_version, source_id=rec.source_id, + source_version=rec.source_version, + source_as_of=rec.as_of.isoformat() if rec.as_of else '') ship_iso = res.ship_by.isoformat() return AvailabilityAnswer( sku=sku, in_stock=rec.qty_on_hand > 0, @@ -76,7 +80,9 @@ def availability(sku: str, *, inventory: dict[str, InventoryRecord], basis=res.basis, plain=_plain_availability(sku, rec.qty_on_hand > 0, rec.qty_on_hand, ship_iso), - catalog_version=catalog_version) + catalog_version=catalog_version, source_id=rec.source_id, + source_version=rec.source_version, + source_as_of=rec.as_of.isoformat() if rec.as_of else '') class PricingRefused(Exception): @@ -85,7 +91,7 @@ class PricingRefused(Exception): def pricing(sku: str, auth: AuthorizationDecision, *, pricebook: PriceBook, - account_tier_of) -> PriceAnswer: + account_tier_of, source_now=None) -> PriceAnswer: # Second, independent gate (#10 defense-in-depth): even if the session # gate were bypassed, the pricing service itself refuses without a grant. if not auth.granted or auth.source in ('unverified', 'cross_account_denied'): @@ -93,10 +99,15 @@ def pricing(sku: str, auth: AuthorizationDecision, *, pricebook: PriceBook, f'pricing refused for {sku}: authorization not granted ' f'(source={auth.source})') tier = account_tier_of(auth.account_id) - price = pricebook.price(sku, tier) - if price is None: + snapshot = pricebook.snapshot(sku, tier, auth.account_id) + if snapshot is None: raise PricingRefused(f'no price on file for {sku} at tier {tier}') + if source_now is not None and not source_is_fresh( + snapshot.as_of, source_now, snapshot.source_id): + raise PricingRefused(f'price source is stale or unreadable for {sku}') return PriceAnswer( - sku=sku, account_id=auth.account_id, unit_price=price, + sku=sku, account_id=auth.account_id, unit_price=snapshot.unit_price, source=auth.source, - plain=f"For your account, {sku} is ${price:.2f} each.") + plain=f"For your account, {sku} is ${snapshot.unit_price:.2f} each.", + source_id=snapshot.source_id, source_version=snapshot.source_version, + source_as_of=snapshot.as_of.isoformat()) diff --git a/src/gateway/conversation_snapshot.py b/src/gateway/conversation_snapshot.py index d36002d..2ec93f4 100644 --- a/src/gateway/conversation_snapshot.py +++ b/src/gateway/conversation_snapshot.py @@ -23,6 +23,7 @@ from gateway.order import DraftLine, LineState, Order from gateway.order_parser import OrderClause from gateway.session import Session +from quoting.models import DeliveryPreflight from sku_translator.quantity import RequestedQty SNAPSHOT_VERSION = 1 @@ -90,6 +91,10 @@ def dump_call(gateway, caller_id: str) -> dict[str, Any]: 'chunks': [[_line(line) for line in chunk] for chunk in chunks[0]], 'next_index': chunks[1]}, 'completed_order_readback': caller_id in gateway._completed_order_readback, + 'delivery_preflight': ( + asdict(gateway._delivery_preflight_by_call[caller_id]) + if caller_id in gateway._delivery_preflight_by_call else None), + 'pending_quote_reference': gateway._pending_quote_reference.get(caller_id), 'pending_clauses': [ {'raw_span': clause.raw_span, 'part_text': clause.part_text, 'requested_qty': asdict(clause.requested_qty), 'uom': clause.uom, @@ -157,6 +162,11 @@ def load_call(gateway, caller_id: str, value: dict[str, Any]) -> str | None: gateway._pending_order_readback.pop(caller_id, None) _restore_set(gateway._completed_order_readback, caller_id, value['completed_order_readback']) + raw_preflight = value.get('delivery_preflight') + _restore_mapping(gateway._delivery_preflight_by_call, caller_id, + DeliveryPreflight(**raw_preflight) if raw_preflight else None) + _restore_mapping(gateway._pending_quote_reference, caller_id, + value.get('pending_quote_reference')) pending_clauses = tuple( OrderClause(**{**clause, 'requested_qty': RequestedQty(**clause['requested_qty'])}) diff --git a/src/gateway/db_adapters.py b/src/gateway/db_adapters.py index 31445c9..b86d9b9 100644 --- a/src/gateway/db_adapters.py +++ b/src/gateway/db_adapters.py @@ -15,10 +15,11 @@ from __future__ import annotations import sqlite3 +from datetime import datetime, timezone from decimal import Decimal from gateway.models import Account -from gateway.pricebook import to_cents +from gateway.pricebook import PriceSnapshot, to_cents def _connect(source) -> sqlite3.Connection: @@ -37,10 +38,26 @@ class SqliteCustomerDB: def __init__(self, source) -> None: self._conn = _connect(source) self._conn.row_factory = sqlite3.Row + self._migrate_contact_columns() + + def _migrate_contact_columns(self) -> None: + columns = {row['name'] for row in self._conn.execute( + 'PRAGMA table_info(accounts)').fetchall()} + additions = { + 'email': 'TEXT', 'tenant_id': 'TEXT', 'contact_id': 'TEXT', + 'contact_version': 'TEXT', + 'phone_verified': 'INTEGER NOT NULL DEFAULT 0', + 'email_verified': 'INTEGER NOT NULL DEFAULT 0', + } + for name, declaration in additions.items(): + if name not in columns: + self._conn.execute( + f'ALTER TABLE accounts ADD COLUMN {name} {declaration}') + self._conn.commit() def by_number(self, account_no: str) -> Account | None: row = self._conn.execute( - 'SELECT account_id, name, phone FROM accounts WHERE account_id = ?', + 'SELECT * FROM accounts WHERE account_id = ?', (account_no.strip(),)).fetchone() return self._account(row) if row else None @@ -52,7 +69,7 @@ def by_name(self, name: str) -> list[Account]: # the 0/1/many rule. LIKE param is escaped against wildcard injection. like = '%' + q.replace('%', r'\%').replace('_', r'\_') + '%' rows = self._conn.execute( - "SELECT account_id, name, phone FROM accounts " + "SELECT * FROM accounts " "WHERE LOWER(name) LIKE ? ESCAPE '\\' ORDER BY account_id", (like,)).fetchall() return [self._account(r) for r in rows] @@ -60,16 +77,26 @@ def by_name(self, name: str) -> list[Account]: @staticmethod def _account(row) -> Account: return Account(account_id=row['account_id'], name=row['name'], - phone=row['phone']) + phone=row['phone'], email=row['email'], + tenant_id=row['tenant_id'] or '', + contact_id=row['contact_id'] or '', + contact_version=row['contact_version'] or '', + phone_verified=bool(row['phone_verified']), + email_verified=bool(row['email_verified'])) @classmethod def build(cls, path, accounts: list[Account]) -> 'SqliteCustomerDB': conn = _connect(path) conn.execute('CREATE TABLE IF NOT EXISTS accounts ' - '(account_id TEXT PRIMARY KEY, name TEXT, phone TEXT)') + '(account_id TEXT PRIMARY KEY, name TEXT, phone TEXT, ' + 'email TEXT, tenant_id TEXT, contact_id TEXT, ' + 'contact_version TEXT, phone_verified INTEGER NOT NULL DEFAULT 0, ' + 'email_verified INTEGER NOT NULL DEFAULT 0)') conn.execute('DELETE FROM accounts') - conn.executemany('INSERT INTO accounts VALUES (?, ?, ?)', - [(a.account_id, a.name, a.phone) for a in accounts]) + conn.executemany('INSERT INTO accounts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + [(a.account_id, a.name, a.phone, a.email, a.tenant_id, + a.contact_id, a.contact_version, int(a.phone_verified), + int(a.email_verified)) for a in accounts]) conn.commit() return cls(conn) @@ -99,19 +126,45 @@ def price(self, sku: str, tier: str) -> Decimal | None: m = Decimal(mult['multiplier']) if mult else Decimal('1.0') return to_cents(Decimal(base['base']) * m) + def snapshot(self, sku: str, tier: str, + account_id: str) -> PriceSnapshot | None: + value = self.price(sku, tier) + try: + meta = self._conn.execute( + 'SELECT source_id, source_version, as_of FROM pricebook_meta ' + 'WHERE singleton=1').fetchone() + except sqlite3.OperationalError: + return None + if value is None or meta is None or not meta['as_of']: + return None + return PriceSnapshot( + sku, account_id, tier, value, meta['source_id'], + meta['source_version'], datetime.fromisoformat(meta['as_of'])) + @classmethod def build(cls, path, base_by_sku: dict[str, Decimal], - tier_multiplier: dict[str, Decimal]) -> 'SqlitePriceBook': + tier_multiplier: dict[str, Decimal], *, + source_id: str = 'sqlite-pricebook', + source_version: str = 'fixture-v1', + as_of: datetime | None = None) -> 'SqlitePriceBook': conn = _connect(path) conn.execute('CREATE TABLE IF NOT EXISTS prices ' '(sku TEXT PRIMARY KEY, base TEXT)') conn.execute('CREATE TABLE IF NOT EXISTS tiers ' '(tier TEXT PRIMARY KEY, multiplier TEXT)') + conn.execute('CREATE TABLE IF NOT EXISTS pricebook_meta ' + '(singleton INTEGER PRIMARY KEY CHECK(singleton=1), ' + 'source_id TEXT, source_version TEXT, as_of TEXT)') conn.execute('DELETE FROM prices') conn.execute('DELETE FROM tiers') conn.executemany('INSERT INTO prices VALUES (?, ?)', [(sku, str(base)) for sku, base in base_by_sku.items()]) conn.executemany('INSERT INTO tiers VALUES (?, ?)', [(tier, str(mult)) for tier, mult in tier_multiplier.items()]) + snapshot_time = as_of or datetime.now(timezone.utc) + conn.execute('INSERT INTO pricebook_meta VALUES(1,?,?,?) ' + 'ON CONFLICT(singleton) DO UPDATE SET source_id=excluded.source_id,' + 'source_version=excluded.source_version,as_of=excluded.as_of', + (source_id, source_version, snapshot_time.isoformat())) conn.commit() return cls(conn) diff --git a/src/gateway/models.py b/src/gateway/models.py index 5c7c20f..dd14f27 100644 --- a/src/gateway/models.py +++ b/src/gateway/models.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import re from dataclasses import dataclass, field from decimal import Decimal from enum import Enum @@ -25,6 +26,19 @@ class Account: name: str phone: str | None = None email: str | None = None + tenant_id: str = 'tenant_001' + contact_id: str = '' + contact_version: str = '' + phone_verified: bool = False + email_verified: bool = False + + def __post_init__(self) -> None: + if self.phone_verified and ( + not self.phone or not re.fullmatch(r'\+[1-9]\d{7,14}', self.phone)): + raise ValueError('verified phone must be normalized E.164') + if self.email_verified and ( + not self.email or not re.fullmatch(r'[^@\s]+@[^@\s]+', self.email)): + raise ValueError('verified email must be normalized and valid') @dataclass(frozen=True) @@ -76,6 +90,9 @@ class AvailabilityAnswer: basis: str # the ship-date rule that fired (provenance) plain: str # rep-language rendering catalog_version: str + source_id: str = '' + source_version: str = '' + source_as_of: str = '' @dataclass(frozen=True) @@ -85,6 +102,9 @@ class PriceAnswer: unit_price: Decimal # cents-quantized; never float (M5 D11) source: str # authorization source (audit anchor) plain: str + source_id: str = '' + source_version: str = '' + source_as_of: str = '' # --- turn envelope (G1) ------------------------------------------------------- diff --git a/src/gateway/orchestrator.py b/src/gateway/orchestrator.py index b8d2db9..08aebe3 100644 --- a/src/gateway/orchestrator.py +++ b/src/gateway/orchestrator.py @@ -15,9 +15,10 @@ """ from __future__ import annotations +import hashlib import re from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: @@ -62,9 +63,12 @@ from gateway.spoken import spoken_description, to_spoken from observability import get_logger, log_event, set_attr, tracer from quoting.models import ( + DeliveryPreflight, QuoteDocument, QuoteHeader, QuoteLine, + QuotePresentation, + QuoteProvenance, QuoteTerms, default_valid_until, ) @@ -83,6 +87,8 @@ r'(\d{3,12})\b', re.I) _QUOTE_NUMBER_RE = re.compile(r'\bQ-[A-Za-z0-9_]+-[0-9]{6}\b', re.I) _REVISION_INTENT_RE = re.compile(r'\b(revise|revision|change|update)\b', re.I) +_QUOTE_SUFFIX_RE = re.compile(r'\b(?:ending|suffix|quote)\s*(\d{6})\b', re.I) +_LATEST_QUOTE_RE = re.compile(r'\blatest\s+quote\b', re.I) @dataclass @@ -132,6 +138,8 @@ class Gateway: # real turn boundary where edits can barge in; it is not string formatting. _pending_order_readback: dict = field(default_factory=dict) _completed_order_readback: set[str] = field(default_factory=set) + _delivery_preflight_by_call: dict[str, DeliveryPreflight] = field(default_factory=dict) + _pending_quote_reference: dict[str, str] = field(default_factory=dict) # Clauses after one that needs a discriminating identity readback. They are # authoritative server state; clarification never discards later items. _pending_clauses: dict = field(default_factory=dict) @@ -299,20 +307,13 @@ def _with_order_disclosure(self, caller_id: str, phase, next_action = 'blocked', 'answer_clarification' else: phase, next_action = 'not_started', 'add_or_prepare_quote' - account_id = self.sessions._sessions.get(caller_id) - account = (self.sessions.customer_db.by_number(account_id.account_id) - if account_id and account_id.account_id else None) - channel = None - destination = None - if account and account.email: - channel, destination = 'email', _mask_email(account.email) - elif account and account.phone: - channel, destination = 'sms', _mask_phone(account.phone) + preflight = self._delivery_preflight_by_call.get(caller_id) disclosure = OrderDisclosure( lines=lines, readback_phase=phase, expected_next_action=next_action, - delivery_channel=channel, masked_destination=destination, - delivery_ready=destination is not None, + delivery_channel=preflight.channel if preflight else None, + masked_destination=preflight.masked_destination if preflight else None, + delivery_ready=bool(preflight and preflight.available), may_close=order.is_assented or not lines) return dataclasses.replace(response, order_disclosure=disclosure) @@ -324,6 +325,19 @@ def _order_for(self, caller_id: str) -> Order: return order def _converse_dispatch(self, caller_id, token, text, channel, conv): + pending_quote = self._pending_quote_reference.get(caller_id) + if pending_quote is not None: + suffix = pending_quote.rsplit('-', 1)[-1] + if suffix in text and re.search(r'\b(yes|correct|right|confirm)\b', text, re.I): + self._pending_quote_reference.pop(caller_id, None) + return self._decided( + self._converse_open_revision(caller_id, token, pending_quote), + conv, move='revision_reference_confirmed') + return self._decided(TurnResponse( + kind='order', session_state=self.sessions.state_of( + caller_id, token).value, needs_confirmation=True, + text=f'Please confirm by saying yes, quote ending {suffix}.'), + conv, move='revision_reference_wait') # Explicit, narrow signals win first regardless of order state — a # caller can always ask for a human, or state a clearly out-of-scope # need (billing/returns), even mid-order. Checked directly (not via @@ -336,6 +350,11 @@ def _converse_dispatch(self, caller_id, token, text, channel, conv): return self._decided( self._escalate(caller_id, token, esc), conv, move='escalate') quote_match = _QUOTE_NUMBER_RE.search(text) + if quote_match is None and _REVISION_INTENT_RE.search(text) and ( + _LATEST_QUOTE_RE.search(text) or _QUOTE_SUFFIX_RE.search(text)): + return self._decided( + self._converse_resolve_quote_reference(caller_id, token, text), + conv, move='revision_reference') if quote_match is not None and _REVISION_INTENT_RE.search(text): return self._decided( self._converse_open_revision( @@ -561,23 +580,17 @@ def _converse_open_revision(self, sid: str, token: str, quote_number: str) -> TurnResponse: """Account-gate a revision before seeding order state or any store write.""" st = self.sessions.state_of(sid, token).value - document = self.quote_store.get(quote_number) - if document is None: - self.journal.record( - EventType.REFUSAL, sid, reason='revision_not_found', - quote_number=quote_number) - return TurnResponse( - kind='order', session_state=st, refused='revision_not_found', - text="I couldn't find that quote. Please check the quote number.") own = _verified_account(self.sessions, sid, token) - if own != document.header.account_id: + document = self.quote_store.get_scoped( + self.catalog.tenant_id(), own or '', quote_number) + if document is None: self.journal.record( - EventType.REFUSAL, sid, reason='revision_unauthorized', + EventType.REFUSAL, sid, reason='revision_not_available', quote_number=quote_number) return TurnResponse( kind='order', session_state=st, - refused='revision_unauthorized', - text="I can't revise that quote for this account.") + refused='revision_not_available', + text="I couldn't open that quote. Please check the reference.") order = Order() for quote_line in document.lines: @@ -608,6 +621,40 @@ def _converse_open_revision(self, sid: str, token: str, 'What would you like to change?', meta={'quote_number': quote_number, 'revision_opened': True}) + def _converse_resolve_quote_reference(self, sid: str, token: str, + text: str) -> TurnResponse: + st = self.sessions.state_of(sid, token).value + own = _verified_account(self.sessions, sid, token) + if own is None: + return TurnResponse( + kind='order', session_state=st, + refused='revision_not_available', + text="I couldn't open that quote. Please check the reference.") + suffix_match = _QUOTE_SUFFIX_RE.search(text) + suffix = suffix_match.group(1) if suffix_match else None + candidates = self.quote_store.find_scoped( + self.catalog.tenant_id(), own, suffix=suffix) + if not candidates: + return TurnResponse( + kind='order', session_state=st, + refused='revision_not_available', + text="I couldn't open that quote. Please check the reference.") + # Latest is definitionally one. A suffix collision is not guessed. + if suffix is not None and len(candidates) != 1: + return TurnResponse( + kind='order', session_state=st, + refused='revision_not_available', + text="I couldn't open that quote. Please check the reference.") + document = candidates[0] + canonical = document.header.quote_number + spoken_suffix = canonical.rsplit('-', 1)[-1] + self._pending_quote_reference[sid] = canonical + return TurnResponse( + kind='order', session_state=st, needs_confirmation=True, + text=f'I found your quote ending {spoken_suffix}, dated ' + f'{document.header.created_at.date().isoformat()}. ' + f'Say yes, quote ending {spoken_suffix}, to revise it.') + # -- gate-backed disclosure (the live path RUNS through discloseable) ------ def _converse_disclose(self, sid, token, text, channel, conv, @@ -950,7 +997,8 @@ def _try_price_line(self, sid, token, order, line): auth = self.sessions.issue_authorization(sid, token, own or '__none__') try: ans = pricing(line.sku, auth, pricebook=self.pricebook, - account_tier_of=self.account_tier_of) + account_tier_of=self.account_tier_of, + source_now=self.now_fn()) except PricingRefused: return line, None priced = order.transition(line.line_id, LineState.PRICED) @@ -1033,9 +1081,27 @@ def _converse_order_assent(self, sid, token, conv) -> TurnResponse: kind='order', session_state=st, text='A couple of lines still need to be sorted out ' 'before the readback.') + preflight = _contact_preflight( + self.sessions.customer_db.by_number(own) if own else None, + self.catalog.tenant_id()) + if preflight is None: + return TurnResponse( + kind='order', session_state=st, refused='contact_missing', + text='I need a verified email or mobile number on the account ' + 'before I can prepare the delivery readback.') + self._delivery_preflight_by_call[sid] = preflight chunks = chunk_lines(order.active_lines) self._pending_order_readback[sid] = (chunks, 1) return self._readback_chunk_turn(sid, token, chunks, 0) + current_preflight = _contact_preflight( + self.sessions.customer_db.by_number(own) if own else None, + self.catalog.tenant_id()) + if current_preflight != self._delivery_preflight_by_call.get(sid): + self._completed_order_readback.discard(sid) + self._delivery_preflight_by_call.pop(sid, None) + return TurnResponse( + kind='order', session_state=st, refused='contact_changed', + text='The delivery contact changed, so I need to read the quote back again.') revision_context = self._revision_context.get(sid) if revision_context is not None and sid not in self._pending_revision_assent: blockers = [ @@ -1125,31 +1191,46 @@ def _issue_quote(self, sid, token, order) -> QuoteDocument: # Readback and non-assent turns therefore remain store-read-only. revision = 0 lines = [] + price_versions: set[str] = set() + inventory_versions: set[str] = set() for i, dl in enumerate(order.active_lines, start=1): if dl.sku is None or dl.requested_qty is None: raise OrderNotIssuable( f'{dl.line_id} is missing identity or quantity at issuance') auth = self.sessions.issue_authorization(sid, token, own) price_ans = pricing(dl.sku, auth, pricebook=self.pricebook, - account_tier_of=self.account_tier_of) + account_tier_of=self.account_tier_of, + source_now=now) avail_ans = availability(dl.sku, inventory=self.inventory, received_at=now, - catalog_version=self.catalog_version) + catalog_version=self.catalog_version, + qty=dl.requested_qty) + price_as_of = _fresh_source_time( + price_ans.source_as_of, now, price_ans.source_id) + inventory_as_of = _fresh_source_time( + avail_ans.source_as_of if avail_ans else '', now, + avail_ans.source_id if avail_ans else '') + price_versions.add(price_ans.source_version) + inventory_versions.add(avail_ans.source_version if avail_ans else '') row = self.catalog.lookup(dl.sku) ship_dt = None if avail_ans is not None and avail_ans.ship_by_iso: ship_dt = datetime.fromisoformat(avail_ans.ship_by_iso) lines.append(QuoteLine( - line_no=i, sku=dl.sku, + line_no=i, sku=dl.sku, tenant_id=tenant_id, catalog_description=(getattr(row, 'description', '') or dl.sku), requested_qty=dl.requested_qty, uom=dl.uom, - unit_price=price_ans.unit_price, unit_price_as_of=now, + unit_price=price_ans.unit_price, unit_price_as_of=price_as_of, account_id=price_ans.account_id, extended_price=extended_price(price_ans.unit_price, dl.requested_qty), availability_note=(avail_ans.plain if avail_ans else 'unavailable'), - ship_date=ship_dt, ship_date_as_of=now, + availability_status=( + 'available' if avail_ans and + dl.requested_qty <= avail_ans.quantity_on_hand + else 'backorder' if avail_ans else 'unavailable'), + ship_date=ship_dt, ship_date_as_of=inventory_as_of, resolution_source=dl.resolution_source, resolution_confidence=dl.resolution_confidence)) if revision_context is not None: @@ -1164,8 +1245,27 @@ def _issue_quote(self, sid, token, order) -> QuoteDocument: pricing_disclaimer='Prices subject to change; valid for the ' 'stated window.', tenant_terms_text='') + customer = self.sessions.customer_db.by_number(own) document = QuoteDocument(header=header, lines=tuple(lines), - totals=build_totals(tuple(lines)), terms=terms) + totals=build_totals(tuple(lines)), terms=terms, + presentation=QuotePresentation( + seller_name='SKU Resolver Demo Seller', + seller_address='', branding_ref='default', + customer_display_name=(customer.name + if customer else own), + customer_account_id=own), + provenance=QuoteProvenance( + gateway_build_sha=getattr( + self, 'gateway_build_sha', 'test-build'), + renderer_schema_version='pdf-xlsx-v1', + guard_schema_version='document-guard-v1', + hosted_agent_version=getattr( + self, 'hosted_agent_version', 'local-test'), + catalog_version=self.catalog_version, + price_source_versions=tuple(sorted(price_versions)), + inventory_source_versions=tuple( + sorted(inventory_versions))), + delivery_preflight=self._delivery_preflight_by_call[sid]) self.quote_store.save(document) self.quote_store.enqueue(document.header.quote_number, document.header.revision) @@ -1210,7 +1310,8 @@ def reader(part, ft, account, now): sid, token, own or '__none__') try: ans = pricing(sku, auth, pricebook=self.pricebook, - account_tier_of=self.account_tier_of) + account_tier_of=self.account_tier_of, + source_now=self.now_fn()) except PricingRefused: return Fact.unreadable() captured[(part.ctx_id, ft)] = ans @@ -1519,7 +1620,8 @@ def _handle_pricing(self, session_id, token, text, channel, else self.sessions.issue_authorization(session_id, token, '__none__') try: ans = pricing(sku, auth, pricebook=self.pricebook, - account_tier_of=self.account_tier_of) + account_tier_of=self.account_tier_of, + source_now=self.now_fn()) except PricingRefused as e: self.journal.record(EventType.PRICING_REFUSED, session_id, sku=sku, reason=str(e)) @@ -1553,6 +1655,41 @@ def _mask_phone(value: str) -> str: return f'***-***-{digits[-4:]}' if len(digits) >= 4 else '***' +def _contact_preflight(account, tenant_id: str) -> DeliveryPreflight | None: + if account is None or account.tenant_id != tenant_id: + return None + if account.email and account.email_verified: + channel, destination = 'email', account.email.strip().lower() + masked = _mask_email(destination) + elif (account.phone and account.phone_verified + and re.fullmatch(r'\+[1-9]\d{7,14}', account.phone)): + channel, destination = 'sms', account.phone + masked = _mask_phone(destination) + else: + return None + if not account.contact_id or not account.contact_version: + return None + fingerprint = hashlib.sha256( + f'{tenant_id}:{account.contact_id}:{destination}'.encode()).hexdigest() + return DeliveryPreflight( + channel, account.contact_id, account.contact_version, + fingerprint, masked, True) + + +def _fresh_source_time(raw: str, now: datetime, source_id: str) -> datetime: + if not raw: + raise OrderNotIssuable(f'{source_id or "source"} has no source timestamp') + as_of = datetime.fromisoformat(raw) + if as_of.tzinfo is None or as_of.utcoffset() is None: + raise OrderNotIssuable(f'{source_id or "source"} timestamp is naive') + if as_of > now + timedelta(minutes=5): + raise OrderNotIssuable(f'{source_id or "source"} timestamp is in the future') + horizon = timedelta(days=3650 if source_id.startswith('synthetic-') else 1) + if now - as_of > horizon: + raise OrderNotIssuable(f'{source_id or "source"} snapshot is stale') + return as_of + + _FILLER_RE = re.compile( r'^\s*(yes|yeah|yep|yup|ok|okay|sure|thanks|thank you|great|got it|' r'right|correct|uh huh|mm hmm|please|hello|hi|hey)[\s.!,]*$', re.I) diff --git a/src/gateway/pricebook.py b/src/gateway/pricebook.py index a1ba77a..6682784 100644 --- a/src/gateway/pricebook.py +++ b/src/gateway/pricebook.py @@ -15,6 +15,7 @@ import json from dataclasses import dataclass +from datetime import datetime, timezone from decimal import ROUND_HALF_UP, Decimal from pathlib import Path from typing import Protocol @@ -30,6 +31,24 @@ def to_cents(value: Decimal) -> Decimal: class PriceBook(Protocol): def price(self, sku: str, tier: str) -> Decimal | None: ... + def snapshot(self, sku: str, tier: str, account_id: str) -> 'PriceSnapshot | None': ... + + +@dataclass(frozen=True) +class PriceSnapshot: + sku: str + account_id: str + tier: str + unit_price: Decimal + source_id: str + source_version: str + as_of: datetime + + def __post_init__(self) -> None: + if self.as_of.tzinfo is None or self.as_of.utcoffset() is None: + raise ValueError('price source as_of must be timezone-aware') + if isinstance(self.unit_price, float): + raise TypeError('price snapshot cannot contain float') @dataclass @@ -39,6 +58,9 @@ class SyntheticPriceBook: arithmetic is Decimal; no float is ever constructed on this path.""" base_by_sku: dict[str, Decimal] tier_multiplier: dict[str, Decimal] + source_id: str = 'synthetic-pricebook' + source_version: str = 'seed-20260607' + as_of: datetime | None = None def price(self, sku: str, tier: str) -> Decimal | None: base = self.base_by_sku.get(sku) @@ -47,8 +69,17 @@ def price(self, sku: str, tier: str) -> Decimal | None: mult = self.tier_multiplier.get(tier, Decimal('1.0')) return to_cents(base * mult) + def snapshot(self, sku: str, tier: str, + account_id: str) -> PriceSnapshot | None: + value = self.price(sku, tier) + if value is None or self.as_of is None: + return None + return PriceSnapshot(sku, account_id, tier, value, self.source_id, + self.source_version, self.as_of) + @classmethod - def seeded(cls, skus: list[str], *, seed: int = 20260607) -> 'SyntheticPriceBook': + def seeded(cls, skus: list[str], *, seed: int = 20260607, + as_of: datetime | None = None) -> 'SyntheticPriceBook': # Deterministic pseudo-price from a stable hash of the sku (no RNG # state, so order-independent and reproducible). Built entirely from # integers -> Decimal(cents) -> Decimal(dollars); no float anywhere @@ -62,16 +93,24 @@ def seeded(cls, skus: list[str], *, seed: int = 20260607) -> 'SyntheticPriceBook return cls(base_by_sku=base, tier_multiplier={'standard': Decimal('1.0'), 'preferred': Decimal('0.9'), - 'distributor': Decimal('0.8')}) + 'distributor': Decimal('0.8')}, + source_version=f'seed-{seed}', + as_of=as_of or datetime.now(timezone.utc)) def save(self, path: Path) -> None: Path(path).write_text(json.dumps( {'base_by_sku': {k: str(v) for k, v in self.base_by_sku.items()}, - 'tier_multiplier': {k: str(v) for k, v in self.tier_multiplier.items()}}, + 'tier_multiplier': {k: str(v) for k, v in self.tier_multiplier.items()}, + 'source_id': self.source_id, 'source_version': self.source_version, + 'as_of': self.as_of.isoformat() if self.as_of else None}, indent=1, sort_keys=True)) @classmethod def load(cls, path: Path) -> 'SyntheticPriceBook': d = json.loads(Path(path).read_text()) + as_of = d.get('as_of') return cls(base_by_sku={k: Decimal(v) for k, v in d['base_by_sku'].items()}, - tier_multiplier={k: Decimal(v) for k, v in d['tier_multiplier'].items()}) + tier_multiplier={k: Decimal(v) for k, v in d['tier_multiplier'].items()}, + source_id=d.get('source_id', ''), + source_version=d.get('source_version', ''), + as_of=datetime.fromisoformat(as_of) if as_of else None) diff --git a/src/gateway/source_freshness.py b/src/gateway/source_freshness.py new file mode 100644 index 0000000..9b35909 --- /dev/null +++ b/src/gateway/source_freshness.py @@ -0,0 +1,19 @@ +"""Source-time validation, separate from fulfillment date arithmetic.""" +from __future__ import annotations + +from datetime import datetime, timedelta + + +def source_is_fresh(as_of: datetime | None, now: datetime, + source_id: str) -> bool: + if as_of is None or as_of.tzinfo is None or as_of.utcoffset() is None: + return False + if now.tzinfo is None or now.utcoffset() is None: + return False + if as_of > now + timedelta(minutes=5): + return False + # Bundled synthetic data is a deterministic test fixture, not a real + # production freshness claim. Real sources use the conservative placeholder + # until P02 supplies measured cadence. + horizon = timedelta(days=3650 if source_id.startswith('synthetic-') else 1) + return now - as_of <= horizon diff --git a/src/quoting/models.py b/src/quoting/models.py index 81d7054..dce355d 100644 --- a/src/quoting/models.py +++ b/src/quoting/models.py @@ -12,6 +12,7 @@ """ from __future__ import annotations +import re from dataclasses import dataclass from datetime import datetime, timedelta from decimal import Decimal @@ -38,6 +39,7 @@ class QuoteHeader: valid_until: datetime # D13: created_at + a named placeholder horizon prepared_by: str # agent + gateway version string catalog_version: str + currency: str = 'USD' @dataclass(frozen=True) @@ -48,6 +50,7 @@ class QuoteLine: can be traced back to the resolution/confirmation that produced it.""" line_no: int sku: str + tenant_id: str catalog_description: str requested_qty: int uom: str @@ -56,6 +59,7 @@ class QuoteLine: account_id: str # the account this unit_price is valid for extended_price: Decimal availability_note: str + availability_status: str ship_date: datetime | None ship_date_as_of: datetime resolution_source: str @@ -79,9 +83,139 @@ class QuoteTerms: tenant_terms_text: str +@dataclass(frozen=True) +class QuotePresentation: + seller_name: str + seller_address: str + branding_ref: str + customer_display_name: str + customer_account_id: str + + +@dataclass(frozen=True) +class QuoteProvenance: + gateway_build_sha: str + renderer_schema_version: str + guard_schema_version: str + hosted_agent_version: str + catalog_version: str + price_source_versions: tuple[str, ...] + inventory_source_versions: tuple[str, ...] + + +@dataclass(frozen=True) +class DeliveryPreflight: + channel: str + contact_id: str + contact_version: str + destination_fingerprint: str + masked_destination: str + available: bool + + @dataclass(frozen=True) class QuoteDocument: header: QuoteHeader lines: tuple[QuoteLine, ...] totals: QuoteTotals terms: QuoteTerms + presentation: QuotePresentation + provenance: QuoteProvenance + delivery_preflight: DeliveryPreflight + + def __post_init__(self) -> None: + _validate_document(self) + + +_CENT = Decimal('0.01') +_UOMS = frozenset({'each'}) +_AVAILABILITY = frozenset({'available', 'backorder', 'unavailable'}) +_TAX = frozenset({'not_included'}) +_FREIGHT = frozenset({'quoted_separately'}) +_TOTAL = frozenset({'subtotal_only_tax_and_freight_excluded'}) + + +def _aware(value: datetime, field: str) -> None: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f'{field} must be timezone-aware') + + +def _money(value: Decimal, field: str) -> None: + if isinstance(value, float) or not isinstance(value, Decimal): + raise TypeError(f'{field} must be Decimal, never float') + if value != value.quantize(_CENT): + raise ValueError(f'{field} must be cents-quantized') + + +def _validate_document(document: QuoteDocument) -> None: + header = document.header + provenance = document.provenance + preflight = document.delivery_preflight + if (not preflight.available or preflight.channel not in ('email', 'sms') + or not all((preflight.contact_id, preflight.contact_version, + preflight.destination_fingerprint, + preflight.masked_destination))): + raise ValueError('a verified delivery preflight is required') + if not all((provenance.gateway_build_sha, + provenance.renderer_schema_version, + provenance.guard_schema_version, + provenance.hosted_agent_version, + provenance.catalog_version)): + raise ValueError('complete build/source provenance is required') + if provenance.catalog_version != header.catalog_version: + raise ValueError('provenance catalog version must match header') + if not provenance.price_source_versions or not provenance.inventory_source_versions: + raise ValueError('price and inventory source versions are required') + if document.presentation is None: + raise ValueError('frozen quote presentation is required') + if document.presentation.customer_account_id != header.account_id: + raise ValueError('presentation customer account must match header') + if not all((document.presentation.seller_name, + document.presentation.branding_ref, + document.presentation.customer_display_name)): + raise ValueError('visible seller/customer presentation fields are required') + if not header.tenant_id or not re.fullmatch( + rf'Q-{re.escape(header.tenant_id)}-\d{{6}}', header.quote_number): + raise ValueError('quote number must be canonical for its tenant') + if header.revision < 1 or header.currency != 'USD': + raise ValueError('revision must be positive and currency must be USD') + _aware(header.created_at, 'created_at') + _aware(header.valid_until, 'valid_until') + if header.valid_until <= header.created_at: + raise ValueError('valid_until must be after created_at') + if not document.lines: + raise ValueError('quote must contain at least one line') + expected_numbers = list(range(1, len(document.lines) + 1)) + if [line.line_no for line in document.lines] != expected_numbers: + raise ValueError('quote line numbers must be contiguous and ordered') + if len({line.sku for line in document.lines}) != len(document.lines): + raise ValueError('quote line SKUs must be unique') + subtotal = Decimal('0.00') + for line in document.lines: + if line.tenant_id != header.tenant_id or line.account_id != header.account_id: + raise ValueError('line tenant/account must match quote header') + if isinstance(line.requested_qty, bool) or line.requested_qty < 1: + raise ValueError('requested quantity must be a positive integer') + if line.uom not in _UOMS: + raise ValueError(f'unsupported canonical UOM: {line.uom}') + if line.availability_status not in _AVAILABILITY: + raise ValueError('unknown availability status') + _aware(line.unit_price_as_of, 'unit_price_as_of') + _aware(line.ship_date_as_of, 'ship_date_as_of') + if line.ship_date is not None: + _aware(line.ship_date, 'ship_date') + _money(line.unit_price, 'unit_price') + _money(line.extended_price, 'extended_price') + expected = (line.unit_price * line.requested_qty).quantize(_CENT) + if line.extended_price != expected: + raise ValueError('extended price does not equal unit price times quantity') + subtotal += line.extended_price + _money(document.totals.subtotal, 'subtotal') + if document.totals.subtotal != subtotal.quantize(_CENT): + raise ValueError('subtotal does not equal line extensions') + if document.totals.tax_status not in _TAX: + raise ValueError('unknown tax status') + if document.totals.freight_status not in _FREIGHT: + raise ValueError('unknown freight status') + if document.totals.total_status not in _TOTAL: + raise ValueError('unknown total status') diff --git a/src/quoting/projection.py b/src/quoting/projection.py new file mode 100644 index 0000000..55db268 --- /dev/null +++ b/src/quoting/projection.py @@ -0,0 +1,41 @@ +"""Explicit allowlist for customer-visible quote data.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + +from quoting.models import QuoteDocument + + +@dataclass(frozen=True) +class CustomerQuoteProjection: + header: dict[str, Any] + presentation: dict[str, Any] + lines: tuple[dict[str, Any], ...] + totals: dict[str, Any] + terms: dict[str, Any] + + +def customer_projection(document: QuoteDocument) -> CustomerQuoteProjection: + """Project approved fields; audit, source, score, and contact data stay out.""" + return CustomerQuoteProjection( + header={ + 'quote_number': document.header.quote_number, + 'revision': document.header.revision, + 'currency': document.header.currency, + 'created_at': document.header.created_at.isoformat(), + 'valid_until': document.header.valid_until.isoformat(), + }, + presentation=asdict(document.presentation), + lines=tuple({ + 'line_no': line.line_no, 'sku': line.sku, + 'catalog_description': line.catalog_description, + 'requested_qty': line.requested_qty, 'uom': line.uom, + 'unit_price': str(line.unit_price), + 'extended_price': str(line.extended_price), + 'availability_status': line.availability_status, + 'ship_date': line.ship_date.isoformat() if line.ship_date else None, + } for line in document.lines), + totals={key: str(value) if key == 'subtotal' else value + for key, value in asdict(document.totals).items()}, + terms=asdict(document.terms)) diff --git a/src/quoting/render_pdf.py b/src/quoting/render_pdf.py index 9807e99..43c3f74 100644 --- a/src/quoting/render_pdf.py +++ b/src/quoting/render_pdf.py @@ -50,8 +50,12 @@ def render_pdf(document: QuoteDocument) -> bytes: styles = getSampleStyleSheet() h = document.header story = [ + Paragraph(document.presentation.seller_name, styles['Heading2']), + Paragraph(document.presentation.seller_address, styles['Normal']), Paragraph(f'Quote {h.quote_number} (Revision {h.revision})', styles['Title']), + Paragraph(f'Customer: {document.presentation.customer_display_name}', + styles['Normal']), Paragraph(f'Account: {h.account_id} ({h.account_tier})', styles['Normal']), Paragraph(f'Created: {h.created_at.isoformat()}', styles['Normal']), diff --git a/src/quoting/render_xlsx.py b/src/quoting/render_xlsx.py index 8ec9cd3..b80a9ec 100644 --- a/src/quoting/render_xlsx.py +++ b/src/quoting/render_xlsx.py @@ -31,6 +31,9 @@ def render_xlsx(document: QuoteDocument) -> bytes: # Named header block (rows 1-7) — one fact per row, label in column A. header_fields = ( + ('Seller', document.presentation.seller_name), + ('Seller Address', document.presentation.seller_address), + ('Customer', document.presentation.customer_display_name), ('Quote Number', document.header.quote_number), ('Revision', document.header.revision), ('Account', document.header.account_id), diff --git a/src/quoting/store.py b/src/quoting/store.py index 0cfcc89..263f983 100644 --- a/src/quoting/store.py +++ b/src/quoting/store.py @@ -24,7 +24,16 @@ from hashlib import sha256 from typing import Protocol -from quoting.models import QuoteDocument, QuoteHeader, QuoteLine, QuoteTerms, QuoteTotals +from quoting.models import ( + DeliveryPreflight, + QuoteDocument, + QuoteHeader, + QuoteLine, + QuotePresentation, + QuoteProvenance, + QuoteTerms, + QuoteTotals, +) @dataclass(frozen=True) @@ -69,6 +78,12 @@ def get(self, quote_number: str, """The latest revision when `revision` is None.""" ... + def get_scoped(self, tenant_id: str, account_id: str, + quote_number: str) -> QuoteDocument | None: ... + + def find_scoped(self, tenant_id: str, account_id: str, *, + suffix: str | None = None) -> tuple[QuoteDocument, ...]: ... + def reserve_revision(self, quote_number: str) -> int: ... def status_of(self, quote_number: str, revision: int) -> str | None: ... @@ -165,6 +180,35 @@ def get(self, quote_number: str, return None return _document_from_dict(json.loads(row['payload_json'])) + def get_scoped(self, tenant_id: str, account_id: str, + quote_number: str) -> QuoteDocument | None: + row = self._conn.execute( + 'SELECT payload_json FROM quotes WHERE tenant_id = ? ' + 'AND quote_number = ? ORDER BY revision DESC LIMIT 1', + (tenant_id, quote_number)).fetchone() + if row is None: + return None + document = _document_from_dict(json.loads(row['payload_json'])) + return document if document.header.account_id == account_id else None + + def find_scoped(self, tenant_id: str, account_id: str, *, + suffix: str | None = None) -> tuple[QuoteDocument, ...]: + rows = self._conn.execute( + 'SELECT payload_json FROM quotes WHERE tenant_id=? ' + 'ORDER BY quote_number, revision DESC', (tenant_id,)).fetchall() + by_number: dict[str, QuoteDocument] = {} + for row in rows: + document = _document_from_dict(json.loads(row['payload_json'])) + number = document.header.quote_number + if number in by_number or document.header.account_id != account_id: + continue + if suffix is not None and not number.endswith(f'-{suffix}'): + continue + by_number[number] = document + return tuple(sorted( + by_number.values(), key=lambda doc: doc.header.created_at, + reverse=True)) + def reserve_revision(self, quote_number: str) -> int: """Atomically mint the next revision and supersede the active prior row.""" self._conn.execute('BEGIN IMMEDIATE') @@ -261,10 +305,11 @@ def _document_from_dict(d: dict) -> QuoteDocument: account_tier=h['account_tier'], created_at=datetime.fromisoformat(h['created_at']), valid_until=datetime.fromisoformat(h['valid_until']), - prepared_by=h['prepared_by'], catalog_version=h['catalog_version']) + prepared_by=h['prepared_by'], catalog_version=h['catalog_version'], + currency=h.get('currency', 'USD')) lines = tuple( QuoteLine( - line_no=ln['line_no'], sku=ln['sku'], + line_no=ln['line_no'], sku=ln['sku'], tenant_id=ln['tenant_id'], catalog_description=ln['catalog_description'], requested_qty=ln['requested_qty'], uom=ln['uom'], unit_price=Decimal(ln['unit_price']), @@ -272,6 +317,7 @@ def _document_from_dict(d: dict) -> QuoteDocument: account_id=ln['account_id'], extended_price=Decimal(ln['extended_price']), availability_note=ln['availability_note'], + availability_status=ln['availability_status'], ship_date=(datetime.fromisoformat(ln['ship_date']) if ln['ship_date'] else None), ship_date_as_of=datetime.fromisoformat(ln['ship_date_as_of']), @@ -286,4 +332,14 @@ def _document_from_dict(d: dict) -> QuoteDocument: terms = QuoteTerms(validity_window_days=terms_d['validity_window_days'], pricing_disclaimer=terms_d['pricing_disclaimer'], tenant_terms_text=terms_d['tenant_terms_text']) - return QuoteDocument(header=header, lines=lines, totals=totals, terms=terms) + presentation = QuotePresentation(**d['presentation']) + provenance_d = d['provenance'] + provenance = QuoteProvenance( + **{**provenance_d, + 'price_source_versions': tuple(provenance_d['price_source_versions']), + 'inventory_source_versions': tuple( + provenance_d['inventory_source_versions'])}) + delivery_preflight = DeliveryPreflight(**d['delivery_preflight']) + return QuoteDocument(header=header, lines=lines, totals=totals, terms=terms, + presentation=presentation, provenance=provenance, + delivery_preflight=delivery_preflight) diff --git a/src/runtime/config.py b/src/runtime/config.py index b0822bd..8317d16 100644 --- a/src/runtime/config.py +++ b/src/runtime/config.py @@ -19,6 +19,7 @@ import json import os +import subprocess from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo @@ -128,6 +129,10 @@ def build_gateway(): quote_store=quote_store, account_tier_of=lambda aid: 'preferred', now_fn=_now_dt, intent_router=intent_router) + gateway.gateway_build_sha = os.environ.get( + 'SKU_GATEWAY_BUILD_SHA') or _repository_build_sha() + gateway.hosted_agent_version = os.environ.get( + 'SKU_HOSTED_AGENT_VERSION', 'local-unconfigured') from gateway.conversation_store import ConversationStore conversation_db = os.environ.get('SKU_CONVERSATION_STORE_DB', ':memory:') gateway.conversation_store = ConversationStore(conversation_db) @@ -139,6 +144,15 @@ def build_gateway(): return gateway, sessions +def _repository_build_sha() -> str: + try: + return subprocess.run( + ['git', 'rev-parse', 'HEAD'], cwd=REPO, check=True, + capture_output=True, text=True).stdout.strip() + except Exception: + return 'source-tree-unavailable' + + def using_dev_secret() -> bool: return not os.environ.get('SKU_SESSION_SECRET') diff --git a/tests/gateway_fixtures.py b/tests/gateway_fixtures.py index fe821a0..417a8c3 100644 --- a/tests/gateway_fixtures.py +++ b/tests/gateway_fixtures.py @@ -24,8 +24,12 @@ NY = ZoneInfo('America/New_York') ACCOUNTS = [ - Account('1001', 'DEMO TRUCK CENTER', '5550100100', 'quotes1@example.test'), - Account('2055', 'NORTH FLEET TRUCK', '5550100200', 'quotes2@example.test'), + Account('1001', 'DEMO TRUCK CENTER', '+15550100100', + 'quotes1@example.test', contact_id='contact-1001', + contact_version='v1', phone_verified=True, email_verified=True), + Account('2055', 'NORTH FLEET TRUCK', '+15550100200', + 'quotes2@example.test', contact_id='contact-2055', + contact_version='v1', phone_verified=True, email_verified=True), Account('3300', 'TRUCK PARTS COMPANY', None), Account('3301', 'TRUCK PARTS WEST', None), # name-collision with 3300 ] @@ -45,16 +49,16 @@ def _shared_catalog(): def build_gateway(tmp_path, *, now=None, clock_start=0.0): cat, ver = _shared_catalog() + received = now or datetime(2026, 6, 8, 10, 0, tzinfo=NY) # Mon 10am svc = ResolutionService(cat, InMemoryStore(), catalog_version=ver) inv = load_inventory(INVENTORY) db = InMemoryCustomerDB(ACCOUNTS) - pb = SyntheticPriceBook.seeded(cat.all_skus()) + pb = SyntheticPriceBook.seeded(cat.all_skus(), as_of=received) clk = {'t': clock_start} sessions = SessionManager(secret=b'gateway-test-secret', customer_db=db, now_fn=lambda: clk['t']) journal = ConversationJournal(path=Path(tmp_path) / 'journal.jsonl', now_fn=lambda: '2026-06-08T10:00:00') - received = now or datetime(2026, 6, 8, 10, 0, tzinfo=NY) # Mon 10am quote_store = SqliteQuoteStore(':memory:') gw = Gateway(service=svc, catalog=cat, inventory=inv, catalog_version=ver, sessions=sessions, journal=journal, pricebook=pb, diff --git a/tests/test_customer_quote_projection.py b/tests/test_customer_quote_projection.py new file mode 100644 index 0000000..848728b --- /dev/null +++ b/tests/test_customer_quote_projection.py @@ -0,0 +1,41 @@ +import io +import json + +from openpyxl import load_workbook +from pypdf import PdfReader +from test_quote_validation import valid_document + +from quoting.projection import customer_projection +from quoting.render_pdf import render_pdf +from quoting.render_xlsx import render_xlsx + + +def test_customer_projection_is_allowlisted_and_excludes_internal_lineage(): + projected = customer_projection(valid_document()) + encoded = json.dumps(projected.__dict__, default=str) + for forbidden in ( + 'resolution_source', 'resolution_confidence', 'quantity_on_hand', + 'source_version', 'source_as_of', 'contact', 'journal', 'event_id', + ): + assert forbidden not in encoded + assert projected.lines[0]['requested_qty'] == 2 + assert projected.header['currency'] == 'USD' + assert projected.presentation['customer_display_name'] == 'Customer' + + +def test_pdf_and_xlsx_exclude_forbidden_internal_and_contact_fields(): + document = valid_document() + pdf_text = '\n'.join( + page.extract_text() or '' + for page in PdfReader(io.BytesIO(render_pdf(document))).pages) + workbook = load_workbook(io.BytesIO(render_xlsx(document)), data_only=True) + xlsx_text = ' '.join( + str(cell.value) for row in workbook.active.iter_rows() for cell in row + if cell.value is not None) + for rendered in (pdf_text, xlsx_text): + for forbidden in ( + 'resolution_source', 'translator', 'resolution_confidence', + 'quantity_on_hand', 'fingerprint', 'contact-1', + 'c***@example.test', 'price-v1', 'inventory-v1', + ): + assert forbidden not in rendered diff --git a/tests/test_db_adapters.py b/tests/test_db_adapters.py index 34666e7..faddd94 100644 --- a/tests/test_db_adapters.py +++ b/tests/test_db_adapters.py @@ -73,9 +73,9 @@ def test_gateway_verifies_and_prices_against_sqlite(tmp_path): # Swap in sqlite-backed customer db + price book, same data. cdb = SqliteCustomerDB.build(tmp_path / 'cust.db', ACCOUNTS) - syn = SyntheticPriceBook.seeded(gw.catalog.all_skus()) + syn = SyntheticPriceBook.seeded(gw.catalog.all_skus(), as_of=gw.now_fn()) pbook = SqlitePriceBook.build(tmp_path / 'price.db', syn.base_by_sku, - syn.tier_multiplier) + syn.tier_multiplier, as_of=gw.now_fn()) sessions._customer_db = cdb # session verification source gw.pricebook = pbook # gateway pricing source @@ -88,3 +88,19 @@ def test_gateway_verifies_and_prices_against_sqlite(tmp_path): assert p.price.source == 'verified_account_self' # the price matches what the sqlite book returns for this account's tier assert p.price.unit_price == pbook.price('K5-24SBC', 'preferred') + + +def test_legacy_customer_contact_is_preserved_but_not_guessed_verified(tmp_path): + import sqlite3 + path = tmp_path / 'legacy-customers.db' + db = sqlite3.connect(path) + db.execute('CREATE TABLE accounts(account_id TEXT PRIMARY KEY, name TEXT, phone TEXT)') + db.execute("INSERT INTO accounts VALUES('1001','Legacy Customer','5550100100')") + db.commit() + db.close() + customers = SqliteCustomerDB(path) + account = customers.by_number('1001') + assert account.phone == '5550100100' + assert account.email is None and account.tenant_id == '' + assert account.phone_verified is False and account.email_verified is False + assert account.contact_id == '' and account.contact_version == '' diff --git a/tests/test_delivery_preflight.py b/tests/test_delivery_preflight.py new file mode 100644 index 0000000..ab49b32 --- /dev/null +++ b/tests/test_delivery_preflight.py @@ -0,0 +1,70 @@ +import dataclasses + +import pytest +from gateway_fixtures import build_gateway + +from gateway import Account + + +def _verify(gateway, sessions, caller, account): + token = sessions.open(caller, caller) + response = gateway.converse( + caller, token, f'my account number is {account}') + assert response.refused is None + return token + + +def test_missing_verified_contact_blocks_before_readback_and_quote_promise(tmp_path): + gateway, sessions, _, _ = build_gateway(tmp_path) + token = _verify(gateway, sessions, 'no-contact', '3300') + gateway.converse('no-contact', token, '10 of the K5-24SBC') + response = gateway.converse('no-contact', token, 'place the order') + assert response.refused == 'contact_missing' + assert 'readback:' not in response.text.lower() + assert gateway.quote_store._conn.execute( + 'SELECT COUNT(*) FROM quotes').fetchone()[0] == 0 + assert response.order_disclosure.delivery_ready is False + + +def test_contact_change_after_readback_invalidates_assent(tmp_path): + gateway, sessions, _, _ = build_gateway(tmp_path) + caller = 'changed-contact' + token = _verify(gateway, sessions, caller, '1001') + gateway.converse(caller, token, '10 of the K5-24SBC') + readback = gateway.converse(caller, token, 'place the order') + assert readback.order_disclosure.delivery_ready + db = sessions.customer_db + original = db.by_number('1001') + changed = dataclasses.replace( + original, email='new@example.test', contact_version='v2') + db._by_no['1001'] = changed + db._accounts = [changed if account.account_id == '1001' else account + for account in db._accounts] + response = gateway.converse(caller, token, 'confirm the quote') + assert response.refused == 'contact_changed' + assert 'quote_number' not in response.meta + assert gateway.quote_store._conn.execute( + 'SELECT COUNT(*) FROM quotes').fetchone()[0] == 0 + + +def test_frozen_preflight_contains_fingerprint_not_destination(tmp_path): + gateway, sessions, _, _ = build_gateway(tmp_path) + caller = 'frozen-contact' + token = _verify(gateway, sessions, caller, '1001') + gateway.converse(caller, token, '10 of the K5-24SBC') + gateway.converse(caller, token, 'place the order') + issued = gateway.converse(caller, token, 'confirm the quote') + document = gateway.quote_store.get(issued.meta['quote_number']) + assert document.delivery_preflight.channel == 'email' + assert document.delivery_preflight.contact_id == 'contact-1001' + assert len(document.delivery_preflight.destination_fingerprint) == 64 + assert document.delivery_preflight.masked_destination == 'q***@example.test' + assert 'quotes1@example.test' not in repr(document.delivery_preflight) + + +def test_verified_phone_must_be_normalized_e164(): + with pytest.raises(ValueError, match='E.164'): + Account('1', 'Customer', phone='5550100100', phone_verified=True) + account = Account('1', 'Customer', phone='+15550100100', + phone_verified=True) + assert account.phone == '+15550100100' diff --git a/tests/test_m5_baseline_defects.py b/tests/test_m5_baseline_defects.py index e40476a..f7e1afd 100644 --- a/tests/test_m5_baseline_defects.py +++ b/tests/test_m5_baseline_defects.py @@ -106,7 +106,7 @@ def test_short_order_requires_readback_then_distinct_assent(tmp_path): assert issued.meta.get("quote_number") and after_assent == 1 -def test_defect_quote_availability_uses_default_quantity_one(tmp_path): +def test_quote_availability_uses_requested_quantity(tmp_path): from gateway.answers import availability gateway, _, _, caller, token = _open(tmp_path, "qty-availability") @@ -137,11 +137,8 @@ def test_defect_quote_availability_uses_default_quantity_one(tmp_path): issued = gateway.converse(caller, token, "confirm the order") document = gateway.quote_store.get(issued.meta["quote_number"]) assert document is not None - assert document.lines[0].ship_date.isoformat() == default.ship_by_iso - assert document.lines[0].ship_date.isoformat() != correct.ship_by_iso, ( - "fulfillment_called_with_quantity_one" - ) - _observed("fulfillment_called_with_quantity_one") + assert document.lines[0].ship_date.isoformat() == correct.ship_by_iso + assert document.lines[0].ship_date.isoformat() != default.ship_by_iso def test_agent_turn_priced_order_line_carries_structural_provenance(tmp_path, monkeypatch): @@ -225,20 +222,21 @@ def test_defect_document_guard_accepts_cross_line_quantity_swap(tmp_path): issued = gateway.converse(caller, token, "confirm the order") document = gateway.quote_store.get(issued.meta["quote_number"]) assert document is not None and len(document.lines) == 2 - bad = dataclasses.replace( - document, - lines=( - dataclasses.replace(document.lines[0], requested_qty=2), - dataclasses.replace(document.lines[1], requested_qty=10), - ), - ) + bad = object.__new__(type(document)) + for field in dataclasses.fields(document): + object.__setattr__(bad, field.name, getattr(document, field.name)) + object.__setattr__(bad, 'lines', ( + dataclasses.replace(document.lines[0], requested_qty=2), + dataclasses.replace(document.lines[1], requested_qty=10))) verify_rendered_document( document, pdf_bytes=render_pdf(bad), xlsx_bytes=render_xlsx(bad) ) - obvious = dataclasses.replace( - document, - lines=(dataclasses.replace(document.lines[0], requested_qty=999), document.lines[1]), - ) + obvious = object.__new__(type(document)) + for field in dataclasses.fields(document): + object.__setattr__(obvious, field.name, getattr(document, field.name)) + object.__setattr__(obvious, 'lines', ( + dataclasses.replace(document.lines[0], requested_qty=999), + document.lines[1])) with pytest.raises(DocumentParityViolation): verify_rendered_document( document, pdf_bytes=render_pdf(obvious), xlsx_bytes=render_xlsx(obvious) @@ -246,7 +244,7 @@ def test_defect_document_guard_accepts_cross_line_quantity_swap(tmp_path): _observed("flat_fact_set_loses_line_association") -def test_defect_revision_lookup_ignores_tenant_when_account_ids_collide(tmp_path): +def test_revision_lookup_is_tenant_and_account_scoped_with_neutral_denial(tmp_path): gateway, sessions, _, owner, owner_token, issued = _issue_one( tmp_path, "cross-tenant-source" ) @@ -258,6 +256,8 @@ def test_defect_revision_lookup_ignores_tenant_when_account_ids_collide(tmp_path header=dataclasses.replace( original.header, tenant_id="tenant_002", quote_number=foreign_number ), + lines=tuple(dataclasses.replace(line, tenant_id="tenant_002") + for line in original.lines), ) gateway.quote_store.save(foreign) @@ -267,16 +267,15 @@ def test_defect_revision_lookup_ignores_tenant_when_account_ids_collide(tmp_path opened = gateway.converse( attacker, attacker_token, f"revise quote {foreign_number}" ) - assert opened.refused is None and opened.meta["quote_number"] == foreign_number, ( - "cross_tenant_revision_opened" - ) + assert opened.refused == "revision_not_available" + assert "quote_number" not in opened.meta other = "other-account" other_token = sessions.open(other, other) _verify(gateway, other, other_token, "2055") refused = gateway.converse(other, other_token, f"revise quote {foreign_number}") - assert refused.refused == "revision_unauthorized" - _observed("cross_tenant_revision_opened") + assert refused.refused == "revision_not_available" + assert refused.text == opened.text def test_defect_latency_harness_rejects_chunked_readback(): diff --git a/tests/test_order_integration.py b/tests/test_order_integration.py index 8968045..e00b7ac 100644 --- a/tests/test_order_integration.py +++ b/tests/test_order_integration.py @@ -260,12 +260,14 @@ def test_order_response_has_typed_delivery_and_next_action_envelope(tmp_path): gw, _, _, caller, tok = _gw(tmp_path) gw.converse(caller, tok, 'my account number is 1001') response = gw.converse(caller, tok, '10 of the K5-24SBC') + assert response.order_disclosure is not None + response = gw.converse(caller, tok, 'place the order') envelope = response.order_disclosure assert envelope is not None assert [(line.sku, line.requested_qty) for line in envelope.lines] == [ ('K5-24SBC', 10)] - assert envelope.readback_phase == 'not_started' - assert envelope.expected_next_action == 'add_or_prepare_quote' + assert envelope.readback_phase == 'in_progress' + assert envelope.expected_next_action == 'continue_readback' assert envelope.delivery_channel == 'email' assert envelope.masked_destination == 'q***@example.test' assert envelope.delivery_ready and not envelope.may_close diff --git a/tests/test_quote_renderers_golden.py b/tests/test_quote_renderers_golden.py index 4a9a8a5..b2d19e3 100644 --- a/tests/test_quote_renderers_golden.py +++ b/tests/test_quote_renderers_golden.py @@ -23,7 +23,15 @@ extract_xlsx_facts, verify_rendered_document, ) -from quoting.models import QuoteDocument, QuoteHeader, QuoteLine, QuoteTerms +from quoting.models import ( + DeliveryPreflight, + QuoteDocument, + QuoteHeader, + QuoteLine, + QuotePresentation, + QuoteProvenance, + QuoteTerms, +) from quoting.render_pdf import render_pdf from quoting.render_xlsx import render_xlsx from quoting.totals import build_totals, extended_price @@ -33,19 +41,21 @@ def _fixture_document() -> QuoteDocument: line1 = QuoteLine( - line_no=1, sku='K5-24SBC', catalog_description='chrome stack', + line_no=1, sku='K5-24SBC', tenant_id='tenant_001', + catalog_description='chrome stack', requested_qty=10, uom='each', unit_price=Decimal('24.99'), unit_price_as_of=NOW, account_id='1001', extended_price=extended_price(Decimal('24.99'), 10), - availability_note='in stock, ships tomorrow', + availability_note='in stock, ships tomorrow', availability_status='available', ship_date=NOW + timedelta(days=1), ship_date_as_of=NOW, resolution_source='translator:parser', resolution_confidence='high') line2 = QuoteLine( - line_no=2, sku='BH6-36SBC', catalog_description='bullhorn stack', + line_no=2, sku='BH6-36SBC', tenant_id='tenant_001', + catalog_description='bullhorn stack', requested_qty=2, uom='each', unit_price=Decimal('149.69'), unit_price_as_of=NOW, account_id='1001', extended_price=extended_price(Decimal('149.69'), 2), - availability_note='in stock, ships in 2 days', + availability_note='in stock, ships in 2 days', availability_status='available', ship_date=NOW + timedelta(days=2), ship_date_as_of=NOW, resolution_source='translator:parser', resolution_confidence='high') header = QuoteHeader( @@ -57,7 +67,16 @@ def _fixture_document() -> QuoteDocument: pricing_disclaimer='Prices subject to change.', tenant_terms_text='Net 30.') return QuoteDocument(header=header, lines=(line1, line2), - totals=build_totals((line1, line2)), terms=terms) + totals=build_totals((line1, line2)), terms=terms, + presentation=QuotePresentation( + 'Seller', '1 Main St', 'default', + 'Demo Truck Center', '1001'), + provenance=QuoteProvenance( + 'build', 'renderer-v1', 'guard-v1', 'agent-v1', + 'deadbeef', ('price-v1',), ('inventory-v1',)), + delivery_preflight=DeliveryPreflight( + 'email', 'c1', 'v1', 'fingerprint', + 'q***@example.test', True)) # -- byte determinism (PDF) -------------------------------------------------- @@ -111,14 +130,24 @@ def test_verify_rendered_document_passes_on_untampered_output(): xlsx_bytes=render_xlsx(doc)) # no raise +def _unsafe_document(document, lines): + """Represent post-construction corruption for document-guard tests.""" + value = object.__new__(QuoteDocument) + for name in ('header', 'totals', 'terms', 'presentation', 'provenance', + 'delivery_preflight'): + object.__setattr__(value, name, getattr(document, name)) + object.__setattr__(value, 'lines', lines) + return value + + # -- the parity gate, adversarially: prove it catches every planted fault --- def test_guard_catches_an_altered_unit_price(): doc = _fixture_document() pdf_bytes, xlsx_bytes = render_pdf(doc), render_xlsx(doc) - tampered = dataclasses.replace( - doc, lines=(dataclasses.replace(doc.lines[0], unit_price=Decimal('1.00')), - doc.lines[1])) + tampered = _unsafe_document( + doc, (dataclasses.replace(doc.lines[0], unit_price=Decimal('1.00')), + doc.lines[1])) with pytest.raises(DocumentParityViolation): assert_document_parity(tampered, extract_pdf_facts(pdf_bytes)) with pytest.raises(DocumentParityViolation): @@ -135,7 +164,7 @@ def test_guard_catches_a_dropped_line(): extra_line = dataclasses.replace( doc.lines[0], line_no=3, sku='ZZ9-EXTRA', unit_price=Decimal('1.23'), extended_price=Decimal('12.30')) - tampered = dataclasses.replace(doc, lines=doc.lines + (extra_line,)) + tampered = _unsafe_document(doc, doc.lines + (extra_line,)) with pytest.raises(DocumentParityViolation) as exc: assert_document_parity(tampered, extract_pdf_facts(pdf_bytes)) assert 'ZZ9-EXTRA' in str(exc.value) diff --git a/tests/test_quote_revisions.py b/tests/test_quote_revisions.py index f4bc0e6..2ec35bd 100644 --- a/tests/test_quote_revisions.py +++ b/tests/test_quote_revisions.py @@ -36,12 +36,12 @@ def test_cross_account_revision_refuses_before_any_store_write(tmp_path): response = gateway.converse( 'quote-attacker', attacker_token, f'revise quote {quote_number}') - assert response.refused == 'revision_unauthorized' + assert response.refused == 'revision_not_available' assert gateway.quote_store._conn.total_changes == before_changes assert gateway.quote_store.get(quote_number).header.revision == 1 refused = [ row for row in journal.events(EventType.REFUSAL) - if row.get('reason') == 'revision_unauthorized' + if row.get('reason') == 'revision_not_available' ] assert len(refused) == 1 assert refused[0]['quote_number'] == quote_number diff --git a/tests/test_quote_store.py b/tests/test_quote_store.py index db3765d..b464081 100644 --- a/tests/test_quote_store.py +++ b/tests/test_quote_store.py @@ -6,9 +6,12 @@ from decimal import Decimal from quoting.models import ( + DeliveryPreflight, QuoteDocument, QuoteHeader, QuoteLine, + QuotePresentation, + QuoteProvenance, QuoteTerms, ) from quoting.store import SqliteQuoteStore, order_content_hash @@ -17,14 +20,16 @@ NOW = datetime(2026, 7, 10, 10, 0, tzinfo=timezone.utc) -def _document(quote_number, revision, tenant_id='tenant_001', +def _document(quote_number, revision, tenant_id=None, sku='K5-24SBC', qty=10) -> QuoteDocument: + tenant_id = tenant_id or quote_number.removeprefix('Q-').rsplit('-', 1)[0] line = QuoteLine( - line_no=1, sku=sku, catalog_description='chrome stack', + line_no=1, sku=sku, tenant_id=tenant_id, catalog_description='chrome stack', requested_qty=qty, uom='each', unit_price=Decimal('24.99'), unit_price_as_of=NOW, account_id='1001', extended_price=extended_price(Decimal('24.99'), qty), - availability_note='in stock', ship_date=None, ship_date_as_of=NOW, + availability_note='in stock', availability_status='available', + ship_date=None, ship_date_as_of=NOW, resolution_source='translator:parser', resolution_confidence='high') header = QuoteHeader( tenant_id=tenant_id, quote_number=quote_number, revision=revision, @@ -34,7 +39,15 @@ def _document(quote_number, revision, tenant_id='tenant_001', terms = QuoteTerms(validity_window_days=30, pricing_disclaimer='prices subject to change', tenant_terms_text='net 30') return QuoteDocument(header=header, lines=(line,), - totals=build_totals((line,)), terms=terms) + totals=build_totals((line,)), terms=terms, + presentation=QuotePresentation( + 'Seller', '', 'default', 'Customer', '1001'), + provenance=QuoteProvenance( + 'build', 'renderer-v1', 'guard-v1', 'agent-v1', + 'deadbeef', ('price-v1',), ('inventory-v1',)), + delivery_preflight=DeliveryPreflight( + 'email', 'c1', 'v1', 'fingerprint', + 'c***@example.test', True)) def test_reserve_mints_a_dense_per_tenant_sequence(): diff --git a/tests/test_quote_validation.py b/tests/test_quote_validation.py new file mode 100644 index 0000000..a30c41b --- /dev/null +++ b/tests/test_quote_validation.py @@ -0,0 +1,127 @@ +import dataclasses +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pytest + +from quoting.models import ( + DeliveryPreflight, + QuoteDocument, + QuoteHeader, + QuoteLine, + QuotePresentation, + QuoteProvenance, + QuoteTerms, + QuoteTotals, +) +from quoting.render_xlsx import render_xlsx +from quoting.store import SqliteQuoteStore +from quoting.totals import build_totals, extended_price + +NOW = datetime(2026, 7, 11, tzinfo=timezone.utc) + + +def valid_document() -> QuoteDocument: + line = QuoteLine( + line_no=1, sku='K5', tenant_id='tenant_001', + catalog_description='part', requested_qty=2, uom='each', + unit_price=Decimal('1.25'), unit_price_as_of=NOW, + account_id='1001', extended_price=Decimal('2.50'), + availability_note='available', availability_status='available', + ship_date=NOW + timedelta(days=1), ship_date_as_of=NOW, + resolution_source='translator', resolution_confidence='high') + return QuoteDocument( + header=QuoteHeader( + tenant_id='tenant_001', quote_number='Q-tenant_001-000001', + revision=1, account_id='1001', account_tier='preferred', + created_at=NOW, valid_until=NOW + timedelta(days=30), + prepared_by='build-sha', catalog_version='catalog-sha'), + lines=(line,), totals=QuoteTotals( + Decimal('2.50'), 'not_included', 'quoted_separately', + 'subtotal_only_tax_and_freight_excluded'), + terms=QuoteTerms(30, 'Prices valid for stated window.', 'Net 30.'), + presentation=QuotePresentation( + 'Seller', '1 Main', 'brand-v1', 'Customer', '1001'), + provenance=QuoteProvenance( + 'build-sha', 'renderer-v1', 'guard-v1', 'agent-v1', + 'catalog-sha', ('price-v1',), ('inventory-v1',)), + delivery_preflight=DeliveryPreflight( + 'email', 'contact-1', 'v1', 'fingerprint', 'c***@example.test', True)) + + +def replace(document, *, header=None, line=None, totals=None, presentation=...): + return dataclasses.replace( + document, header=header or document.header, + lines=(line or document.lines[0],), totals=totals or document.totals, + presentation=(document.presentation if presentation is ... else presentation)) + + +@pytest.mark.parametrize('mutation,match', [ + (lambda d: replace(d, header=dataclasses.replace( + d.header, quote_number='Q-other-000001')), 'canonical'), + (lambda d: replace(d, header=dataclasses.replace(d.header, currency='EUR')), + 'currency'), + (lambda d: replace(d, header=dataclasses.replace( + d.header, created_at=datetime(2026, 1, 1))), 'timezone-aware'), + (lambda d: replace(d, header=dataclasses.replace( + d.header, valid_until=d.header.created_at)), 'after created'), + (lambda d: replace(d, line=dataclasses.replace( + d.lines[0], tenant_id='other')), 'tenant/account'), + (lambda d: replace(d, line=dataclasses.replace( + d.lines[0], account_id='other')), 'tenant/account'), + (lambda d: replace(d, line=dataclasses.replace( + d.lines[0], requested_qty=0)), 'positive integer'), + (lambda d: replace(d, line=dataclasses.replace(d.lines[0], uom='box')), + 'unsupported canonical UOM'), + (lambda d: replace(d, line=dataclasses.replace( + d.lines[0], availability_status='maybe')), 'availability status'), + (lambda d: replace(d, line=dataclasses.replace( + d.lines[0], extended_price=Decimal('9.99'))), 'extended price'), + (lambda d: replace(d, totals=dataclasses.replace( + d.totals, subtotal=Decimal('9.99'))), 'subtotal'), + (lambda d: replace(d, presentation=None), 'presentation'), +]) +def test_invalid_quote_contract_is_rejected_at_construction(mutation, match): + with pytest.raises((ValueError, TypeError), match=match): + mutation(valid_document()) + + +def test_float_money_is_rejected_at_construction(): + document = valid_document() + with pytest.raises(TypeError, match='Decimal'): + replace(document, line=dataclasses.replace( + document.lines[0], unit_price=1.25)) + + +def test_empty_duplicate_or_noncontiguous_lines_are_rejected(): + document = valid_document() + with pytest.raises(ValueError, match='at least one'): + dataclasses.replace(document, lines=()) + duplicate = dataclasses.replace(document.lines[0], line_no=2) + with pytest.raises(ValueError, match='unique'): + dataclasses.replace(document, lines=(document.lines[0], duplicate)) + gap = dataclasses.replace(document.lines[0], line_no=2, sku='B') + with pytest.raises(ValueError, match='contiguous'): + dataclasses.replace(document, lines=(gap,)) + + +def test_decimal_cents_survive_generated_store_and_xlsx_round_trips(): + for line_count in (1, 2, 5): + for cents in (1, 5, 99, 100, 12345): + for qty in (1, 2, 17, 1000): + base = valid_document() + unit = Decimal(cents) / Decimal(100) + lines = tuple(dataclasses.replace( + base.lines[0], line_no=index, sku=f'SKU-{index}', + requested_qty=qty, unit_price=unit, + extended_price=extended_price(unit, qty)) + for index in range(1, line_count + 1)) + document = dataclasses.replace( + base, lines=lines, totals=build_totals(lines)) + store = SqliteQuoteStore(':memory:') + store.save(document) + restored = store.get(document.header.quote_number) + assert restored == document + assert restored.totals.subtotal == sum( + (line.extended_price for line in lines), Decimal('0.00')) + assert render_xlsx(restored).startswith(b'PK') diff --git a/tests/test_quote_worker.py b/tests/test_quote_worker.py index 1847ef2..d206143 100644 --- a/tests/test_quote_worker.py +++ b/tests/test_quote_worker.py @@ -10,7 +10,15 @@ from gateway import Account, ConversationJournal, EventType from quoting.delivery import SimulatedDeliverer, mint_link, verify_link -from quoting.models import QuoteDocument, QuoteHeader, QuoteLine, QuoteTerms +from quoting.models import ( + DeliveryPreflight, + QuoteDocument, + QuoteHeader, + QuoteLine, + QuotePresentation, + QuoteProvenance, + QuoteTerms, +) from quoting.store import SqliteQuoteStore from quoting.totals import build_totals, extended_price from quoting.worker import run_once @@ -21,11 +29,13 @@ def _document() -> QuoteDocument: line = QuoteLine( - line_no=1, sku='K5-24SBC', catalog_description='chrome stack', + line_no=1, sku='K5-24SBC', tenant_id='tenant_001', + catalog_description='chrome stack', requested_qty=10, uom='each', unit_price=Decimal('24.99'), unit_price_as_of=NOW, account_id='1001', extended_price=extended_price(Decimal('24.99'), 10), - availability_note='in stock', ship_date=NOW + timedelta(days=1), + availability_note='in stock', availability_status='available', + ship_date=NOW + timedelta(days=1), ship_date_as_of=NOW, resolution_source='translator:parser', resolution_confidence='high') header = QuoteHeader( @@ -37,7 +47,15 @@ def _document() -> QuoteDocument: pricing_disclaimer='Prices subject to change.', tenant_terms_text='Net 30.') return QuoteDocument(header=header, lines=(line,), - totals=build_totals((line,)), terms=terms) + totals=build_totals((line,)), terms=terms, + presentation=QuotePresentation( + 'Seller', '', 'default', 'Customer', '1001'), + provenance=QuoteProvenance( + 'build', 'renderer-v1', 'guard-v1', 'agent-v1', + 'deadbeef', ('price-v1',), ('inventory-v1',)), + delivery_preflight=DeliveryPreflight( + 'email', 'c1', 'v1', 'fingerprint', + 'q***@example.test', True)) def _queued(tmp_path): @@ -59,6 +77,16 @@ def _link(document): document.header.valid_until, SECRET) +def _unsafe_document(document, lines): + """Simulate corrupted renderer input without weakening domain validation.""" + value = object.__new__(QuoteDocument) + for name in ('header', 'totals', 'terms', 'presentation', 'provenance', + 'delivery_preflight'): + object.__setattr__(value, name, getattr(document, name)) + object.__setattr__(value, 'lines', lines) + return value + + def test_fail_closed_guard_called_once_blocks_and_never_delivers( tmp_path, monkeypatch): store, document, journal = _queued(tmp_path) @@ -67,7 +95,7 @@ def test_fail_closed_guard_called_once_blocks_and_never_delivers( tampered_line = dataclasses.replace(document.lines[0], unit_price=Decimal('1.00')) - tampered = dataclasses.replace(document, lines=(tampered_line,)) + tampered = _unsafe_document(document, (tampered_line,)) from quoting.document_guard import verify_rendered_document as real_guard from quoting.render_pdf import render_pdf from quoting.render_xlsx import render_xlsx diff --git a/tests/test_quoting_purity.py b/tests/test_quoting_purity.py index ec2a630..7b6b34c 100644 --- a/tests/test_quoting_purity.py +++ b/tests/test_quoting_purity.py @@ -13,7 +13,7 @@ # Whitelist per PURE module. Anything imported outside this table fails the # test — including indirect creep like `sqlite3`, `gateway`, or a model SDK. ALLOWED = { - 'models.py': {'__future__', 'dataclasses', 'datetime', 'decimal'}, + 'models.py': {'__future__', 'dataclasses', 'datetime', 'decimal', 're'}, 'totals.py': {'__future__', 'decimal', 'quoting.models'}, } diff --git a/tests/test_quoting_totals.py b/tests/test_quoting_totals.py index 7a99d04..3d38bd7 100644 --- a/tests/test_quoting_totals.py +++ b/tests/test_quoting_totals.py @@ -19,11 +19,13 @@ def _line(sku, qty, unit_price, line_no=1) -> QuoteLine: return QuoteLine( - line_no=line_no, sku=sku, catalog_description='a part', + line_no=line_no, sku=sku, tenant_id='tenant_001', + catalog_description='a part', requested_qty=qty, uom='each', unit_price=Decimal(unit_price), unit_price_as_of=NOW, account_id='1001', extended_price=extended_price(Decimal(unit_price), qty), - availability_note='in stock', ship_date=None, ship_date_as_of=NOW, + availability_note='in stock', availability_status='available', + ship_date=None, ship_date_as_of=NOW, resolution_source='translator:parser', resolution_confidence='high') diff --git a/tests/test_source_snapshots.py b/tests/test_source_snapshots.py new file mode 100644 index 0000000..05ab3eb --- /dev/null +++ b/tests/test_source_snapshots.py @@ -0,0 +1,77 @@ +import dataclasses +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pytest +from gateway_fixtures import build_gateway + +from gateway.db_adapters import SqlitePriceBook +from gateway.orchestrator import _fresh_source_time +from gateway.order import OrderNotIssuable +from gateway.pricebook import SyntheticPriceBook + +NOW = datetime(2026, 7, 11, 12, 0, tzinfo=timezone.utc) + + +def test_price_snapshot_carries_source_identity_version_account_and_time(): + book = SyntheticPriceBook.seeded(['K5'], as_of=NOW) + snap = book.snapshot('K5', 'preferred', '1001') + assert snap is not None + assert snap.account_id == '1001' and snap.tier == 'preferred' + assert snap.source_id == 'synthetic-pricebook' + assert snap.source_version and snap.as_of == NOW + assert isinstance(snap.unit_price, Decimal) + + +def test_legacy_sqlite_pricebook_without_metadata_is_unreadable(tmp_path): + import sqlite3 + path = tmp_path / 'legacy.db' + db = sqlite3.connect(path) + db.execute('CREATE TABLE prices(sku TEXT PRIMARY KEY, base TEXT)') + db.execute('CREATE TABLE tiers(tier TEXT PRIMARY KEY, multiplier TEXT)') + db.execute("INSERT INTO prices VALUES('K5','10.00')") + db.commit() + db.close() + book = SqlitePriceBook(path) + assert book.price('K5', 'preferred') == Decimal('10.00') + assert book.snapshot('K5', 'preferred', '1001') is None + + +@pytest.mark.parametrize('raw,source', [ + ('', 'erp-price'), + ((NOW - timedelta(days=2)).isoformat(), 'erp-price'), + ((NOW + timedelta(hours=1)).isoformat(), 'erp-price'), +]) +def test_absent_stale_or_future_source_time_cannot_be_frozen(raw, source): + with pytest.raises(OrderNotIssuable): + _fresh_source_time(raw, NOW, source) + + +def test_fresh_source_time_is_preserved_not_replaced_with_gateway_now(): + source_time = NOW - timedelta(hours=2) + assert _fresh_source_time( + source_time.isoformat(), NOW, 'erp-price') == source_time + + +def test_stale_inventory_snapshot_cannot_be_spoken(tmp_path): + gateway, sessions, _, _ = build_gateway(tmp_path, now=NOW) + record = gateway.inventory['K5-24SBC'] + gateway.inventory['K5-24SBC'] = dataclasses.replace( + record, source_id='erp-inventory', as_of=NOW - timedelta(days=2)) + token = sessions.open('stale-inventory', 'stale-inventory') + response = gateway.converse( + 'stale-inventory', token, 'is K5-24SBC in stock?') + assert response.availability is None + assert 'in stock' not in response.text.lower() + + +def test_stale_price_snapshot_cannot_be_spoken(tmp_path): + gateway, sessions, _, _ = build_gateway(tmp_path, now=NOW) + gateway.pricebook.source_id = 'erp-price' + gateway.pricebook.as_of = NOW - timedelta(days=2) + token = sessions.open('stale-price', 'stale-price') + gateway.converse('stale-price', token, 'my account number is 1001') + response = gateway.converse( + 'stale-price', token, 'how much is K5-24SBC?') + assert response.price is None + assert '$' not in response.text diff --git a/tests/test_spoken_quote_reference.py b/tests/test_spoken_quote_reference.py new file mode 100644 index 0000000..872fece --- /dev/null +++ b/tests/test_spoken_quote_reference.py @@ -0,0 +1,56 @@ +import dataclasses + +from gateway_fixtures import build_gateway + + +def _verify(gateway, sessions, caller, account='1001'): + token = sessions.open(caller, caller) + assert gateway.converse( + caller, token, f'my account number is {account}').refused is None + return token + + +def _issue(gateway, caller, token, sku='K5-24SBC'): + gateway.converse(caller, token, f'10 of the {sku}') + gateway.converse(caller, token, 'place the order') + return gateway.converse(caller, token, 'confirm the quote').meta['quote_number'] + + +def test_latest_quote_is_store_resolved_then_discriminatingly_confirmed(tmp_path): + gateway, sessions, _, _ = build_gateway(tmp_path) + token = _verify(gateway, sessions, 'owner') + number = _issue(gateway, 'owner', token) + caller = 'revision-call' + revision_token = _verify(gateway, sessions, caller) + response = gateway.converse(caller, revision_token, 'revise my latest quote') + suffix = number.rsplit('-', 1)[-1] + assert response.needs_confirmation and suffix in response.text + assert 'quote_number' not in response.meta + assert gateway._pending_quote_reference[caller] == number + opened = gateway.converse( + caller, revision_token, f'yes, quote ending {suffix}') + assert opened.meta['quote_number'] == number + assert opened.meta['revision_opened'] is True + + +def test_suffix_cannot_disclose_foreign_tenant_or_account(tmp_path): + gateway, sessions, _, _ = build_gateway(tmp_path) + owner_token = _verify(gateway, sessions, 'owner') + number = _issue(gateway, 'owner', owner_token) + original = gateway.quote_store.get(number) + foreign_number = 'Q-tenant_002-999999' + foreign = dataclasses.replace( + original, + header=dataclasses.replace( + original.header, tenant_id='tenant_002', quote_number=foreign_number), + lines=tuple(dataclasses.replace(line, tenant_id='tenant_002') + for line in original.lines)) + gateway.quote_store.save(foreign) + attacker_token = _verify(gateway, sessions, 'attacker', '2055') + foreign_response = gateway.converse( + 'attacker', attacker_token, 'revise quote ending 999999') + missing_response = gateway.converse( + 'attacker', attacker_token, 'revise quote ending 888888') + assert foreign_response.refused == missing_response.refused == 'revision_not_available' + assert foreign_response.text == missing_response.text + assert 'Q-tenant_002' not in foreign_response.text