Quote-path hardening: ultrareview + user review findings - #49

Merged
corrin merged 5 commits into
mainfrom
xero/quote-hardening
Aug 9, 2026
Merged

Quote-path hardening: ultrareview + user review findings#49
corrin merged 5 commits into
mainfrom
xero/quote-hardening

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #48, fixing every verified correctness finding from the earmarked ultrareview and the user's own review. Full E2E gate green on this branch (39/39, writes live); 1716 unit tests, coverage 88.52%.

Backend

  • Provider quote calls adopt the PO validation pattern (summarize_errors=False + element-level validation_errors): deleting an ACCEPTED quote can no longer read as success and silently drop the local mirror while the quote lives on in Xero.
  • A compensation boundary covers EVERY failure after the remote write (_finalize_created_quote): totals validation, the insert, and the timestamp bump all void the orphan best-effort with the external id carried in the error — a real Xero quote can no longer become untracked, and a retry can no longer duplicate it. The IntegrityError inside it is discriminated by state: a same-xero_id row means the sync mirrored our own quote first and is adopted (linking the job the transform never sets) — never voided; only the job-constraint race voids. The persist happens at the catch site, which the handler-contract gate verified the hard way.
  • Quote deletion unbricked: no validate_company() on the delete path, and the endpoint falls back to the quote row's own company when job.company was cleared.
  • An unsynced company refuses with a readable 400 instead of a 500 (create path).
  • Retained diagnostic PDFs report their path (inspection JSON + the no-text error).
  • One document-endpoint adapter replaces the 7×-copied scaffolding across all six push endpoints — wire-identical by construction (ninja serializes every declared field), exported schema unchanged, and the error_type drift disappears with the copies. The full apps/xero capability split is recorded as post-cutover backlog, per "finish before improving".

Frontend

  • Draft rows derive unit_rev from unit_cost — a filled phantom used to silently never POST.
  • Draft commits skip the send-dedupe — retyping the same value after a failed POST retries.
  • The autosave buffer tracks dirtiness instead of copying the server value at focus (focus in the same tick as a sibling cell's state-updating blur captured a stale render).
  • Quantity edits make the phantom real; typed free-form rows infer adjust (v1 rule restored — material means a stock pick).
  • PATCH echoes merge only their own fields; a failed delete re-inserts only its line — interleaved optimistic edits can no longer be clobbered or resurrected.

Spec

The quote spec now hard-asserts the fresh job's line state before its repair pass (exactly one missing stock binding, nothing else wrong) and all-clear after — a regression in line creation fails the gate instead of being silently healed, while the repair pass keeps exercising the grid.

Declined with evidence in the review threads: the xero-python get_quote_as_pdf claim (the pinned SDK has it; the live spec exercises it) and the compact-match regex (equivalent in power to the existing check).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved quote creation and deletion error reporting, including detailed validation messages.
    • Quote cleanup now continues successfully when company details are unavailable or remote quotes no longer exist.
    • Prevented failed quote synchronizations from leaving incomplete remote records.
    • Improved retained diagnostic PDF handling and error messages.
  • Costing Improvements

    • Draft cost lines now classify correctly and calculate revenue values more reliably.
    • Autosave can retry failed updates and prevents stale edits from overwriting newer changes.
    • Failed deletions restore only the affected cost line.

corrinand others added 5 commits August 9, 2026 20:28
…ked deletes
Provider quote calls adopt the PO pattern (summarize_errors=False +
element-level validation_errors checks) so a rejected status change — e.g.
deleting an ACCEPTED quote — can never read as success. The post-create
tail moves into _finalize_created_quote: EVERY failure after the remote
write now compensates (totals validation, persist, the timestamp bump all
void the orphan best-effort with the external id in the error), and the
IntegrityError is discriminated by state — a same-xero_id row means the
sync mirrored our own quote first and is ADOPTED (linking the job the
transform never sets), never voided; only the job-constraint race voids.
Deletion no longer requires a Xero-valid company (the quote row carries
its own), an unsynced company refuses with a readable 400 instead of a
500, and a retained diagnostic PDF reports its path.
Findings: ultrareview (2) + user review (1, 2, 5) over PR #48.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 7x-copied scaffolding (token check, failure-to-payload mapping with the
status clamp, success-invariant raise + response construction) collapses
into _xero_auth_refusal/_document_failure/_document_success. Wire-identical
by construction — ninja serializes every declared field, so explicit Nones
equal the fields each endpoint used to omit — and the exported schema is
unchanged. The error_type drift (missing only from delete_purchase_order)
disappears with the copies. Full capability split of apps/xero stays a
recorded backlog item, not a pre-cutover change.
Findings: ultrareview sub-cap cleanup + user review (4, partial).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draft rows derive unit_rev from unit_cost like server rows (a filled
phantom used to silently never POST); draft commits skip the send-dedupe so
retyping the same value after a failed POST retries; the autosave buffer
tracks dirtiness instead of copying the server value in at focus (a focus
landing in the same tick as a sibling cell's state-updating blur copied a
stale render's value); quantity edits make the phantom real; typed
free-form rows infer adjust (v1 rule — material means a stock pick); the
PATCH echo merges only its own fields so it cannot clobber an interleaved
optimistic edit; a failed delete re-inserts only its line. The quote spec
now hard-asserts the fresh job's line state before the repair pass and
all-clear after it — a line-creation regression fails instead of being
silently healed.
Findings: ultrareview (3, 5, 6, 8, 9, 10) + user review (3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The handler-contract gate rightly flagged the IntegrityError handler: its
persist lived inside the delegate where the AST cannot see it. The persist
moves to the catch, which is where it belonged anyway. 1716 tests, 88.52%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates Xero quote validation and persistence, retains diagnostic PDFs for failed inspection, and improves costing draft autosave and optimistic state reconciliation. Tests and project metrics were updated for these behaviors.

Changes

Xero quote flow

Layer / File(s)Summary
Shared document response handling
apps/xero/api.py, stubs/xero_python/accounting/__init__.pyi
Shared authentication and document response helpers now serve invoice, quote, and purchase-order endpoints. Quote includes typed validation errors.
Provider validation errors
apps/xero/provider.py, apps/xero/tests/test_provider_documents.py
Quote creation and deletion expose element-level Xero validation messages and disable error summarization.
Quote finalization and cleanup
apps/xero/documents/quote.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py, docs/*
Quote finalization handles collisions, compensation, synchronization, and provider-total failures. Deletion works without a valid company relation. Tests and project metrics reflect the changes.

Quote PDF diagnostics

Layer / File(s)Summary
Retained diagnostic PDFs
apps/accounting/services/quote_pdf.py, apps/accounting/tests/test_quote_pdf.py
Non-matching PDFs are retained and returned through retained_pdf_path. Empty-text errors and command JSON include the retained path.

Costing draft and optimistic updates

Layer / File(s)Summary
Draft autosave and costing fields
frontend/src/features/job/costing/CostLineGrid.tsx, frontend/src/features/job/costing/useAutosaveField.ts, frontend/src/features/job/costing/CostLineGrid.test.tsx
Draft detection, free-form line kinds, derived revenue, and retry behavior now use dirty-field tracking.
Optimistic echo and rollback handling
frontend/src/features/job/costing/useCostLines.ts, frontend/src/features/job/costing/useCostLines.test.ts
Patch responses merge only requested fields, and failed deletes restore only the affected line.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant QuoteEndpoint
participant QuoteProvider
participant Xero
participant QuoteFinalizer
participant LocalDatabase
QuoteEndpoint->>QuoteProvider: create quote
QuoteProvider->>Xero: submit quote with summarize_errors=false
Xero-->>QuoteProvider: quote result and validation_errors
QuoteProvider-->>QuoteFinalizer: DocumentResult
QuoteFinalizer->>LocalDatabase: finalize or compensate local persistence
QuoteFinalizer-->>QuoteEndpoint: success or failure response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 41.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: hardening quote handling based on review findings.
Description check✅ PassedThe description explains the rationale, backend and frontend changes, specification updates, and test results, but omits the template headings and checklist.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xero/quote-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
apps/xero/tests/test_quote_manager.py (3)

208-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the adopted totals.

Adoption overwrites total_excl_tax and total_incl_tax from the provider raw payload (quote.py Lines 287-288), replacing the mirror's zeros. That overwrite is the part a regression would silently drop, because number and job_id would still look correct.

💚 Proposed assertion
 assert adopted.job_id == job.id
assert adopted.number == "QU-RAW-1"
+ assert adopted.total_excl_tax == Decimal("250.00")+ assert adopted.total_incl_tax == Decimal("287.50")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 208 - 243, Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.

272-290: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The test name promises an assertion the body does not make.

test_post_persist_failure_voids_and_names_the_id asserts the void and the empty table. It never checks that the external id appears anywhere. The raised error is RuntimeError("db gone") from the patched bump, which carries no id. Either assert the void argument or rename the test.

💚 Proposed fix
- provider.delete_quote.assert_called_once()+ external_id = provider.create_quote.return_value.external_id+ provider.delete_quote.assert_called_once_with(external_id)
assert Quote.objects.count() == 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 272 - 290, Update
test_post_persist_failure_voids_and_names_the_id to assert that
provider.delete_quote was called with the created quote’s external ID, in
addition to verifying the void operation and empty database; ensure the
assertion specifically validates the ID value rather than only call count.

244-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the safety property this test exists for.

_resolve_persist_collision documents that a row on another job must not be voided, because that would delete another job's document. The test asserts only the raise. Add the negative assertion so a future change that voids first still fails the test.

💚 Proposed assertion
 with pytest.raises(ValueError, match="different job"):
manager.create_document(breakdown=False)
++ provider.delete_quote.assert_not_called()+ assert Quote.objects.get(xero_id=external_id).job_id == other_job.id
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 244 - 270, Extend
test_mirrored_row_on_another_job_raises to retain the created mirrored Quote
and, after manager.create_document raises ValueError, assert that the other
job’s Quote remains present and unvoided. Keep the existing “different job”
exception assertion and verify the safety property enforced by
_resolve_persist_collision.
apps/xero/api.py (1)

573-589: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm the delete path never needs job.company semantics over quote.company.

The fallback selects quote.company when job.company is None. XeroQuoteManager.delete_document does not call validate_company, so the company is only bound for identity and logging. That matches the documented intent. One readability point: the chained conditional on Line 576 packs two decisions into one expression.

♻️ Optional: split the fallback
- quote = Quote.objects.filter(job=job).select_related("company").first()- company = job.company if job.company is not None else quote.company if quote else None+ quote = Quote.objects.filter(job=job).select_related("company").first()+ company = job.company+ if company is None and quote is not None:+ company = quote.company
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/api.py` around lines 573 - 589, Keep the delete path’s company
selection semantics unchanged: prefer job.company, then fall back to
quote.company, and return the existing error when neither exists. For
readability, split the chained conditional around the delete handler’s company
selection into explicit steps while preserving the
XeroQuoteManager(company=company, job=job, ...) behavior.
apps/xero/tests/test_document_api.py (1)

339-339: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document why untracked_update is used on a tracked field.

Job.untracked_update states: "Use only for migrations and bookkeeping fields that are in UNTRACKED_FIELDS." company is not in Job.UNTRACKED_FIELDS. The call is reasonable here, because the test must reproduce a legacy row state without emitting a JobEvent. Record that reason inline so the next reader does not treat it as a template for production code.

📝 Proposed comment
- Job.objects.filter(pk=job.pk).untracked_update(company=None)+ # untracked_update, not save(staff=...): the state under test is a row+ # whose company was cleared, and a tracked save would add a JobEvent+ # the scenario never produced.+ Job.objects.filter(pk=job.pk).untracked_update(company=None)

As per coding guidelines: "Comments must document the rejected obvious alternative and the factual constraint that rejected it."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_document_api.py` at line 339, Document the intentional
use of Job.objects.filter(...).untracked_update in this test: state that it
creates a legacy row state without emitting a JobEvent, and explicitly note that
normal tracked updates are rejected because they would emit that event. Make
clear this is test-only behavior and not a production usage pattern.

Source: Coding guidelines

apps/xero/provider.py (1)

340-357: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the validation-error mapping.

The same three steps now appear in create_quote (Lines 290-297), delete_quote (Lines 349-357), and _create_or_update_purchase_order (Lines 457-465): collect str(ve.message), log a warning, and return a failed DocumentResult with the joined text. A small private helper keeps one implementation of the element-error contract.

Also note Lines 347-348 build updated_quotes only to index element zero. A direct guard reads shorter.

♻️ Proposed helper
`@staticmethod`def_validation_failure(
element: Any, context: str, external_id: str|None=None
) ->DocumentResult|None:
"""Return the element-level failure result, or None when Xero accepted it."""ifnotelement.validation_errors:
returnNoneerrors= [str(ve.message) forveinelement.validation_errors]
logger.warning("Xero %s validation errors: %s", context, errors)
returnDocumentResult(
success=False,
external_id=external_id,
error=" | ".join(errors),
validation_errors=errors,
)
- updated_quotes = response.quotes or []- updated = updated_quotes[0] if updated_quotes else None- if updated is not None and updated.validation_errors:- errors = [str(ve.message) for ve in updated.validation_errors]- logger.warning("Xero quote %s delete validation errors: %s", external_id, errors)- return DocumentResult(- success=False,- external_id=external_id,- error=" | ".join(errors),- validation_errors=errors,- )+ updated = next(iter(response.quotes or []), None)+ if updated is not None:+ rejected = self._validation_failure(+ updated, f"quote {external_id} delete", external_id+ )+ if rejected is not None:+ return rejected

As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/provider.py` around lines 340 - 357, Extract the repeated
element-level validation handling from create_quote, delete_quote, and
_create_or_update_purchase_order into one private helper, such as
_validation_failure, that maps messages, logs the context, and returns the
failed DocumentResult or None. Replace each inline implementation with the
helper while preserving each operation’s context and external_id, and simplify
delete_quote to guard the first response element directly instead of building
updated_quotes solely for indexing.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/rewrite-status.md`:
- Around line 876-887: Renumber the ordered backlog list in
docs/rewrite-status.md so the new entries around the split and ultrareview
cleanup items remain consistent with all subsequent entries. Update the
following existing numbered items to shift their source numbers accordingly, or
convert the entire list to `1.` markers while preserving its rendered order.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 324-325: Update persistDraftIfReady and the draft commit flow so
edits made while draft creation is in flight are queued or reconciled and
applied to the newly created line instead of being lost. Cover both the
description edit at frontend/src/features/job/costing/CostLineGrid.tsx:324-325
and numeric edit at frontend/src/features/job/costing/CostLineGrid.tsx:384-385;
retain the draft until all later edits are persisted, and add a delayed-create
test that edits a field before the initial POST resolves.
- Around line 381-383: Update the unit_cost handling in CostLineGrid so derived
unit_rev is recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.
In `@frontend/src/features/job/costing/useCostLines.test.ts`:
- Around line 50-63: Update the test around restoreDeletedLine to make lineX the
current cached entry with its distinct rejected-optimistic unit_rev, while
keeping snapshotX at 12.00. Preserve the existing assertions that only y is
reinserted and assert restored x retains lineX’s current value, distinguishing
it from the snapshot.
---
Nitpick comments:
In `@apps/xero/api.py`:
- Around line 573-589: Keep the delete path’s company selection semantics
unchanged: prefer job.company, then fall back to quote.company, and return the
existing error when neither exists. For readability, split the chained
conditional around the delete handler’s company selection into explicit steps
while preserving the XeroQuoteManager(company=company, job=job, ...) behavior.
In `@apps/xero/provider.py`:
- Around line 340-357: Extract the repeated element-level validation handling
from create_quote, delete_quote, and _create_or_update_purchase_order into one
private helper, such as _validation_failure, that maps messages, logs the
context, and returns the failed DocumentResult or None. Replace each inline
implementation with the helper while preserving each operation’s context and
external_id, and simplify delete_quote to guard the first response element
directly instead of building updated_quotes solely for indexing.
In `@apps/xero/tests/test_document_api.py`:
- Line 339: Document the intentional use of
Job.objects.filter(...).untracked_update in this test: state that it creates a
legacy row state without emitting a JobEvent, and explicitly note that normal
tracked updates are rejected because they would emit that event. Make clear this
is test-only behavior and not a production usage pattern.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 208-243: Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.
- Around line 272-290: Update test_post_persist_failure_voids_and_names_the_id
to assert that provider.delete_quote was called with the created quote’s
external ID, in addition to verifying the void operation and empty database;
ensure the assertion specifically validates the ID value rather than only call
count.
- Around line 244-270: Extend test_mirrored_row_on_another_job_raises to retain
the created mirrored Quote and, after manager.create_document raises ValueError,
assert that the other job’s Quote remains present and unvoided. Keep the
existing “different job” exception assertion and verify the safety property
enforced by _resolve_persist_collision.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9be30f0d-b9c7-4e0e-8913-25c45c8a6bbf

📥 Commits

Reviewing files that changed from the base of the PR and between cd5943d and c426af2.

📒 Files selected for processing (16)
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/xero/api.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/code-quality.md
  • docs/rewrite-status.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.test.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • stubs/xero_python/accounting/__init__.pyi

Comment on lines +876 to +887
5. Split `apps/xero` by capability — routers and provider modules for
connection, contacts, sales documents, purchasing, sync — keeping
invoice/quote/PO domain orchestration separate. `api.py` is ~1,200 lines
and `provider.py` ~600; the shared document-endpoint adapter (landed with
the quote hardening) stops the scaffolding drift, but the file split is
deliberate post-cutover structure work.
6. Ultrareview sub-cap cleanups from the quote slice: managers read
provider-private `_sub_total`/`_total` raw keys the readonly provider
must fabricate; `EMPTY_SERVER_SHAPE` could be a `Pick<CostLineOut, ...>`;
XeroQuoteCard/JobInvoiceCard are siblings with drift; the item picker's
stock search fires per keystroke undebounced; the quote tab duplicates
the HOURS formatter; a dead "No online URL" toast.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Renumber the backlog items.

The new entries are written as 5. (Line 876) and 6. (Line 882). Lines 888 and 891 already use 5. and 6.. Markdown renumbers ordered lists on render, so the published list will read 5, 6, 7, 8, … while the source text keeps the old numbers. Anyone citing "backlog item 9" will then reference a different entry in the source than in the rendered document. Number the new entries 5. and 6. and shift the following entries, or convert the whole list to 1. markers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/rewrite-status.md` around lines 876 - 887, Renumber the ordered backlog
list in docs/rewrite-status.md so the new entries around the split and
ultrareview cleanup items remain consistent with all subsequent entries. Update
the following existing numbered items to shift their source numbers accordingly,
or convert the entire list to `1.` markers while preserving its rendered order.

Comment on lines +324 to +325
context.updateDraft(gridRow.localId, patch)
context.commitDraftField(gridRow.localId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve edits made while draft creation is in flight.

persistDraftIfReady captures the earlier draft in the create request, ignores later commits while persistingRef contains the local ID, and removes the draft on success. An edit during that request is silently lost.

  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325: queue or reconcile a description edit made after draft creation starts.
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385: queue or reconcile a numeric edit made after draft creation starts.

Keep the draft until later edits are persisted to the created line. Add a delayed-create test that edits a field before the first POST resolves.

📍 Affects 1 file
  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325 (this comment)
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 324 - 325,
Update persistDraftIfReady and the draft commit flow so edits made while draft
creation is in flight are queued or reconciled and applied to the newly created
line instead of being lost. Cover both the description edit at
frontend/src/features/job/costing/CostLineGrid.tsx:324-325 and numeric edit at
frontend/src/features/job/costing/CostLineGrid.tsx:384-385; retain the draft
until all later edits are persisted, and add a delayed-create test that edits a
field before the initial POST resolves.

Comment on lines +381 to +383
if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recalculate draft revenue after every unit-cost edit.

After a failed create, a draft can retain derived unit_rev: "12.00". If the user changes unit_cost from "10" to "20", this condition skips recalculation and the retry submits inconsistent values. The server-row path already recalculates revenue for every cost edit.

Proposed fix
- if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {+ if (fieldName === 'unit_cost' && kind !== 'time') {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if(fieldName==='unit_cost'&&kind!=='time'&&gridRow.draft.unit_rev===null){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
if(fieldName==='unit_cost'&&kind!=='time'){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 381 - 383,
Update the unit_cost handling in CostLineGrid so derived unit_rev is
recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.

Comment on lines +50 to +63
it('re-inserts only the deleted line at its index, not the whole snapshot', () => {
const lineX = line({ id: 'x', unit_rev: 'rejected-optimistic' })
const snapshotX = line({ id: 'x', unit_rev: '12.00' })
const lineY = line({ id: 'y' })
// The current cache has X already rolled back by its own PATCH failure.
const current = [line({ id: 'x', unit_rev: '12.00' })]
const snapshot = [snapshotX, lineY]
void lineX

const restored = restoreDeletedLine(current, snapshot, 'y')

expect(restored.map((entry) => entry.id)).toEqual(['x', 'y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the preservation assertion distinguish current state from the snapshot.

current[0] and snapshotX both have unit_rev: "12.00". The test therefore passes if rollback replaces the current cache with the full snapshot. Use lineX as the current entry and assert that its distinct value remains.

Proposed fix
- const current = [line({ id: 'x', unit_rev: '12.00' })]+ const current = [lineX]
@@
- expect(restored[0]!.unit_rev).toBe('12.00')+ expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[line({id: 'x',unit_rev: '12.00'})]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[lineX]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/useCostLines.test.ts` around lines 50 - 63,
Update the test around restoreDeletedLine to make lineX the current cached entry
with its distinct rejected-optimistic unit_rev, while keeping snapshotX at
12.00. Preserve the existing assertions that only y is reinserted and assert
restored x retains lineX’s current value, distinguishing it from the snapshot.

@corrin
corrin merged commit 06038e3 into mainAug 9, 2026
3 checks passed
Sign up for freeto 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

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Quote-path hardening: ultrareview + user review findings - #49

Merged
corrin merged 5 commits into
mainfrom
xero/quote-hardening
Aug 9, 2026
Merged

Quote-path hardening: ultrareview + user review findings#49
corrin merged 5 commits into
mainfrom
xero/quote-hardening

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #48, fixing every verified correctness finding from the earmarked ultrareview and the user's own review. Full E2E gate green on this branch (39/39, writes live); 1716 unit tests, coverage 88.52%.

Backend

  • Provider quote calls adopt the PO validation pattern (summarize_errors=False + element-level validation_errors): deleting an ACCEPTED quote can no longer read as success and silently drop the local mirror while the quote lives on in Xero.
  • A compensation boundary covers EVERY failure after the remote write (_finalize_created_quote): totals validation, the insert, and the timestamp bump all void the orphan best-effort with the external id carried in the error — a real Xero quote can no longer become untracked, and a retry can no longer duplicate it. The IntegrityError inside it is discriminated by state: a same-xero_id row means the sync mirrored our own quote first and is adopted (linking the job the transform never sets) — never voided; only the job-constraint race voids. The persist happens at the catch site, which the handler-contract gate verified the hard way.
  • Quote deletion unbricked: no validate_company() on the delete path, and the endpoint falls back to the quote row's own company when job.company was cleared.
  • An unsynced company refuses with a readable 400 instead of a 500 (create path).
  • Retained diagnostic PDFs report their path (inspection JSON + the no-text error).
  • One document-endpoint adapter replaces the 7×-copied scaffolding across all six push endpoints — wire-identical by construction (ninja serializes every declared field), exported schema unchanged, and the error_type drift disappears with the copies. The full apps/xero capability split is recorded as post-cutover backlog, per "finish before improving".

Frontend

  • Draft rows derive unit_rev from unit_cost — a filled phantom used to silently never POST.
  • Draft commits skip the send-dedupe — retyping the same value after a failed POST retries.
  • The autosave buffer tracks dirtiness instead of copying the server value at focus (focus in the same tick as a sibling cell's state-updating blur captured a stale render).
  • Quantity edits make the phantom real; typed free-form rows infer adjust (v1 rule restored — material means a stock pick).
  • PATCH echoes merge only their own fields; a failed delete re-inserts only its line — interleaved optimistic edits can no longer be clobbered or resurrected.

Spec

The quote spec now hard-asserts the fresh job's line state before its repair pass (exactly one missing stock binding, nothing else wrong) and all-clear after — a regression in line creation fails the gate instead of being silently healed, while the repair pass keeps exercising the grid.

Declined with evidence in the review threads: the xero-python get_quote_as_pdf claim (the pinned SDK has it; the live spec exercises it) and the compact-match regex (equivalent in power to the existing check).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved quote creation and deletion error reporting, including detailed validation messages.
    • Quote cleanup now continues successfully when company details are unavailable or remote quotes no longer exist.
    • Prevented failed quote synchronizations from leaving incomplete remote records.
    • Improved retained diagnostic PDF handling and error messages.
  • Costing Improvements

    • Draft cost lines now classify correctly and calculate revenue values more reliably.
    • Autosave can retry failed updates and prevents stale edits from overwriting newer changes.
    • Failed deletions restore only the affected cost line.

corrinand others added 5 commits August 9, 2026 20:28
…ked deletes
Provider quote calls adopt the PO pattern (summarize_errors=False +
element-level validation_errors checks) so a rejected status change — e.g.
deleting an ACCEPTED quote — can never read as success. The post-create
tail moves into _finalize_created_quote: EVERY failure after the remote
write now compensates (totals validation, persist, the timestamp bump all
void the orphan best-effort with the external id in the error), and the
IntegrityError is discriminated by state — a same-xero_id row means the
sync mirrored our own quote first and is ADOPTED (linking the job the
transform never sets), never voided; only the job-constraint race voids.
Deletion no longer requires a Xero-valid company (the quote row carries
its own), an unsynced company refuses with a readable 400 instead of a
500, and a retained diagnostic PDF reports its path.
Findings: ultrareview (2) + user review (1, 2, 5) over PR #48.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 7x-copied scaffolding (token check, failure-to-payload mapping with the
status clamp, success-invariant raise + response construction) collapses
into _xero_auth_refusal/_document_failure/_document_success. Wire-identical
by construction — ninja serializes every declared field, so explicit Nones
equal the fields each endpoint used to omit — and the exported schema is
unchanged. The error_type drift (missing only from delete_purchase_order)
disappears with the copies. Full capability split of apps/xero stays a
recorded backlog item, not a pre-cutover change.
Findings: ultrareview sub-cap cleanup + user review (4, partial).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draft rows derive unit_rev from unit_cost like server rows (a filled
phantom used to silently never POST); draft commits skip the send-dedupe so
retyping the same value after a failed POST retries; the autosave buffer
tracks dirtiness instead of copying the server value in at focus (a focus
landing in the same tick as a sibling cell's state-updating blur copied a
stale render's value); quantity edits make the phantom real; typed
free-form rows infer adjust (v1 rule — material means a stock pick); the
PATCH echo merges only its own fields so it cannot clobber an interleaved
optimistic edit; a failed delete re-inserts only its line. The quote spec
now hard-asserts the fresh job's line state before the repair pass and
all-clear after it — a line-creation regression fails instead of being
silently healed.
Findings: ultrareview (3, 5, 6, 8, 9, 10) + user review (3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The handler-contract gate rightly flagged the IntegrityError handler: its
persist lived inside the delegate where the AST cannot see it. The persist
moves to the catch, which is where it belonged anyway. 1716 tests, 88.52%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates Xero quote validation and persistence, retains diagnostic PDFs for failed inspection, and improves costing draft autosave and optimistic state reconciliation. Tests and project metrics were updated for these behaviors.

Changes

Xero quote flow

Layer / File(s)Summary
Shared document response handling
apps/xero/api.py, stubs/xero_python/accounting/__init__.pyi
Shared authentication and document response helpers now serve invoice, quote, and purchase-order endpoints. Quote includes typed validation errors.
Provider validation errors
apps/xero/provider.py, apps/xero/tests/test_provider_documents.py
Quote creation and deletion expose element-level Xero validation messages and disable error summarization.
Quote finalization and cleanup
apps/xero/documents/quote.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py, docs/*
Quote finalization handles collisions, compensation, synchronization, and provider-total failures. Deletion works without a valid company relation. Tests and project metrics reflect the changes.

Quote PDF diagnostics

Layer / File(s)Summary
Retained diagnostic PDFs
apps/accounting/services/quote_pdf.py, apps/accounting/tests/test_quote_pdf.py
Non-matching PDFs are retained and returned through retained_pdf_path. Empty-text errors and command JSON include the retained path.

Costing draft and optimistic updates

Layer / File(s)Summary
Draft autosave and costing fields
frontend/src/features/job/costing/CostLineGrid.tsx, frontend/src/features/job/costing/useAutosaveField.ts, frontend/src/features/job/costing/CostLineGrid.test.tsx
Draft detection, free-form line kinds, derived revenue, and retry behavior now use dirty-field tracking.
Optimistic echo and rollback handling
frontend/src/features/job/costing/useCostLines.ts, frontend/src/features/job/costing/useCostLines.test.ts
Patch responses merge only requested fields, and failed deletes restore only the affected line.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant QuoteEndpoint
participant QuoteProvider
participant Xero
participant QuoteFinalizer
participant LocalDatabase
QuoteEndpoint->>QuoteProvider: create quote
QuoteProvider->>Xero: submit quote with summarize_errors=false
Xero-->>QuoteProvider: quote result and validation_errors
QuoteProvider-->>QuoteFinalizer: DocumentResult
QuoteFinalizer->>LocalDatabase: finalize or compensate local persistence
QuoteFinalizer-->>QuoteEndpoint: success or failure response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 41.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: hardening quote handling based on review findings.
Description check✅ PassedThe description explains the rationale, backend and frontend changes, specification updates, and test results, but omits the template headings and checklist.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xero/quote-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
apps/xero/tests/test_quote_manager.py (3)

208-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the adopted totals.

Adoption overwrites total_excl_tax and total_incl_tax from the provider raw payload (quote.py Lines 287-288), replacing the mirror's zeros. That overwrite is the part a regression would silently drop, because number and job_id would still look correct.

💚 Proposed assertion
 assert adopted.job_id == job.id
assert adopted.number == "QU-RAW-1"
+ assert adopted.total_excl_tax == Decimal("250.00")+ assert adopted.total_incl_tax == Decimal("287.50")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 208 - 243, Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.

272-290: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The test name promises an assertion the body does not make.

test_post_persist_failure_voids_and_names_the_id asserts the void and the empty table. It never checks that the external id appears anywhere. The raised error is RuntimeError("db gone") from the patched bump, which carries no id. Either assert the void argument or rename the test.

💚 Proposed fix
- provider.delete_quote.assert_called_once()+ external_id = provider.create_quote.return_value.external_id+ provider.delete_quote.assert_called_once_with(external_id)
assert Quote.objects.count() == 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 272 - 290, Update
test_post_persist_failure_voids_and_names_the_id to assert that
provider.delete_quote was called with the created quote’s external ID, in
addition to verifying the void operation and empty database; ensure the
assertion specifically validates the ID value rather than only call count.

244-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the safety property this test exists for.

_resolve_persist_collision documents that a row on another job must not be voided, because that would delete another job's document. The test asserts only the raise. Add the negative assertion so a future change that voids first still fails the test.

💚 Proposed assertion
 with pytest.raises(ValueError, match="different job"):
manager.create_document(breakdown=False)
++ provider.delete_quote.assert_not_called()+ assert Quote.objects.get(xero_id=external_id).job_id == other_job.id
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 244 - 270, Extend
test_mirrored_row_on_another_job_raises to retain the created mirrored Quote
and, after manager.create_document raises ValueError, assert that the other
job’s Quote remains present and unvoided. Keep the existing “different job”
exception assertion and verify the safety property enforced by
_resolve_persist_collision.
apps/xero/api.py (1)

573-589: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm the delete path never needs job.company semantics over quote.company.

The fallback selects quote.company when job.company is None. XeroQuoteManager.delete_document does not call validate_company, so the company is only bound for identity and logging. That matches the documented intent. One readability point: the chained conditional on Line 576 packs two decisions into one expression.

♻️ Optional: split the fallback
- quote = Quote.objects.filter(job=job).select_related("company").first()- company = job.company if job.company is not None else quote.company if quote else None+ quote = Quote.objects.filter(job=job).select_related("company").first()+ company = job.company+ if company is None and quote is not None:+ company = quote.company
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/api.py` around lines 573 - 589, Keep the delete path’s company
selection semantics unchanged: prefer job.company, then fall back to
quote.company, and return the existing error when neither exists. For
readability, split the chained conditional around the delete handler’s company
selection into explicit steps while preserving the
XeroQuoteManager(company=company, job=job, ...) behavior.
apps/xero/tests/test_document_api.py (1)

339-339: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document why untracked_update is used on a tracked field.

Job.untracked_update states: "Use only for migrations and bookkeeping fields that are in UNTRACKED_FIELDS." company is not in Job.UNTRACKED_FIELDS. The call is reasonable here, because the test must reproduce a legacy row state without emitting a JobEvent. Record that reason inline so the next reader does not treat it as a template for production code.

📝 Proposed comment
- Job.objects.filter(pk=job.pk).untracked_update(company=None)+ # untracked_update, not save(staff=...): the state under test is a row+ # whose company was cleared, and a tracked save would add a JobEvent+ # the scenario never produced.+ Job.objects.filter(pk=job.pk).untracked_update(company=None)

As per coding guidelines: "Comments must document the rejected obvious alternative and the factual constraint that rejected it."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_document_api.py` at line 339, Document the intentional
use of Job.objects.filter(...).untracked_update in this test: state that it
creates a legacy row state without emitting a JobEvent, and explicitly note that
normal tracked updates are rejected because they would emit that event. Make
clear this is test-only behavior and not a production usage pattern.

Source: Coding guidelines

apps/xero/provider.py (1)

340-357: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the validation-error mapping.

The same three steps now appear in create_quote (Lines 290-297), delete_quote (Lines 349-357), and _create_or_update_purchase_order (Lines 457-465): collect str(ve.message), log a warning, and return a failed DocumentResult with the joined text. A small private helper keeps one implementation of the element-error contract.

Also note Lines 347-348 build updated_quotes only to index element zero. A direct guard reads shorter.

♻️ Proposed helper
`@staticmethod`def_validation_failure(
element: Any, context: str, external_id: str|None=None
) ->DocumentResult|None:
"""Return the element-level failure result, or None when Xero accepted it."""ifnotelement.validation_errors:
returnNoneerrors= [str(ve.message) forveinelement.validation_errors]
logger.warning("Xero %s validation errors: %s", context, errors)
returnDocumentResult(
success=False,
external_id=external_id,
error=" | ".join(errors),
validation_errors=errors,
)
- updated_quotes = response.quotes or []- updated = updated_quotes[0] if updated_quotes else None- if updated is not None and updated.validation_errors:- errors = [str(ve.message) for ve in updated.validation_errors]- logger.warning("Xero quote %s delete validation errors: %s", external_id, errors)- return DocumentResult(- success=False,- external_id=external_id,- error=" | ".join(errors),- validation_errors=errors,- )+ updated = next(iter(response.quotes or []), None)+ if updated is not None:+ rejected = self._validation_failure(+ updated, f"quote {external_id} delete", external_id+ )+ if rejected is not None:+ return rejected

As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/provider.py` around lines 340 - 357, Extract the repeated
element-level validation handling from create_quote, delete_quote, and
_create_or_update_purchase_order into one private helper, such as
_validation_failure, that maps messages, logs the context, and returns the
failed DocumentResult or None. Replace each inline implementation with the
helper while preserving each operation’s context and external_id, and simplify
delete_quote to guard the first response element directly instead of building
updated_quotes solely for indexing.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/rewrite-status.md`:
- Around line 876-887: Renumber the ordered backlog list in
docs/rewrite-status.md so the new entries around the split and ultrareview
cleanup items remain consistent with all subsequent entries. Update the
following existing numbered items to shift their source numbers accordingly, or
convert the entire list to `1.` markers while preserving its rendered order.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 324-325: Update persistDraftIfReady and the draft commit flow so
edits made while draft creation is in flight are queued or reconciled and
applied to the newly created line instead of being lost. Cover both the
description edit at frontend/src/features/job/costing/CostLineGrid.tsx:324-325
and numeric edit at frontend/src/features/job/costing/CostLineGrid.tsx:384-385;
retain the draft until all later edits are persisted, and add a delayed-create
test that edits a field before the initial POST resolves.
- Around line 381-383: Update the unit_cost handling in CostLineGrid so derived
unit_rev is recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.
In `@frontend/src/features/job/costing/useCostLines.test.ts`:
- Around line 50-63: Update the test around restoreDeletedLine to make lineX the
current cached entry with its distinct rejected-optimistic unit_rev, while
keeping snapshotX at 12.00. Preserve the existing assertions that only y is
reinserted and assert restored x retains lineX’s current value, distinguishing
it from the snapshot.
---
Nitpick comments:
In `@apps/xero/api.py`:
- Around line 573-589: Keep the delete path’s company selection semantics
unchanged: prefer job.company, then fall back to quote.company, and return the
existing error when neither exists. For readability, split the chained
conditional around the delete handler’s company selection into explicit steps
while preserving the XeroQuoteManager(company=company, job=job, ...) behavior.
In `@apps/xero/provider.py`:
- Around line 340-357: Extract the repeated element-level validation handling
from create_quote, delete_quote, and _create_or_update_purchase_order into one
private helper, such as _validation_failure, that maps messages, logs the
context, and returns the failed DocumentResult or None. Replace each inline
implementation with the helper while preserving each operation’s context and
external_id, and simplify delete_quote to guard the first response element
directly instead of building updated_quotes solely for indexing.
In `@apps/xero/tests/test_document_api.py`:
- Line 339: Document the intentional use of
Job.objects.filter(...).untracked_update in this test: state that it creates a
legacy row state without emitting a JobEvent, and explicitly note that normal
tracked updates are rejected because they would emit that event. Make clear this
is test-only behavior and not a production usage pattern.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 208-243: Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.
- Around line 272-290: Update test_post_persist_failure_voids_and_names_the_id
to assert that provider.delete_quote was called with the created quote’s
external ID, in addition to verifying the void operation and empty database;
ensure the assertion specifically validates the ID value rather than only call
count.
- Around line 244-270: Extend test_mirrored_row_on_another_job_raises to retain
the created mirrored Quote and, after manager.create_document raises ValueError,
assert that the other job’s Quote remains present and unvoided. Keep the
existing “different job” exception assertion and verify the safety property
enforced by _resolve_persist_collision.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9be30f0d-b9c7-4e0e-8913-25c45c8a6bbf

📥 Commits

Reviewing files that changed from the base of the PR and between cd5943d and c426af2.

📒 Files selected for processing (16)
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/xero/api.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/code-quality.md
  • docs/rewrite-status.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.test.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • stubs/xero_python/accounting/__init__.pyi

Comment on lines +876 to +887
5. Split `apps/xero` by capability — routers and provider modules for
connection, contacts, sales documents, purchasing, sync — keeping
invoice/quote/PO domain orchestration separate. `api.py` is ~1,200 lines
and `provider.py` ~600; the shared document-endpoint adapter (landed with
the quote hardening) stops the scaffolding drift, but the file split is
deliberate post-cutover structure work.
6. Ultrareview sub-cap cleanups from the quote slice: managers read
provider-private `_sub_total`/`_total` raw keys the readonly provider
must fabricate; `EMPTY_SERVER_SHAPE` could be a `Pick<CostLineOut, ...>`;
XeroQuoteCard/JobInvoiceCard are siblings with drift; the item picker's
stock search fires per keystroke undebounced; the quote tab duplicates
the HOURS formatter; a dead "No online URL" toast.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Renumber the backlog items.

The new entries are written as 5. (Line 876) and 6. (Line 882). Lines 888 and 891 already use 5. and 6.. Markdown renumbers ordered lists on render, so the published list will read 5, 6, 7, 8, … while the source text keeps the old numbers. Anyone citing "backlog item 9" will then reference a different entry in the source than in the rendered document. Number the new entries 5. and 6. and shift the following entries, or convert the whole list to 1. markers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/rewrite-status.md` around lines 876 - 887, Renumber the ordered backlog
list in docs/rewrite-status.md so the new entries around the split and
ultrareview cleanup items remain consistent with all subsequent entries. Update
the following existing numbered items to shift their source numbers accordingly,
or convert the entire list to `1.` markers while preserving its rendered order.

Comment on lines +324 to +325
context.updateDraft(gridRow.localId, patch)
context.commitDraftField(gridRow.localId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve edits made while draft creation is in flight.

persistDraftIfReady captures the earlier draft in the create request, ignores later commits while persistingRef contains the local ID, and removes the draft on success. An edit during that request is silently lost.

  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325: queue or reconcile a description edit made after draft creation starts.
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385: queue or reconcile a numeric edit made after draft creation starts.

Keep the draft until later edits are persisted to the created line. Add a delayed-create test that edits a field before the first POST resolves.

📍 Affects 1 file
  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325 (this comment)
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 324 - 325,
Update persistDraftIfReady and the draft commit flow so edits made while draft
creation is in flight are queued or reconciled and applied to the newly created
line instead of being lost. Cover both the description edit at
frontend/src/features/job/costing/CostLineGrid.tsx:324-325 and numeric edit at
frontend/src/features/job/costing/CostLineGrid.tsx:384-385; retain the draft
until all later edits are persisted, and add a delayed-create test that edits a
field before the initial POST resolves.

Comment on lines +381 to +383
if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recalculate draft revenue after every unit-cost edit.

After a failed create, a draft can retain derived unit_rev: "12.00". If the user changes unit_cost from "10" to "20", this condition skips recalculation and the retry submits inconsistent values. The server-row path already recalculates revenue for every cost edit.

Proposed fix
- if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {+ if (fieldName === 'unit_cost' && kind !== 'time') {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if(fieldName==='unit_cost'&&kind!=='time'&&gridRow.draft.unit_rev===null){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
if(fieldName==='unit_cost'&&kind!=='time'){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 381 - 383,
Update the unit_cost handling in CostLineGrid so derived unit_rev is
recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.

Comment on lines +50 to +63
it('re-inserts only the deleted line at its index, not the whole snapshot', () => {
const lineX = line({ id: 'x', unit_rev: 'rejected-optimistic' })
const snapshotX = line({ id: 'x', unit_rev: '12.00' })
const lineY = line({ id: 'y' })
// The current cache has X already rolled back by its own PATCH failure.
const current = [line({ id: 'x', unit_rev: '12.00' })]
const snapshot = [snapshotX, lineY]
void lineX

const restored = restoreDeletedLine(current, snapshot, 'y')

expect(restored.map((entry) => entry.id)).toEqual(['x', 'y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the preservation assertion distinguish current state from the snapshot.

current[0] and snapshotX both have unit_rev: "12.00". The test therefore passes if rollback replaces the current cache with the full snapshot. Use lineX as the current entry and assert that its distinct value remains.

Proposed fix
- const current = [line({ id: 'x', unit_rev: '12.00' })]+ const current = [lineX]
@@
- expect(restored[0]!.unit_rev).toBe('12.00')+ expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[line({id: 'x',unit_rev: '12.00'})]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[lineX]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/useCostLines.test.ts` around lines 50 - 63,
Update the test around restoreDeletedLine to make lineX the current cached entry
with its distinct rejected-optimistic unit_rev, while keeping snapshotX at
12.00. Preserve the existing assertions that only y is reinserted and assert
restored x retains lineX’s current value, distinguishing it from the snapshot.

@corrin
corrin merged commit 06038e3 into mainAug 9, 2026
3 checks passed
Sign up for freeto 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

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Quote-path hardening: ultrareview + user review findings - #49

Merged
corrin merged 5 commits into
mainfrom
xero/quote-hardening
Aug 9, 2026
Merged

Quote-path hardening: ultrareview + user review findings#49
corrin merged 5 commits into
mainfrom
xero/quote-hardening

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #48, fixing every verified correctness finding from the earmarked ultrareview and the user's own review. Full E2E gate green on this branch (39/39, writes live); 1716 unit tests, coverage 88.52%.

Backend

  • Provider quote calls adopt the PO validation pattern (summarize_errors=False + element-level validation_errors): deleting an ACCEPTED quote can no longer read as success and silently drop the local mirror while the quote lives on in Xero.
  • A compensation boundary covers EVERY failure after the remote write (_finalize_created_quote): totals validation, the insert, and the timestamp bump all void the orphan best-effort with the external id carried in the error — a real Xero quote can no longer become untracked, and a retry can no longer duplicate it. The IntegrityError inside it is discriminated by state: a same-xero_id row means the sync mirrored our own quote first and is adopted (linking the job the transform never sets) — never voided; only the job-constraint race voids. The persist happens at the catch site, which the handler-contract gate verified the hard way.
  • Quote deletion unbricked: no validate_company() on the delete path, and the endpoint falls back to the quote row's own company when job.company was cleared.
  • An unsynced company refuses with a readable 400 instead of a 500 (create path).
  • Retained diagnostic PDFs report their path (inspection JSON + the no-text error).
  • One document-endpoint adapter replaces the 7×-copied scaffolding across all six push endpoints — wire-identical by construction (ninja serializes every declared field), exported schema unchanged, and the error_type drift disappears with the copies. The full apps/xero capability split is recorded as post-cutover backlog, per "finish before improving".

Frontend

  • Draft rows derive unit_rev from unit_cost — a filled phantom used to silently never POST.
  • Draft commits skip the send-dedupe — retyping the same value after a failed POST retries.
  • The autosave buffer tracks dirtiness instead of copying the server value at focus (focus in the same tick as a sibling cell's state-updating blur captured a stale render).
  • Quantity edits make the phantom real; typed free-form rows infer adjust (v1 rule restored — material means a stock pick).
  • PATCH echoes merge only their own fields; a failed delete re-inserts only its line — interleaved optimistic edits can no longer be clobbered or resurrected.

Spec

The quote spec now hard-asserts the fresh job's line state before its repair pass (exactly one missing stock binding, nothing else wrong) and all-clear after — a regression in line creation fails the gate instead of being silently healed, while the repair pass keeps exercising the grid.

Declined with evidence in the review threads: the xero-python get_quote_as_pdf claim (the pinned SDK has it; the live spec exercises it) and the compact-match regex (equivalent in power to the existing check).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved quote creation and deletion error reporting, including detailed validation messages.
    • Quote cleanup now continues successfully when company details are unavailable or remote quotes no longer exist.
    • Prevented failed quote synchronizations from leaving incomplete remote records.
    • Improved retained diagnostic PDF handling and error messages.
  • Costing Improvements

    • Draft cost lines now classify correctly and calculate revenue values more reliably.
    • Autosave can retry failed updates and prevents stale edits from overwriting newer changes.
    • Failed deletions restore only the affected cost line.

corrinand others added 5 commits August 9, 2026 20:28
…ked deletes
Provider quote calls adopt the PO pattern (summarize_errors=False +
element-level validation_errors checks) so a rejected status change — e.g.
deleting an ACCEPTED quote — can never read as success. The post-create
tail moves into _finalize_created_quote: EVERY failure after the remote
write now compensates (totals validation, persist, the timestamp bump all
void the orphan best-effort with the external id in the error), and the
IntegrityError is discriminated by state — a same-xero_id row means the
sync mirrored our own quote first and is ADOPTED (linking the job the
transform never sets), never voided; only the job-constraint race voids.
Deletion no longer requires a Xero-valid company (the quote row carries
its own), an unsynced company refuses with a readable 400 instead of a
500, and a retained diagnostic PDF reports its path.
Findings: ultrareview (2) + user review (1, 2, 5) over PR #48.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 7x-copied scaffolding (token check, failure-to-payload mapping with the
status clamp, success-invariant raise + response construction) collapses
into _xero_auth_refusal/_document_failure/_document_success. Wire-identical
by construction — ninja serializes every declared field, so explicit Nones
equal the fields each endpoint used to omit — and the exported schema is
unchanged. The error_type drift (missing only from delete_purchase_order)
disappears with the copies. Full capability split of apps/xero stays a
recorded backlog item, not a pre-cutover change.
Findings: ultrareview sub-cap cleanup + user review (4, partial).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draft rows derive unit_rev from unit_cost like server rows (a filled
phantom used to silently never POST); draft commits skip the send-dedupe so
retyping the same value after a failed POST retries; the autosave buffer
tracks dirtiness instead of copying the server value in at focus (a focus
landing in the same tick as a sibling cell's state-updating blur copied a
stale render's value); quantity edits make the phantom real; typed
free-form rows infer adjust (v1 rule — material means a stock pick); the
PATCH echo merges only its own fields so it cannot clobber an interleaved
optimistic edit; a failed delete re-inserts only its line. The quote spec
now hard-asserts the fresh job's line state before the repair pass and
all-clear after it — a line-creation regression fails instead of being
silently healed.
Findings: ultrareview (3, 5, 6, 8, 9, 10) + user review (3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The handler-contract gate rightly flagged the IntegrityError handler: its
persist lived inside the delegate where the AST cannot see it. The persist
moves to the catch, which is where it belonged anyway. 1716 tests, 88.52%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates Xero quote validation and persistence, retains diagnostic PDFs for failed inspection, and improves costing draft autosave and optimistic state reconciliation. Tests and project metrics were updated for these behaviors.

Changes

Xero quote flow

Layer / File(s)Summary
Shared document response handling
apps/xero/api.py, stubs/xero_python/accounting/__init__.pyi
Shared authentication and document response helpers now serve invoice, quote, and purchase-order endpoints. Quote includes typed validation errors.
Provider validation errors
apps/xero/provider.py, apps/xero/tests/test_provider_documents.py
Quote creation and deletion expose element-level Xero validation messages and disable error summarization.
Quote finalization and cleanup
apps/xero/documents/quote.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py, docs/*
Quote finalization handles collisions, compensation, synchronization, and provider-total failures. Deletion works without a valid company relation. Tests and project metrics reflect the changes.

Quote PDF diagnostics

Layer / File(s)Summary
Retained diagnostic PDFs
apps/accounting/services/quote_pdf.py, apps/accounting/tests/test_quote_pdf.py
Non-matching PDFs are retained and returned through retained_pdf_path. Empty-text errors and command JSON include the retained path.

Costing draft and optimistic updates

Layer / File(s)Summary
Draft autosave and costing fields
frontend/src/features/job/costing/CostLineGrid.tsx, frontend/src/features/job/costing/useAutosaveField.ts, frontend/src/features/job/costing/CostLineGrid.test.tsx
Draft detection, free-form line kinds, derived revenue, and retry behavior now use dirty-field tracking.
Optimistic echo and rollback handling
frontend/src/features/job/costing/useCostLines.ts, frontend/src/features/job/costing/useCostLines.test.ts
Patch responses merge only requested fields, and failed deletes restore only the affected line.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant QuoteEndpoint
participant QuoteProvider
participant Xero
participant QuoteFinalizer
participant LocalDatabase
QuoteEndpoint->>QuoteProvider: create quote
QuoteProvider->>Xero: submit quote with summarize_errors=false
Xero-->>QuoteProvider: quote result and validation_errors
QuoteProvider-->>QuoteFinalizer: DocumentResult
QuoteFinalizer->>LocalDatabase: finalize or compensate local persistence
QuoteFinalizer-->>QuoteEndpoint: success or failure response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 41.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: hardening quote handling based on review findings.
Description check✅ PassedThe description explains the rationale, backend and frontend changes, specification updates, and test results, but omits the template headings and checklist.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xero/quote-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
apps/xero/tests/test_quote_manager.py (3)

208-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the adopted totals.

Adoption overwrites total_excl_tax and total_incl_tax from the provider raw payload (quote.py Lines 287-288), replacing the mirror's zeros. That overwrite is the part a regression would silently drop, because number and job_id would still look correct.

💚 Proposed assertion
 assert adopted.job_id == job.id
assert adopted.number == "QU-RAW-1"
+ assert adopted.total_excl_tax == Decimal("250.00")+ assert adopted.total_incl_tax == Decimal("287.50")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 208 - 243, Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.

272-290: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The test name promises an assertion the body does not make.

test_post_persist_failure_voids_and_names_the_id asserts the void and the empty table. It never checks that the external id appears anywhere. The raised error is RuntimeError("db gone") from the patched bump, which carries no id. Either assert the void argument or rename the test.

💚 Proposed fix
- provider.delete_quote.assert_called_once()+ external_id = provider.create_quote.return_value.external_id+ provider.delete_quote.assert_called_once_with(external_id)
assert Quote.objects.count() == 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 272 - 290, Update
test_post_persist_failure_voids_and_names_the_id to assert that
provider.delete_quote was called with the created quote’s external ID, in
addition to verifying the void operation and empty database; ensure the
assertion specifically validates the ID value rather than only call count.

244-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the safety property this test exists for.

_resolve_persist_collision documents that a row on another job must not be voided, because that would delete another job's document. The test asserts only the raise. Add the negative assertion so a future change that voids first still fails the test.

💚 Proposed assertion
 with pytest.raises(ValueError, match="different job"):
manager.create_document(breakdown=False)
++ provider.delete_quote.assert_not_called()+ assert Quote.objects.get(xero_id=external_id).job_id == other_job.id
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 244 - 270, Extend
test_mirrored_row_on_another_job_raises to retain the created mirrored Quote
and, after manager.create_document raises ValueError, assert that the other
job’s Quote remains present and unvoided. Keep the existing “different job”
exception assertion and verify the safety property enforced by
_resolve_persist_collision.
apps/xero/api.py (1)

573-589: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm the delete path never needs job.company semantics over quote.company.

The fallback selects quote.company when job.company is None. XeroQuoteManager.delete_document does not call validate_company, so the company is only bound for identity and logging. That matches the documented intent. One readability point: the chained conditional on Line 576 packs two decisions into one expression.

♻️ Optional: split the fallback
- quote = Quote.objects.filter(job=job).select_related("company").first()- company = job.company if job.company is not None else quote.company if quote else None+ quote = Quote.objects.filter(job=job).select_related("company").first()+ company = job.company+ if company is None and quote is not None:+ company = quote.company
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/api.py` around lines 573 - 589, Keep the delete path’s company
selection semantics unchanged: prefer job.company, then fall back to
quote.company, and return the existing error when neither exists. For
readability, split the chained conditional around the delete handler’s company
selection into explicit steps while preserving the
XeroQuoteManager(company=company, job=job, ...) behavior.
apps/xero/tests/test_document_api.py (1)

339-339: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document why untracked_update is used on a tracked field.

Job.untracked_update states: "Use only for migrations and bookkeeping fields that are in UNTRACKED_FIELDS." company is not in Job.UNTRACKED_FIELDS. The call is reasonable here, because the test must reproduce a legacy row state without emitting a JobEvent. Record that reason inline so the next reader does not treat it as a template for production code.

📝 Proposed comment
- Job.objects.filter(pk=job.pk).untracked_update(company=None)+ # untracked_update, not save(staff=...): the state under test is a row+ # whose company was cleared, and a tracked save would add a JobEvent+ # the scenario never produced.+ Job.objects.filter(pk=job.pk).untracked_update(company=None)

As per coding guidelines: "Comments must document the rejected obvious alternative and the factual constraint that rejected it."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_document_api.py` at line 339, Document the intentional
use of Job.objects.filter(...).untracked_update in this test: state that it
creates a legacy row state without emitting a JobEvent, and explicitly note that
normal tracked updates are rejected because they would emit that event. Make
clear this is test-only behavior and not a production usage pattern.

Source: Coding guidelines

apps/xero/provider.py (1)

340-357: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the validation-error mapping.

The same three steps now appear in create_quote (Lines 290-297), delete_quote (Lines 349-357), and _create_or_update_purchase_order (Lines 457-465): collect str(ve.message), log a warning, and return a failed DocumentResult with the joined text. A small private helper keeps one implementation of the element-error contract.

Also note Lines 347-348 build updated_quotes only to index element zero. A direct guard reads shorter.

♻️ Proposed helper
`@staticmethod`def_validation_failure(
element: Any, context: str, external_id: str|None=None
) ->DocumentResult|None:
"""Return the element-level failure result, or None when Xero accepted it."""ifnotelement.validation_errors:
returnNoneerrors= [str(ve.message) forveinelement.validation_errors]
logger.warning("Xero %s validation errors: %s", context, errors)
returnDocumentResult(
success=False,
external_id=external_id,
error=" | ".join(errors),
validation_errors=errors,
)
- updated_quotes = response.quotes or []- updated = updated_quotes[0] if updated_quotes else None- if updated is not None and updated.validation_errors:- errors = [str(ve.message) for ve in updated.validation_errors]- logger.warning("Xero quote %s delete validation errors: %s", external_id, errors)- return DocumentResult(- success=False,- external_id=external_id,- error=" | ".join(errors),- validation_errors=errors,- )+ updated = next(iter(response.quotes or []), None)+ if updated is not None:+ rejected = self._validation_failure(+ updated, f"quote {external_id} delete", external_id+ )+ if rejected is not None:+ return rejected

As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/provider.py` around lines 340 - 357, Extract the repeated
element-level validation handling from create_quote, delete_quote, and
_create_or_update_purchase_order into one private helper, such as
_validation_failure, that maps messages, logs the context, and returns the
failed DocumentResult or None. Replace each inline implementation with the
helper while preserving each operation’s context and external_id, and simplify
delete_quote to guard the first response element directly instead of building
updated_quotes solely for indexing.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/rewrite-status.md`:
- Around line 876-887: Renumber the ordered backlog list in
docs/rewrite-status.md so the new entries around the split and ultrareview
cleanup items remain consistent with all subsequent entries. Update the
following existing numbered items to shift their source numbers accordingly, or
convert the entire list to `1.` markers while preserving its rendered order.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 324-325: Update persistDraftIfReady and the draft commit flow so
edits made while draft creation is in flight are queued or reconciled and
applied to the newly created line instead of being lost. Cover both the
description edit at frontend/src/features/job/costing/CostLineGrid.tsx:324-325
and numeric edit at frontend/src/features/job/costing/CostLineGrid.tsx:384-385;
retain the draft until all later edits are persisted, and add a delayed-create
test that edits a field before the initial POST resolves.
- Around line 381-383: Update the unit_cost handling in CostLineGrid so derived
unit_rev is recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.
In `@frontend/src/features/job/costing/useCostLines.test.ts`:
- Around line 50-63: Update the test around restoreDeletedLine to make lineX the
current cached entry with its distinct rejected-optimistic unit_rev, while
keeping snapshotX at 12.00. Preserve the existing assertions that only y is
reinserted and assert restored x retains lineX’s current value, distinguishing
it from the snapshot.
---
Nitpick comments:
In `@apps/xero/api.py`:
- Around line 573-589: Keep the delete path’s company selection semantics
unchanged: prefer job.company, then fall back to quote.company, and return the
existing error when neither exists. For readability, split the chained
conditional around the delete handler’s company selection into explicit steps
while preserving the XeroQuoteManager(company=company, job=job, ...) behavior.
In `@apps/xero/provider.py`:
- Around line 340-357: Extract the repeated element-level validation handling
from create_quote, delete_quote, and _create_or_update_purchase_order into one
private helper, such as _validation_failure, that maps messages, logs the
context, and returns the failed DocumentResult or None. Replace each inline
implementation with the helper while preserving each operation’s context and
external_id, and simplify delete_quote to guard the first response element
directly instead of building updated_quotes solely for indexing.
In `@apps/xero/tests/test_document_api.py`:
- Line 339: Document the intentional use of
Job.objects.filter(...).untracked_update in this test: state that it creates a
legacy row state without emitting a JobEvent, and explicitly note that normal
tracked updates are rejected because they would emit that event. Make clear this
is test-only behavior and not a production usage pattern.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 208-243: Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.
- Around line 272-290: Update test_post_persist_failure_voids_and_names_the_id
to assert that provider.delete_quote was called with the created quote’s
external ID, in addition to verifying the void operation and empty database;
ensure the assertion specifically validates the ID value rather than only call
count.
- Around line 244-270: Extend test_mirrored_row_on_another_job_raises to retain
the created mirrored Quote and, after manager.create_document raises ValueError,
assert that the other job’s Quote remains present and unvoided. Keep the
existing “different job” exception assertion and verify the safety property
enforced by _resolve_persist_collision.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9be30f0d-b9c7-4e0e-8913-25c45c8a6bbf

📥 Commits

Reviewing files that changed from the base of the PR and between cd5943d and c426af2.

📒 Files selected for processing (16)
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/xero/api.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/code-quality.md
  • docs/rewrite-status.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.test.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • stubs/xero_python/accounting/__init__.pyi

Comment on lines +876 to +887
5. Split `apps/xero` by capability — routers and provider modules for
connection, contacts, sales documents, purchasing, sync — keeping
invoice/quote/PO domain orchestration separate. `api.py` is ~1,200 lines
and `provider.py` ~600; the shared document-endpoint adapter (landed with
the quote hardening) stops the scaffolding drift, but the file split is
deliberate post-cutover structure work.
6. Ultrareview sub-cap cleanups from the quote slice: managers read
provider-private `_sub_total`/`_total` raw keys the readonly provider
must fabricate; `EMPTY_SERVER_SHAPE` could be a `Pick<CostLineOut, ...>`;
XeroQuoteCard/JobInvoiceCard are siblings with drift; the item picker's
stock search fires per keystroke undebounced; the quote tab duplicates
the HOURS formatter; a dead "No online URL" toast.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Renumber the backlog items.

The new entries are written as 5. (Line 876) and 6. (Line 882). Lines 888 and 891 already use 5. and 6.. Markdown renumbers ordered lists on render, so the published list will read 5, 6, 7, 8, … while the source text keeps the old numbers. Anyone citing "backlog item 9" will then reference a different entry in the source than in the rendered document. Number the new entries 5. and 6. and shift the following entries, or convert the whole list to 1. markers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/rewrite-status.md` around lines 876 - 887, Renumber the ordered backlog
list in docs/rewrite-status.md so the new entries around the split and
ultrareview cleanup items remain consistent with all subsequent entries. Update
the following existing numbered items to shift their source numbers accordingly,
or convert the entire list to `1.` markers while preserving its rendered order.

Comment on lines +324 to +325
context.updateDraft(gridRow.localId, patch)
context.commitDraftField(gridRow.localId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve edits made while draft creation is in flight.

persistDraftIfReady captures the earlier draft in the create request, ignores later commits while persistingRef contains the local ID, and removes the draft on success. An edit during that request is silently lost.

  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325: queue or reconcile a description edit made after draft creation starts.
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385: queue or reconcile a numeric edit made after draft creation starts.

Keep the draft until later edits are persisted to the created line. Add a delayed-create test that edits a field before the first POST resolves.

📍 Affects 1 file
  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325 (this comment)
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 324 - 325,
Update persistDraftIfReady and the draft commit flow so edits made while draft
creation is in flight are queued or reconciled and applied to the newly created
line instead of being lost. Cover both the description edit at
frontend/src/features/job/costing/CostLineGrid.tsx:324-325 and numeric edit at
frontend/src/features/job/costing/CostLineGrid.tsx:384-385; retain the draft
until all later edits are persisted, and add a delayed-create test that edits a
field before the initial POST resolves.

Comment on lines +381 to +383
if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recalculate draft revenue after every unit-cost edit.

After a failed create, a draft can retain derived unit_rev: "12.00". If the user changes unit_cost from "10" to "20", this condition skips recalculation and the retry submits inconsistent values. The server-row path already recalculates revenue for every cost edit.

Proposed fix
- if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {+ if (fieldName === 'unit_cost' && kind !== 'time') {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if(fieldName==='unit_cost'&&kind!=='time'&&gridRow.draft.unit_rev===null){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
if(fieldName==='unit_cost'&&kind!=='time'){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 381 - 383,
Update the unit_cost handling in CostLineGrid so derived unit_rev is
recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.

Comment on lines +50 to +63
it('re-inserts only the deleted line at its index, not the whole snapshot', () => {
const lineX = line({ id: 'x', unit_rev: 'rejected-optimistic' })
const snapshotX = line({ id: 'x', unit_rev: '12.00' })
const lineY = line({ id: 'y' })
// The current cache has X already rolled back by its own PATCH failure.
const current = [line({ id: 'x', unit_rev: '12.00' })]
const snapshot = [snapshotX, lineY]
void lineX

const restored = restoreDeletedLine(current, snapshot, 'y')

expect(restored.map((entry) => entry.id)).toEqual(['x', 'y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the preservation assertion distinguish current state from the snapshot.

current[0] and snapshotX both have unit_rev: "12.00". The test therefore passes if rollback replaces the current cache with the full snapshot. Use lineX as the current entry and assert that its distinct value remains.

Proposed fix
- const current = [line({ id: 'x', unit_rev: '12.00' })]+ const current = [lineX]
@@
- expect(restored[0]!.unit_rev).toBe('12.00')+ expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[line({id: 'x',unit_rev: '12.00'})]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[lineX]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/useCostLines.test.ts` around lines 50 - 63,
Update the test around restoreDeletedLine to make lineX the current cached entry
with its distinct rejected-optimistic unit_rev, while keeping snapshotX at
12.00. Preserve the existing assertions that only y is reinserted and assert
restored x retains lineX’s current value, distinguishing it from the snapshot.

@corrin
corrin merged commit 06038e3 into mainAug 9, 2026
3 checks passed
Sign up for freeto 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

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Quote-path hardening: ultrareview + user review findings - #49

Merged
corrin merged 5 commits into
mainfrom
xero/quote-hardening
Aug 9, 2026
Merged

Quote-path hardening: ultrareview + user review findings#49
corrin merged 5 commits into
mainfrom
xero/quote-hardening

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #48, fixing every verified correctness finding from the earmarked ultrareview and the user's own review. Full E2E gate green on this branch (39/39, writes live); 1716 unit tests, coverage 88.52%.

Backend

  • Provider quote calls adopt the PO validation pattern (summarize_errors=False + element-level validation_errors): deleting an ACCEPTED quote can no longer read as success and silently drop the local mirror while the quote lives on in Xero.
  • A compensation boundary covers EVERY failure after the remote write (_finalize_created_quote): totals validation, the insert, and the timestamp bump all void the orphan best-effort with the external id carried in the error — a real Xero quote can no longer become untracked, and a retry can no longer duplicate it. The IntegrityError inside it is discriminated by state: a same-xero_id row means the sync mirrored our own quote first and is adopted (linking the job the transform never sets) — never voided; only the job-constraint race voids. The persist happens at the catch site, which the handler-contract gate verified the hard way.
  • Quote deletion unbricked: no validate_company() on the delete path, and the endpoint falls back to the quote row's own company when job.company was cleared.
  • An unsynced company refuses with a readable 400 instead of a 500 (create path).
  • Retained diagnostic PDFs report their path (inspection JSON + the no-text error).
  • One document-endpoint adapter replaces the 7×-copied scaffolding across all six push endpoints — wire-identical by construction (ninja serializes every declared field), exported schema unchanged, and the error_type drift disappears with the copies. The full apps/xero capability split is recorded as post-cutover backlog, per "finish before improving".

Frontend

  • Draft rows derive unit_rev from unit_cost — a filled phantom used to silently never POST.
  • Draft commits skip the send-dedupe — retyping the same value after a failed POST retries.
  • The autosave buffer tracks dirtiness instead of copying the server value at focus (focus in the same tick as a sibling cell's state-updating blur captured a stale render).
  • Quantity edits make the phantom real; typed free-form rows infer adjust (v1 rule restored — material means a stock pick).
  • PATCH echoes merge only their own fields; a failed delete re-inserts only its line — interleaved optimistic edits can no longer be clobbered or resurrected.

Spec

The quote spec now hard-asserts the fresh job's line state before its repair pass (exactly one missing stock binding, nothing else wrong) and all-clear after — a regression in line creation fails the gate instead of being silently healed, while the repair pass keeps exercising the grid.

Declined with evidence in the review threads: the xero-python get_quote_as_pdf claim (the pinned SDK has it; the live spec exercises it) and the compact-match regex (equivalent in power to the existing check).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved quote creation and deletion error reporting, including detailed validation messages.
    • Quote cleanup now continues successfully when company details are unavailable or remote quotes no longer exist.
    • Prevented failed quote synchronizations from leaving incomplete remote records.
    • Improved retained diagnostic PDF handling and error messages.
  • Costing Improvements

    • Draft cost lines now classify correctly and calculate revenue values more reliably.
    • Autosave can retry failed updates and prevents stale edits from overwriting newer changes.
    • Failed deletions restore only the affected cost line.

corrinand others added 5 commits August 9, 2026 20:28
…ked deletes
Provider quote calls adopt the PO pattern (summarize_errors=False +
element-level validation_errors checks) so a rejected status change — e.g.
deleting an ACCEPTED quote — can never read as success. The post-create
tail moves into _finalize_created_quote: EVERY failure after the remote
write now compensates (totals validation, persist, the timestamp bump all
void the orphan best-effort with the external id in the error), and the
IntegrityError is discriminated by state — a same-xero_id row means the
sync mirrored our own quote first and is ADOPTED (linking the job the
transform never sets), never voided; only the job-constraint race voids.
Deletion no longer requires a Xero-valid company (the quote row carries
its own), an unsynced company refuses with a readable 400 instead of a
500, and a retained diagnostic PDF reports its path.
Findings: ultrareview (2) + user review (1, 2, 5) over PR #48.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 7x-copied scaffolding (token check, failure-to-payload mapping with the
status clamp, success-invariant raise + response construction) collapses
into _xero_auth_refusal/_document_failure/_document_success. Wire-identical
by construction — ninja serializes every declared field, so explicit Nones
equal the fields each endpoint used to omit — and the exported schema is
unchanged. The error_type drift (missing only from delete_purchase_order)
disappears with the copies. Full capability split of apps/xero stays a
recorded backlog item, not a pre-cutover change.
Findings: ultrareview sub-cap cleanup + user review (4, partial).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draft rows derive unit_rev from unit_cost like server rows (a filled
phantom used to silently never POST); draft commits skip the send-dedupe so
retyping the same value after a failed POST retries; the autosave buffer
tracks dirtiness instead of copying the server value in at focus (a focus
landing in the same tick as a sibling cell's state-updating blur copied a
stale render's value); quantity edits make the phantom real; typed
free-form rows infer adjust (v1 rule — material means a stock pick); the
PATCH echo merges only its own fields so it cannot clobber an interleaved
optimistic edit; a failed delete re-inserts only its line. The quote spec
now hard-asserts the fresh job's line state before the repair pass and
all-clear after it — a line-creation regression fails instead of being
silently healed.
Findings: ultrareview (3, 5, 6, 8, 9, 10) + user review (3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The handler-contract gate rightly flagged the IntegrityError handler: its
persist lived inside the delegate where the AST cannot see it. The persist
moves to the catch, which is where it belonged anyway. 1716 tests, 88.52%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates Xero quote validation and persistence, retains diagnostic PDFs for failed inspection, and improves costing draft autosave and optimistic state reconciliation. Tests and project metrics were updated for these behaviors.

Changes

Xero quote flow

Layer / File(s)Summary
Shared document response handling
apps/xero/api.py, stubs/xero_python/accounting/__init__.pyi
Shared authentication and document response helpers now serve invoice, quote, and purchase-order endpoints. Quote includes typed validation errors.
Provider validation errors
apps/xero/provider.py, apps/xero/tests/test_provider_documents.py
Quote creation and deletion expose element-level Xero validation messages and disable error summarization.
Quote finalization and cleanup
apps/xero/documents/quote.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py, docs/*
Quote finalization handles collisions, compensation, synchronization, and provider-total failures. Deletion works without a valid company relation. Tests and project metrics reflect the changes.

Quote PDF diagnostics

Layer / File(s)Summary
Retained diagnostic PDFs
apps/accounting/services/quote_pdf.py, apps/accounting/tests/test_quote_pdf.py
Non-matching PDFs are retained and returned through retained_pdf_path. Empty-text errors and command JSON include the retained path.

Costing draft and optimistic updates

Layer / File(s)Summary
Draft autosave and costing fields
frontend/src/features/job/costing/CostLineGrid.tsx, frontend/src/features/job/costing/useAutosaveField.ts, frontend/src/features/job/costing/CostLineGrid.test.tsx
Draft detection, free-form line kinds, derived revenue, and retry behavior now use dirty-field tracking.
Optimistic echo and rollback handling
frontend/src/features/job/costing/useCostLines.ts, frontend/src/features/job/costing/useCostLines.test.ts
Patch responses merge only requested fields, and failed deletes restore only the affected line.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant QuoteEndpoint
participant QuoteProvider
participant Xero
participant QuoteFinalizer
participant LocalDatabase
QuoteEndpoint->>QuoteProvider: create quote
QuoteProvider->>Xero: submit quote with summarize_errors=false
Xero-->>QuoteProvider: quote result and validation_errors
QuoteProvider-->>QuoteFinalizer: DocumentResult
QuoteFinalizer->>LocalDatabase: finalize or compensate local persistence
QuoteFinalizer-->>QuoteEndpoint: success or failure response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 41.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: hardening quote handling based on review findings.
Description check✅ PassedThe description explains the rationale, backend and frontend changes, specification updates, and test results, but omits the template headings and checklist.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xero/quote-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
apps/xero/tests/test_quote_manager.py (3)

208-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the adopted totals.

Adoption overwrites total_excl_tax and total_incl_tax from the provider raw payload (quote.py Lines 287-288), replacing the mirror's zeros. That overwrite is the part a regression would silently drop, because number and job_id would still look correct.

💚 Proposed assertion
 assert adopted.job_id == job.id
assert adopted.number == "QU-RAW-1"
+ assert adopted.total_excl_tax == Decimal("250.00")+ assert adopted.total_incl_tax == Decimal("287.50")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 208 - 243, Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.

272-290: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The test name promises an assertion the body does not make.

test_post_persist_failure_voids_and_names_the_id asserts the void and the empty table. It never checks that the external id appears anywhere. The raised error is RuntimeError("db gone") from the patched bump, which carries no id. Either assert the void argument or rename the test.

💚 Proposed fix
- provider.delete_quote.assert_called_once()+ external_id = provider.create_quote.return_value.external_id+ provider.delete_quote.assert_called_once_with(external_id)
assert Quote.objects.count() == 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 272 - 290, Update
test_post_persist_failure_voids_and_names_the_id to assert that
provider.delete_quote was called with the created quote’s external ID, in
addition to verifying the void operation and empty database; ensure the
assertion specifically validates the ID value rather than only call count.

244-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the safety property this test exists for.

_resolve_persist_collision documents that a row on another job must not be voided, because that would delete another job's document. The test asserts only the raise. Add the negative assertion so a future change that voids first still fails the test.

💚 Proposed assertion
 with pytest.raises(ValueError, match="different job"):
manager.create_document(breakdown=False)
++ provider.delete_quote.assert_not_called()+ assert Quote.objects.get(xero_id=external_id).job_id == other_job.id
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 244 - 270, Extend
test_mirrored_row_on_another_job_raises to retain the created mirrored Quote
and, after manager.create_document raises ValueError, assert that the other
job’s Quote remains present and unvoided. Keep the existing “different job”
exception assertion and verify the safety property enforced by
_resolve_persist_collision.
apps/xero/api.py (1)

573-589: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm the delete path never needs job.company semantics over quote.company.

The fallback selects quote.company when job.company is None. XeroQuoteManager.delete_document does not call validate_company, so the company is only bound for identity and logging. That matches the documented intent. One readability point: the chained conditional on Line 576 packs two decisions into one expression.

♻️ Optional: split the fallback
- quote = Quote.objects.filter(job=job).select_related("company").first()- company = job.company if job.company is not None else quote.company if quote else None+ quote = Quote.objects.filter(job=job).select_related("company").first()+ company = job.company+ if company is None and quote is not None:+ company = quote.company
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/api.py` around lines 573 - 589, Keep the delete path’s company
selection semantics unchanged: prefer job.company, then fall back to
quote.company, and return the existing error when neither exists. For
readability, split the chained conditional around the delete handler’s company
selection into explicit steps while preserving the
XeroQuoteManager(company=company, job=job, ...) behavior.
apps/xero/tests/test_document_api.py (1)

339-339: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document why untracked_update is used on a tracked field.

Job.untracked_update states: "Use only for migrations and bookkeeping fields that are in UNTRACKED_FIELDS." company is not in Job.UNTRACKED_FIELDS. The call is reasonable here, because the test must reproduce a legacy row state without emitting a JobEvent. Record that reason inline so the next reader does not treat it as a template for production code.

📝 Proposed comment
- Job.objects.filter(pk=job.pk).untracked_update(company=None)+ # untracked_update, not save(staff=...): the state under test is a row+ # whose company was cleared, and a tracked save would add a JobEvent+ # the scenario never produced.+ Job.objects.filter(pk=job.pk).untracked_update(company=None)

As per coding guidelines: "Comments must document the rejected obvious alternative and the factual constraint that rejected it."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_document_api.py` at line 339, Document the intentional
use of Job.objects.filter(...).untracked_update in this test: state that it
creates a legacy row state without emitting a JobEvent, and explicitly note that
normal tracked updates are rejected because they would emit that event. Make
clear this is test-only behavior and not a production usage pattern.

Source: Coding guidelines

apps/xero/provider.py (1)

340-357: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the validation-error mapping.

The same three steps now appear in create_quote (Lines 290-297), delete_quote (Lines 349-357), and _create_or_update_purchase_order (Lines 457-465): collect str(ve.message), log a warning, and return a failed DocumentResult with the joined text. A small private helper keeps one implementation of the element-error contract.

Also note Lines 347-348 build updated_quotes only to index element zero. A direct guard reads shorter.

♻️ Proposed helper
`@staticmethod`def_validation_failure(
element: Any, context: str, external_id: str|None=None
) ->DocumentResult|None:
"""Return the element-level failure result, or None when Xero accepted it."""ifnotelement.validation_errors:
returnNoneerrors= [str(ve.message) forveinelement.validation_errors]
logger.warning("Xero %s validation errors: %s", context, errors)
returnDocumentResult(
success=False,
external_id=external_id,
error=" | ".join(errors),
validation_errors=errors,
)
- updated_quotes = response.quotes or []- updated = updated_quotes[0] if updated_quotes else None- if updated is not None and updated.validation_errors:- errors = [str(ve.message) for ve in updated.validation_errors]- logger.warning("Xero quote %s delete validation errors: %s", external_id, errors)- return DocumentResult(- success=False,- external_id=external_id,- error=" | ".join(errors),- validation_errors=errors,- )+ updated = next(iter(response.quotes or []), None)+ if updated is not None:+ rejected = self._validation_failure(+ updated, f"quote {external_id} delete", external_id+ )+ if rejected is not None:+ return rejected

As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/provider.py` around lines 340 - 357, Extract the repeated
element-level validation handling from create_quote, delete_quote, and
_create_or_update_purchase_order into one private helper, such as
_validation_failure, that maps messages, logs the context, and returns the
failed DocumentResult or None. Replace each inline implementation with the
helper while preserving each operation’s context and external_id, and simplify
delete_quote to guard the first response element directly instead of building
updated_quotes solely for indexing.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/rewrite-status.md`:
- Around line 876-887: Renumber the ordered backlog list in
docs/rewrite-status.md so the new entries around the split and ultrareview
cleanup items remain consistent with all subsequent entries. Update the
following existing numbered items to shift their source numbers accordingly, or
convert the entire list to `1.` markers while preserving its rendered order.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 324-325: Update persistDraftIfReady and the draft commit flow so
edits made while draft creation is in flight are queued or reconciled and
applied to the newly created line instead of being lost. Cover both the
description edit at frontend/src/features/job/costing/CostLineGrid.tsx:324-325
and numeric edit at frontend/src/features/job/costing/CostLineGrid.tsx:384-385;
retain the draft until all later edits are persisted, and add a delayed-create
test that edits a field before the initial POST resolves.
- Around line 381-383: Update the unit_cost handling in CostLineGrid so derived
unit_rev is recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.
In `@frontend/src/features/job/costing/useCostLines.test.ts`:
- Around line 50-63: Update the test around restoreDeletedLine to make lineX the
current cached entry with its distinct rejected-optimistic unit_rev, while
keeping snapshotX at 12.00. Preserve the existing assertions that only y is
reinserted and assert restored x retains lineX’s current value, distinguishing
it from the snapshot.
---
Nitpick comments:
In `@apps/xero/api.py`:
- Around line 573-589: Keep the delete path’s company selection semantics
unchanged: prefer job.company, then fall back to quote.company, and return the
existing error when neither exists. For readability, split the chained
conditional around the delete handler’s company selection into explicit steps
while preserving the XeroQuoteManager(company=company, job=job, ...) behavior.
In `@apps/xero/provider.py`:
- Around line 340-357: Extract the repeated element-level validation handling
from create_quote, delete_quote, and _create_or_update_purchase_order into one
private helper, such as _validation_failure, that maps messages, logs the
context, and returns the failed DocumentResult or None. Replace each inline
implementation with the helper while preserving each operation’s context and
external_id, and simplify delete_quote to guard the first response element
directly instead of building updated_quotes solely for indexing.
In `@apps/xero/tests/test_document_api.py`:
- Line 339: Document the intentional use of
Job.objects.filter(...).untracked_update in this test: state that it creates a
legacy row state without emitting a JobEvent, and explicitly note that normal
tracked updates are rejected because they would emit that event. Make clear this
is test-only behavior and not a production usage pattern.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 208-243: Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.
- Around line 272-290: Update test_post_persist_failure_voids_and_names_the_id
to assert that provider.delete_quote was called with the created quote’s
external ID, in addition to verifying the void operation and empty database;
ensure the assertion specifically validates the ID value rather than only call
count.
- Around line 244-270: Extend test_mirrored_row_on_another_job_raises to retain
the created mirrored Quote and, after manager.create_document raises ValueError,
assert that the other job’s Quote remains present and unvoided. Keep the
existing “different job” exception assertion and verify the safety property
enforced by _resolve_persist_collision.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9be30f0d-b9c7-4e0e-8913-25c45c8a6bbf

📥 Commits

Reviewing files that changed from the base of the PR and between cd5943d and c426af2.

📒 Files selected for processing (16)
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/xero/api.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/code-quality.md
  • docs/rewrite-status.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.test.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • stubs/xero_python/accounting/__init__.pyi

Comment on lines +876 to +887
5. Split `apps/xero` by capability — routers and provider modules for
connection, contacts, sales documents, purchasing, sync — keeping
invoice/quote/PO domain orchestration separate. `api.py` is ~1,200 lines
and `provider.py` ~600; the shared document-endpoint adapter (landed with
the quote hardening) stops the scaffolding drift, but the file split is
deliberate post-cutover structure work.
6. Ultrareview sub-cap cleanups from the quote slice: managers read
provider-private `_sub_total`/`_total` raw keys the readonly provider
must fabricate; `EMPTY_SERVER_SHAPE` could be a `Pick<CostLineOut, ...>`;
XeroQuoteCard/JobInvoiceCard are siblings with drift; the item picker's
stock search fires per keystroke undebounced; the quote tab duplicates
the HOURS formatter; a dead "No online URL" toast.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Renumber the backlog items.

The new entries are written as 5. (Line 876) and 6. (Line 882). Lines 888 and 891 already use 5. and 6.. Markdown renumbers ordered lists on render, so the published list will read 5, 6, 7, 8, … while the source text keeps the old numbers. Anyone citing "backlog item 9" will then reference a different entry in the source than in the rendered document. Number the new entries 5. and 6. and shift the following entries, or convert the whole list to 1. markers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/rewrite-status.md` around lines 876 - 887, Renumber the ordered backlog
list in docs/rewrite-status.md so the new entries around the split and
ultrareview cleanup items remain consistent with all subsequent entries. Update
the following existing numbered items to shift their source numbers accordingly,
or convert the entire list to `1.` markers while preserving its rendered order.

Comment on lines +324 to +325
context.updateDraft(gridRow.localId, patch)
context.commitDraftField(gridRow.localId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve edits made while draft creation is in flight.

persistDraftIfReady captures the earlier draft in the create request, ignores later commits while persistingRef contains the local ID, and removes the draft on success. An edit during that request is silently lost.

  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325: queue or reconcile a description edit made after draft creation starts.
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385: queue or reconcile a numeric edit made after draft creation starts.

Keep the draft until later edits are persisted to the created line. Add a delayed-create test that edits a field before the first POST resolves.

📍 Affects 1 file
  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325 (this comment)
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 324 - 325,
Update persistDraftIfReady and the draft commit flow so edits made while draft
creation is in flight are queued or reconciled and applied to the newly created
line instead of being lost. Cover both the description edit at
frontend/src/features/job/costing/CostLineGrid.tsx:324-325 and numeric edit at
frontend/src/features/job/costing/CostLineGrid.tsx:384-385; retain the draft
until all later edits are persisted, and add a delayed-create test that edits a
field before the initial POST resolves.

Comment on lines +381 to +383
if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recalculate draft revenue after every unit-cost edit.

After a failed create, a draft can retain derived unit_rev: "12.00". If the user changes unit_cost from "10" to "20", this condition skips recalculation and the retry submits inconsistent values. The server-row path already recalculates revenue for every cost edit.

Proposed fix
- if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {+ if (fieldName === 'unit_cost' && kind !== 'time') {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if(fieldName==='unit_cost'&&kind!=='time'&&gridRow.draft.unit_rev===null){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
if(fieldName==='unit_cost'&&kind!=='time'){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 381 - 383,
Update the unit_cost handling in CostLineGrid so derived unit_rev is
recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.

Comment on lines +50 to +63
it('re-inserts only the deleted line at its index, not the whole snapshot', () => {
const lineX = line({ id: 'x', unit_rev: 'rejected-optimistic' })
const snapshotX = line({ id: 'x', unit_rev: '12.00' })
const lineY = line({ id: 'y' })
// The current cache has X already rolled back by its own PATCH failure.
const current = [line({ id: 'x', unit_rev: '12.00' })]
const snapshot = [snapshotX, lineY]
void lineX

const restored = restoreDeletedLine(current, snapshot, 'y')

expect(restored.map((entry) => entry.id)).toEqual(['x', 'y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the preservation assertion distinguish current state from the snapshot.

current[0] and snapshotX both have unit_rev: "12.00". The test therefore passes if rollback replaces the current cache with the full snapshot. Use lineX as the current entry and assert that its distinct value remains.

Proposed fix
- const current = [line({ id: 'x', unit_rev: '12.00' })]+ const current = [lineX]
@@
- expect(restored[0]!.unit_rev).toBe('12.00')+ expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[line({id: 'x',unit_rev: '12.00'})]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[lineX]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/useCostLines.test.ts` around lines 50 - 63,
Update the test around restoreDeletedLine to make lineX the current cached entry
with its distinct rejected-optimistic unit_rev, while keeping snapshotX at
12.00. Preserve the existing assertions that only y is reinserted and assert
restored x retains lineX’s current value, distinguishing it from the snapshot.

@corrin
corrin merged commit 06038e3 into mainAug 9, 2026
3 checks passed
Sign up for freeto 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

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Quote-path hardening: ultrareview + user review findings - #49

Merged
corrin merged 5 commits into
mainfrom
xero/quote-hardening
Aug 9, 2026
Merged

Quote-path hardening: ultrareview + user review findings#49
corrin merged 5 commits into
mainfrom
xero/quote-hardening

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #48, fixing every verified correctness finding from the earmarked ultrareview and the user's own review. Full E2E gate green on this branch (39/39, writes live); 1716 unit tests, coverage 88.52%.

Backend

  • Provider quote calls adopt the PO validation pattern (summarize_errors=False + element-level validation_errors): deleting an ACCEPTED quote can no longer read as success and silently drop the local mirror while the quote lives on in Xero.
  • A compensation boundary covers EVERY failure after the remote write (_finalize_created_quote): totals validation, the insert, and the timestamp bump all void the orphan best-effort with the external id carried in the error — a real Xero quote can no longer become untracked, and a retry can no longer duplicate it. The IntegrityError inside it is discriminated by state: a same-xero_id row means the sync mirrored our own quote first and is adopted (linking the job the transform never sets) — never voided; only the job-constraint race voids. The persist happens at the catch site, which the handler-contract gate verified the hard way.
  • Quote deletion unbricked: no validate_company() on the delete path, and the endpoint falls back to the quote row's own company when job.company was cleared.
  • An unsynced company refuses with a readable 400 instead of a 500 (create path).
  • Retained diagnostic PDFs report their path (inspection JSON + the no-text error).
  • One document-endpoint adapter replaces the 7×-copied scaffolding across all six push endpoints — wire-identical by construction (ninja serializes every declared field), exported schema unchanged, and the error_type drift disappears with the copies. The full apps/xero capability split is recorded as post-cutover backlog, per "finish before improving".

Frontend

  • Draft rows derive unit_rev from unit_cost — a filled phantom used to silently never POST.
  • Draft commits skip the send-dedupe — retyping the same value after a failed POST retries.
  • The autosave buffer tracks dirtiness instead of copying the server value at focus (focus in the same tick as a sibling cell's state-updating blur captured a stale render).
  • Quantity edits make the phantom real; typed free-form rows infer adjust (v1 rule restored — material means a stock pick).
  • PATCH echoes merge only their own fields; a failed delete re-inserts only its line — interleaved optimistic edits can no longer be clobbered or resurrected.

Spec

The quote spec now hard-asserts the fresh job's line state before its repair pass (exactly one missing stock binding, nothing else wrong) and all-clear after — a regression in line creation fails the gate instead of being silently healed, while the repair pass keeps exercising the grid.

Declined with evidence in the review threads: the xero-python get_quote_as_pdf claim (the pinned SDK has it; the live spec exercises it) and the compact-match regex (equivalent in power to the existing check).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved quote creation and deletion error reporting, including detailed validation messages.
    • Quote cleanup now continues successfully when company details are unavailable or remote quotes no longer exist.
    • Prevented failed quote synchronizations from leaving incomplete remote records.
    • Improved retained diagnostic PDF handling and error messages.
  • Costing Improvements

    • Draft cost lines now classify correctly and calculate revenue values more reliably.
    • Autosave can retry failed updates and prevents stale edits from overwriting newer changes.
    • Failed deletions restore only the affected cost line.

corrinand others added 5 commits August 9, 2026 20:28
…ked deletes
Provider quote calls adopt the PO pattern (summarize_errors=False +
element-level validation_errors checks) so a rejected status change — e.g.
deleting an ACCEPTED quote — can never read as success. The post-create
tail moves into _finalize_created_quote: EVERY failure after the remote
write now compensates (totals validation, persist, the timestamp bump all
void the orphan best-effort with the external id in the error), and the
IntegrityError is discriminated by state — a same-xero_id row means the
sync mirrored our own quote first and is ADOPTED (linking the job the
transform never sets), never voided; only the job-constraint race voids.
Deletion no longer requires a Xero-valid company (the quote row carries
its own), an unsynced company refuses with a readable 400 instead of a
500, and a retained diagnostic PDF reports its path.
Findings: ultrareview (2) + user review (1, 2, 5) over PR #48.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 7x-copied scaffolding (token check, failure-to-payload mapping with the
status clamp, success-invariant raise + response construction) collapses
into _xero_auth_refusal/_document_failure/_document_success. Wire-identical
by construction — ninja serializes every declared field, so explicit Nones
equal the fields each endpoint used to omit — and the exported schema is
unchanged. The error_type drift (missing only from delete_purchase_order)
disappears with the copies. Full capability split of apps/xero stays a
recorded backlog item, not a pre-cutover change.
Findings: ultrareview sub-cap cleanup + user review (4, partial).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draft rows derive unit_rev from unit_cost like server rows (a filled
phantom used to silently never POST); draft commits skip the send-dedupe so
retyping the same value after a failed POST retries; the autosave buffer
tracks dirtiness instead of copying the server value in at focus (a focus
landing in the same tick as a sibling cell's state-updating blur copied a
stale render's value); quantity edits make the phantom real; typed
free-form rows infer adjust (v1 rule — material means a stock pick); the
PATCH echo merges only its own fields so it cannot clobber an interleaved
optimistic edit; a failed delete re-inserts only its line. The quote spec
now hard-asserts the fresh job's line state before the repair pass and
all-clear after it — a line-creation regression fails instead of being
silently healed.
Findings: ultrareview (3, 5, 6, 8, 9, 10) + user review (3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The handler-contract gate rightly flagged the IntegrityError handler: its
persist lived inside the delegate where the AST cannot see it. The persist
moves to the catch, which is where it belonged anyway. 1716 tests, 88.52%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates Xero quote validation and persistence, retains diagnostic PDFs for failed inspection, and improves costing draft autosave and optimistic state reconciliation. Tests and project metrics were updated for these behaviors.

Changes

Xero quote flow

Layer / File(s)Summary
Shared document response handling
apps/xero/api.py, stubs/xero_python/accounting/__init__.pyi
Shared authentication and document response helpers now serve invoice, quote, and purchase-order endpoints. Quote includes typed validation errors.
Provider validation errors
apps/xero/provider.py, apps/xero/tests/test_provider_documents.py
Quote creation and deletion expose element-level Xero validation messages and disable error summarization.
Quote finalization and cleanup
apps/xero/documents/quote.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py, docs/*
Quote finalization handles collisions, compensation, synchronization, and provider-total failures. Deletion works without a valid company relation. Tests and project metrics reflect the changes.

Quote PDF diagnostics

Layer / File(s)Summary
Retained diagnostic PDFs
apps/accounting/services/quote_pdf.py, apps/accounting/tests/test_quote_pdf.py
Non-matching PDFs are retained and returned through retained_pdf_path. Empty-text errors and command JSON include the retained path.

Costing draft and optimistic updates

Layer / File(s)Summary
Draft autosave and costing fields
frontend/src/features/job/costing/CostLineGrid.tsx, frontend/src/features/job/costing/useAutosaveField.ts, frontend/src/features/job/costing/CostLineGrid.test.tsx
Draft detection, free-form line kinds, derived revenue, and retry behavior now use dirty-field tracking.
Optimistic echo and rollback handling
frontend/src/features/job/costing/useCostLines.ts, frontend/src/features/job/costing/useCostLines.test.ts
Patch responses merge only requested fields, and failed deletes restore only the affected line.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant QuoteEndpoint
participant QuoteProvider
participant Xero
participant QuoteFinalizer
participant LocalDatabase
QuoteEndpoint->>QuoteProvider: create quote
QuoteProvider->>Xero: submit quote with summarize_errors=false
Xero-->>QuoteProvider: quote result and validation_errors
QuoteProvider-->>QuoteFinalizer: DocumentResult
QuoteFinalizer->>LocalDatabase: finalize or compensate local persistence
QuoteFinalizer-->>QuoteEndpoint: success or failure response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 41.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: hardening quote handling based on review findings.
Description check✅ PassedThe description explains the rationale, backend and frontend changes, specification updates, and test results, but omits the template headings and checklist.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xero/quote-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
apps/xero/tests/test_quote_manager.py (3)

208-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the adopted totals.

Adoption overwrites total_excl_tax and total_incl_tax from the provider raw payload (quote.py Lines 287-288), replacing the mirror's zeros. That overwrite is the part a regression would silently drop, because number and job_id would still look correct.

💚 Proposed assertion
 assert adopted.job_id == job.id
assert adopted.number == "QU-RAW-1"
+ assert adopted.total_excl_tax == Decimal("250.00")+ assert adopted.total_incl_tax == Decimal("287.50")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 208 - 243, Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.

272-290: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The test name promises an assertion the body does not make.

test_post_persist_failure_voids_and_names_the_id asserts the void and the empty table. It never checks that the external id appears anywhere. The raised error is RuntimeError("db gone") from the patched bump, which carries no id. Either assert the void argument or rename the test.

💚 Proposed fix
- provider.delete_quote.assert_called_once()+ external_id = provider.create_quote.return_value.external_id+ provider.delete_quote.assert_called_once_with(external_id)
assert Quote.objects.count() == 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 272 - 290, Update
test_post_persist_failure_voids_and_names_the_id to assert that
provider.delete_quote was called with the created quote’s external ID, in
addition to verifying the void operation and empty database; ensure the
assertion specifically validates the ID value rather than only call count.

244-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the safety property this test exists for.

_resolve_persist_collision documents that a row on another job must not be voided, because that would delete another job's document. The test asserts only the raise. Add the negative assertion so a future change that voids first still fails the test.

💚 Proposed assertion
 with pytest.raises(ValueError, match="different job"):
manager.create_document(breakdown=False)
++ provider.delete_quote.assert_not_called()+ assert Quote.objects.get(xero_id=external_id).job_id == other_job.id
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 244 - 270, Extend
test_mirrored_row_on_another_job_raises to retain the created mirrored Quote
and, after manager.create_document raises ValueError, assert that the other
job’s Quote remains present and unvoided. Keep the existing “different job”
exception assertion and verify the safety property enforced by
_resolve_persist_collision.
apps/xero/api.py (1)

573-589: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm the delete path never needs job.company semantics over quote.company.

The fallback selects quote.company when job.company is None. XeroQuoteManager.delete_document does not call validate_company, so the company is only bound for identity and logging. That matches the documented intent. One readability point: the chained conditional on Line 576 packs two decisions into one expression.

♻️ Optional: split the fallback
- quote = Quote.objects.filter(job=job).select_related("company").first()- company = job.company if job.company is not None else quote.company if quote else None+ quote = Quote.objects.filter(job=job).select_related("company").first()+ company = job.company+ if company is None and quote is not None:+ company = quote.company
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/api.py` around lines 573 - 589, Keep the delete path’s company
selection semantics unchanged: prefer job.company, then fall back to
quote.company, and return the existing error when neither exists. For
readability, split the chained conditional around the delete handler’s company
selection into explicit steps while preserving the
XeroQuoteManager(company=company, job=job, ...) behavior.
apps/xero/tests/test_document_api.py (1)

339-339: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document why untracked_update is used on a tracked field.

Job.untracked_update states: "Use only for migrations and bookkeeping fields that are in UNTRACKED_FIELDS." company is not in Job.UNTRACKED_FIELDS. The call is reasonable here, because the test must reproduce a legacy row state without emitting a JobEvent. Record that reason inline so the next reader does not treat it as a template for production code.

📝 Proposed comment
- Job.objects.filter(pk=job.pk).untracked_update(company=None)+ # untracked_update, not save(staff=...): the state under test is a row+ # whose company was cleared, and a tracked save would add a JobEvent+ # the scenario never produced.+ Job.objects.filter(pk=job.pk).untracked_update(company=None)

As per coding guidelines: "Comments must document the rejected obvious alternative and the factual constraint that rejected it."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_document_api.py` at line 339, Document the intentional
use of Job.objects.filter(...).untracked_update in this test: state that it
creates a legacy row state without emitting a JobEvent, and explicitly note that
normal tracked updates are rejected because they would emit that event. Make
clear this is test-only behavior and not a production usage pattern.

Source: Coding guidelines

apps/xero/provider.py (1)

340-357: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the validation-error mapping.

The same three steps now appear in create_quote (Lines 290-297), delete_quote (Lines 349-357), and _create_or_update_purchase_order (Lines 457-465): collect str(ve.message), log a warning, and return a failed DocumentResult with the joined text. A small private helper keeps one implementation of the element-error contract.

Also note Lines 347-348 build updated_quotes only to index element zero. A direct guard reads shorter.

♻️ Proposed helper
`@staticmethod`def_validation_failure(
element: Any, context: str, external_id: str|None=None
) ->DocumentResult|None:
"""Return the element-level failure result, or None when Xero accepted it."""ifnotelement.validation_errors:
returnNoneerrors= [str(ve.message) forveinelement.validation_errors]
logger.warning("Xero %s validation errors: %s", context, errors)
returnDocumentResult(
success=False,
external_id=external_id,
error=" | ".join(errors),
validation_errors=errors,
)
- updated_quotes = response.quotes or []- updated = updated_quotes[0] if updated_quotes else None- if updated is not None and updated.validation_errors:- errors = [str(ve.message) for ve in updated.validation_errors]- logger.warning("Xero quote %s delete validation errors: %s", external_id, errors)- return DocumentResult(- success=False,- external_id=external_id,- error=" | ".join(errors),- validation_errors=errors,- )+ updated = next(iter(response.quotes or []), None)+ if updated is not None:+ rejected = self._validation_failure(+ updated, f"quote {external_id} delete", external_id+ )+ if rejected is not None:+ return rejected

As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/provider.py` around lines 340 - 357, Extract the repeated
element-level validation handling from create_quote, delete_quote, and
_create_or_update_purchase_order into one private helper, such as
_validation_failure, that maps messages, logs the context, and returns the
failed DocumentResult or None. Replace each inline implementation with the
helper while preserving each operation’s context and external_id, and simplify
delete_quote to guard the first response element directly instead of building
updated_quotes solely for indexing.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/rewrite-status.md`:
- Around line 876-887: Renumber the ordered backlog list in
docs/rewrite-status.md so the new entries around the split and ultrareview
cleanup items remain consistent with all subsequent entries. Update the
following existing numbered items to shift their source numbers accordingly, or
convert the entire list to `1.` markers while preserving its rendered order.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 324-325: Update persistDraftIfReady and the draft commit flow so
edits made while draft creation is in flight are queued or reconciled and
applied to the newly created line instead of being lost. Cover both the
description edit at frontend/src/features/job/costing/CostLineGrid.tsx:324-325
and numeric edit at frontend/src/features/job/costing/CostLineGrid.tsx:384-385;
retain the draft until all later edits are persisted, and add a delayed-create
test that edits a field before the initial POST resolves.
- Around line 381-383: Update the unit_cost handling in CostLineGrid so derived
unit_rev is recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.
In `@frontend/src/features/job/costing/useCostLines.test.ts`:
- Around line 50-63: Update the test around restoreDeletedLine to make lineX the
current cached entry with its distinct rejected-optimistic unit_rev, while
keeping snapshotX at 12.00. Preserve the existing assertions that only y is
reinserted and assert restored x retains lineX’s current value, distinguishing
it from the snapshot.
---
Nitpick comments:
In `@apps/xero/api.py`:
- Around line 573-589: Keep the delete path’s company selection semantics
unchanged: prefer job.company, then fall back to quote.company, and return the
existing error when neither exists. For readability, split the chained
conditional around the delete handler’s company selection into explicit steps
while preserving the XeroQuoteManager(company=company, job=job, ...) behavior.
In `@apps/xero/provider.py`:
- Around line 340-357: Extract the repeated element-level validation handling
from create_quote, delete_quote, and _create_or_update_purchase_order into one
private helper, such as _validation_failure, that maps messages, logs the
context, and returns the failed DocumentResult or None. Replace each inline
implementation with the helper while preserving each operation’s context and
external_id, and simplify delete_quote to guard the first response element
directly instead of building updated_quotes solely for indexing.
In `@apps/xero/tests/test_document_api.py`:
- Line 339: Document the intentional use of
Job.objects.filter(...).untracked_update in this test: state that it creates a
legacy row state without emitting a JobEvent, and explicitly note that normal
tracked updates are rejected because they would emit that event. Make clear this
is test-only behavior and not a production usage pattern.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 208-243: Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.
- Around line 272-290: Update test_post_persist_failure_voids_and_names_the_id
to assert that provider.delete_quote was called with the created quote’s
external ID, in addition to verifying the void operation and empty database;
ensure the assertion specifically validates the ID value rather than only call
count.
- Around line 244-270: Extend test_mirrored_row_on_another_job_raises to retain
the created mirrored Quote and, after manager.create_document raises ValueError,
assert that the other job’s Quote remains present and unvoided. Keep the
existing “different job” exception assertion and verify the safety property
enforced by _resolve_persist_collision.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9be30f0d-b9c7-4e0e-8913-25c45c8a6bbf

📥 Commits

Reviewing files that changed from the base of the PR and between cd5943d and c426af2.

📒 Files selected for processing (16)
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/xero/api.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/code-quality.md
  • docs/rewrite-status.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.test.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • stubs/xero_python/accounting/__init__.pyi

Comment on lines +876 to +887
5. Split `apps/xero` by capability — routers and provider modules for
connection, contacts, sales documents, purchasing, sync — keeping
invoice/quote/PO domain orchestration separate. `api.py` is ~1,200 lines
and `provider.py` ~600; the shared document-endpoint adapter (landed with
the quote hardening) stops the scaffolding drift, but the file split is
deliberate post-cutover structure work.
6. Ultrareview sub-cap cleanups from the quote slice: managers read
provider-private `_sub_total`/`_total` raw keys the readonly provider
must fabricate; `EMPTY_SERVER_SHAPE` could be a `Pick<CostLineOut, ...>`;
XeroQuoteCard/JobInvoiceCard are siblings with drift; the item picker's
stock search fires per keystroke undebounced; the quote tab duplicates
the HOURS formatter; a dead "No online URL" toast.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Renumber the backlog items.

The new entries are written as 5. (Line 876) and 6. (Line 882). Lines 888 and 891 already use 5. and 6.. Markdown renumbers ordered lists on render, so the published list will read 5, 6, 7, 8, … while the source text keeps the old numbers. Anyone citing "backlog item 9" will then reference a different entry in the source than in the rendered document. Number the new entries 5. and 6. and shift the following entries, or convert the whole list to 1. markers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/rewrite-status.md` around lines 876 - 887, Renumber the ordered backlog
list in docs/rewrite-status.md so the new entries around the split and
ultrareview cleanup items remain consistent with all subsequent entries. Update
the following existing numbered items to shift their source numbers accordingly,
or convert the entire list to `1.` markers while preserving its rendered order.

Comment on lines +324 to +325
context.updateDraft(gridRow.localId, patch)
context.commitDraftField(gridRow.localId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve edits made while draft creation is in flight.

persistDraftIfReady captures the earlier draft in the create request, ignores later commits while persistingRef contains the local ID, and removes the draft on success. An edit during that request is silently lost.

  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325: queue or reconcile a description edit made after draft creation starts.
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385: queue or reconcile a numeric edit made after draft creation starts.

Keep the draft until later edits are persisted to the created line. Add a delayed-create test that edits a field before the first POST resolves.

📍 Affects 1 file
  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325 (this comment)
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 324 - 325,
Update persistDraftIfReady and the draft commit flow so edits made while draft
creation is in flight are queued or reconciled and applied to the newly created
line instead of being lost. Cover both the description edit at
frontend/src/features/job/costing/CostLineGrid.tsx:324-325 and numeric edit at
frontend/src/features/job/costing/CostLineGrid.tsx:384-385; retain the draft
until all later edits are persisted, and add a delayed-create test that edits a
field before the initial POST resolves.

Comment on lines +381 to +383
if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recalculate draft revenue after every unit-cost edit.

After a failed create, a draft can retain derived unit_rev: "12.00". If the user changes unit_cost from "10" to "20", this condition skips recalculation and the retry submits inconsistent values. The server-row path already recalculates revenue for every cost edit.

Proposed fix
- if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {+ if (fieldName === 'unit_cost' && kind !== 'time') {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if(fieldName==='unit_cost'&&kind!=='time'&&gridRow.draft.unit_rev===null){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
if(fieldName==='unit_cost'&&kind!=='time'){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 381 - 383,
Update the unit_cost handling in CostLineGrid so derived unit_rev is
recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.

Comment on lines +50 to +63
it('re-inserts only the deleted line at its index, not the whole snapshot', () => {
const lineX = line({ id: 'x', unit_rev: 'rejected-optimistic' })
const snapshotX = line({ id: 'x', unit_rev: '12.00' })
const lineY = line({ id: 'y' })
// The current cache has X already rolled back by its own PATCH failure.
const current = [line({ id: 'x', unit_rev: '12.00' })]
const snapshot = [snapshotX, lineY]
void lineX

const restored = restoreDeletedLine(current, snapshot, 'y')

expect(restored.map((entry) => entry.id)).toEqual(['x', 'y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the preservation assertion distinguish current state from the snapshot.

current[0] and snapshotX both have unit_rev: "12.00". The test therefore passes if rollback replaces the current cache with the full snapshot. Use lineX as the current entry and assert that its distinct value remains.

Proposed fix
- const current = [line({ id: 'x', unit_rev: '12.00' })]+ const current = [lineX]
@@
- expect(restored[0]!.unit_rev).toBe('12.00')+ expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[line({id: 'x',unit_rev: '12.00'})]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[lineX]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/useCostLines.test.ts` around lines 50 - 63,
Update the test around restoreDeletedLine to make lineX the current cached entry
with its distinct rejected-optimistic unit_rev, while keeping snapshotX at
12.00. Preserve the existing assertions that only y is reinserted and assert
restored x retains lineX’s current value, distinguishing it from the snapshot.

@corrin
corrin merged commit 06038e3 into mainAug 9, 2026
3 checks passed
Sign up for freeto 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

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Quote-path hardening: ultrareview + user review findings - #49

Merged
corrin merged 5 commits into
mainfrom
xero/quote-hardening
Aug 9, 2026
Merged

Quote-path hardening: ultrareview + user review findings#49
corrin merged 5 commits into
mainfrom
xero/quote-hardening

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #48, fixing every verified correctness finding from the earmarked ultrareview and the user's own review. Full E2E gate green on this branch (39/39, writes live); 1716 unit tests, coverage 88.52%.

Backend

  • Provider quote calls adopt the PO validation pattern (summarize_errors=False + element-level validation_errors): deleting an ACCEPTED quote can no longer read as success and silently drop the local mirror while the quote lives on in Xero.
  • A compensation boundary covers EVERY failure after the remote write (_finalize_created_quote): totals validation, the insert, and the timestamp bump all void the orphan best-effort with the external id carried in the error — a real Xero quote can no longer become untracked, and a retry can no longer duplicate it. The IntegrityError inside it is discriminated by state: a same-xero_id row means the sync mirrored our own quote first and is adopted (linking the job the transform never sets) — never voided; only the job-constraint race voids. The persist happens at the catch site, which the handler-contract gate verified the hard way.
  • Quote deletion unbricked: no validate_company() on the delete path, and the endpoint falls back to the quote row's own company when job.company was cleared.
  • An unsynced company refuses with a readable 400 instead of a 500 (create path).
  • Retained diagnostic PDFs report their path (inspection JSON + the no-text error).
  • One document-endpoint adapter replaces the 7×-copied scaffolding across all six push endpoints — wire-identical by construction (ninja serializes every declared field), exported schema unchanged, and the error_type drift disappears with the copies. The full apps/xero capability split is recorded as post-cutover backlog, per "finish before improving".

Frontend

  • Draft rows derive unit_rev from unit_cost — a filled phantom used to silently never POST.
  • Draft commits skip the send-dedupe — retyping the same value after a failed POST retries.
  • The autosave buffer tracks dirtiness instead of copying the server value at focus (focus in the same tick as a sibling cell's state-updating blur captured a stale render).
  • Quantity edits make the phantom real; typed free-form rows infer adjust (v1 rule restored — material means a stock pick).
  • PATCH echoes merge only their own fields; a failed delete re-inserts only its line — interleaved optimistic edits can no longer be clobbered or resurrected.

Spec

The quote spec now hard-asserts the fresh job's line state before its repair pass (exactly one missing stock binding, nothing else wrong) and all-clear after — a regression in line creation fails the gate instead of being silently healed, while the repair pass keeps exercising the grid.

Declined with evidence in the review threads: the xero-python get_quote_as_pdf claim (the pinned SDK has it; the live spec exercises it) and the compact-match regex (equivalent in power to the existing check).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved quote creation and deletion error reporting, including detailed validation messages.
    • Quote cleanup now continues successfully when company details are unavailable or remote quotes no longer exist.
    • Prevented failed quote synchronizations from leaving incomplete remote records.
    • Improved retained diagnostic PDF handling and error messages.
  • Costing Improvements

    • Draft cost lines now classify correctly and calculate revenue values more reliably.
    • Autosave can retry failed updates and prevents stale edits from overwriting newer changes.
    • Failed deletions restore only the affected cost line.

corrinand others added 5 commits August 9, 2026 20:28
…ked deletes
Provider quote calls adopt the PO pattern (summarize_errors=False +
element-level validation_errors checks) so a rejected status change — e.g.
deleting an ACCEPTED quote — can never read as success. The post-create
tail moves into _finalize_created_quote: EVERY failure after the remote
write now compensates (totals validation, persist, the timestamp bump all
void the orphan best-effort with the external id in the error), and the
IntegrityError is discriminated by state — a same-xero_id row means the
sync mirrored our own quote first and is ADOPTED (linking the job the
transform never sets), never voided; only the job-constraint race voids.
Deletion no longer requires a Xero-valid company (the quote row carries
its own), an unsynced company refuses with a readable 400 instead of a
500, and a retained diagnostic PDF reports its path.
Findings: ultrareview (2) + user review (1, 2, 5) over PR #48.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 7x-copied scaffolding (token check, failure-to-payload mapping with the
status clamp, success-invariant raise + response construction) collapses
into _xero_auth_refusal/_document_failure/_document_success. Wire-identical
by construction — ninja serializes every declared field, so explicit Nones
equal the fields each endpoint used to omit — and the exported schema is
unchanged. The error_type drift (missing only from delete_purchase_order)
disappears with the copies. Full capability split of apps/xero stays a
recorded backlog item, not a pre-cutover change.
Findings: ultrareview sub-cap cleanup + user review (4, partial).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draft rows derive unit_rev from unit_cost like server rows (a filled
phantom used to silently never POST); draft commits skip the send-dedupe so
retyping the same value after a failed POST retries; the autosave buffer
tracks dirtiness instead of copying the server value in at focus (a focus
landing in the same tick as a sibling cell's state-updating blur copied a
stale render's value); quantity edits make the phantom real; typed
free-form rows infer adjust (v1 rule — material means a stock pick); the
PATCH echo merges only its own fields so it cannot clobber an interleaved
optimistic edit; a failed delete re-inserts only its line. The quote spec
now hard-asserts the fresh job's line state before the repair pass and
all-clear after it — a line-creation regression fails instead of being
silently healed.
Findings: ultrareview (3, 5, 6, 8, 9, 10) + user review (3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The handler-contract gate rightly flagged the IntegrityError handler: its
persist lived inside the delegate where the AST cannot see it. The persist
moves to the catch, which is where it belonged anyway. 1716 tests, 88.52%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates Xero quote validation and persistence, retains diagnostic PDFs for failed inspection, and improves costing draft autosave and optimistic state reconciliation. Tests and project metrics were updated for these behaviors.

Changes

Xero quote flow

Layer / File(s)Summary
Shared document response handling
apps/xero/api.py, stubs/xero_python/accounting/__init__.pyi
Shared authentication and document response helpers now serve invoice, quote, and purchase-order endpoints. Quote includes typed validation errors.
Provider validation errors
apps/xero/provider.py, apps/xero/tests/test_provider_documents.py
Quote creation and deletion expose element-level Xero validation messages and disable error summarization.
Quote finalization and cleanup
apps/xero/documents/quote.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py, docs/*
Quote finalization handles collisions, compensation, synchronization, and provider-total failures. Deletion works without a valid company relation. Tests and project metrics reflect the changes.

Quote PDF diagnostics

Layer / File(s)Summary
Retained diagnostic PDFs
apps/accounting/services/quote_pdf.py, apps/accounting/tests/test_quote_pdf.py
Non-matching PDFs are retained and returned through retained_pdf_path. Empty-text errors and command JSON include the retained path.

Costing draft and optimistic updates

Layer / File(s)Summary
Draft autosave and costing fields
frontend/src/features/job/costing/CostLineGrid.tsx, frontend/src/features/job/costing/useAutosaveField.ts, frontend/src/features/job/costing/CostLineGrid.test.tsx
Draft detection, free-form line kinds, derived revenue, and retry behavior now use dirty-field tracking.
Optimistic echo and rollback handling
frontend/src/features/job/costing/useCostLines.ts, frontend/src/features/job/costing/useCostLines.test.ts
Patch responses merge only requested fields, and failed deletes restore only the affected line.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant QuoteEndpoint
participant QuoteProvider
participant Xero
participant QuoteFinalizer
participant LocalDatabase
QuoteEndpoint->>QuoteProvider: create quote
QuoteProvider->>Xero: submit quote with summarize_errors=false
Xero-->>QuoteProvider: quote result and validation_errors
QuoteProvider-->>QuoteFinalizer: DocumentResult
QuoteFinalizer->>LocalDatabase: finalize or compensate local persistence
QuoteFinalizer-->>QuoteEndpoint: success or failure response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 41.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: hardening quote handling based on review findings.
Description check✅ PassedThe description explains the rationale, backend and frontend changes, specification updates, and test results, but omits the template headings and checklist.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xero/quote-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
apps/xero/tests/test_quote_manager.py (3)

208-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the adopted totals.

Adoption overwrites total_excl_tax and total_incl_tax from the provider raw payload (quote.py Lines 287-288), replacing the mirror's zeros. That overwrite is the part a regression would silently drop, because number and job_id would still look correct.

💚 Proposed assertion
 assert adopted.job_id == job.id
assert adopted.number == "QU-RAW-1"
+ assert adopted.total_excl_tax == Decimal("250.00")+ assert adopted.total_incl_tax == Decimal("287.50")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 208 - 243, Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.

272-290: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The test name promises an assertion the body does not make.

test_post_persist_failure_voids_and_names_the_id asserts the void and the empty table. It never checks that the external id appears anywhere. The raised error is RuntimeError("db gone") from the patched bump, which carries no id. Either assert the void argument or rename the test.

💚 Proposed fix
- provider.delete_quote.assert_called_once()+ external_id = provider.create_quote.return_value.external_id+ provider.delete_quote.assert_called_once_with(external_id)
assert Quote.objects.count() == 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 272 - 290, Update
test_post_persist_failure_voids_and_names_the_id to assert that
provider.delete_quote was called with the created quote’s external ID, in
addition to verifying the void operation and empty database; ensure the
assertion specifically validates the ID value rather than only call count.

244-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the safety property this test exists for.

_resolve_persist_collision documents that a row on another job must not be voided, because that would delete another job's document. The test asserts only the raise. Add the negative assertion so a future change that voids first still fails the test.

💚 Proposed assertion
 with pytest.raises(ValueError, match="different job"):
manager.create_document(breakdown=False)
++ provider.delete_quote.assert_not_called()+ assert Quote.objects.get(xero_id=external_id).job_id == other_job.id
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 244 - 270, Extend
test_mirrored_row_on_another_job_raises to retain the created mirrored Quote
and, after manager.create_document raises ValueError, assert that the other
job’s Quote remains present and unvoided. Keep the existing “different job”
exception assertion and verify the safety property enforced by
_resolve_persist_collision.
apps/xero/api.py (1)

573-589: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm the delete path never needs job.company semantics over quote.company.

The fallback selects quote.company when job.company is None. XeroQuoteManager.delete_document does not call validate_company, so the company is only bound for identity and logging. That matches the documented intent. One readability point: the chained conditional on Line 576 packs two decisions into one expression.

♻️ Optional: split the fallback
- quote = Quote.objects.filter(job=job).select_related("company").first()- company = job.company if job.company is not None else quote.company if quote else None+ quote = Quote.objects.filter(job=job).select_related("company").first()+ company = job.company+ if company is None and quote is not None:+ company = quote.company
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/api.py` around lines 573 - 589, Keep the delete path’s company
selection semantics unchanged: prefer job.company, then fall back to
quote.company, and return the existing error when neither exists. For
readability, split the chained conditional around the delete handler’s company
selection into explicit steps while preserving the
XeroQuoteManager(company=company, job=job, ...) behavior.
apps/xero/tests/test_document_api.py (1)

339-339: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document why untracked_update is used on a tracked field.

Job.untracked_update states: "Use only for migrations and bookkeeping fields that are in UNTRACKED_FIELDS." company is not in Job.UNTRACKED_FIELDS. The call is reasonable here, because the test must reproduce a legacy row state without emitting a JobEvent. Record that reason inline so the next reader does not treat it as a template for production code.

📝 Proposed comment
- Job.objects.filter(pk=job.pk).untracked_update(company=None)+ # untracked_update, not save(staff=...): the state under test is a row+ # whose company was cleared, and a tracked save would add a JobEvent+ # the scenario never produced.+ Job.objects.filter(pk=job.pk).untracked_update(company=None)

As per coding guidelines: "Comments must document the rejected obvious alternative and the factual constraint that rejected it."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_document_api.py` at line 339, Document the intentional
use of Job.objects.filter(...).untracked_update in this test: state that it
creates a legacy row state without emitting a JobEvent, and explicitly note that
normal tracked updates are rejected because they would emit that event. Make
clear this is test-only behavior and not a production usage pattern.

Source: Coding guidelines

apps/xero/provider.py (1)

340-357: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the validation-error mapping.

The same three steps now appear in create_quote (Lines 290-297), delete_quote (Lines 349-357), and _create_or_update_purchase_order (Lines 457-465): collect str(ve.message), log a warning, and return a failed DocumentResult with the joined text. A small private helper keeps one implementation of the element-error contract.

Also note Lines 347-348 build updated_quotes only to index element zero. A direct guard reads shorter.

♻️ Proposed helper
`@staticmethod`def_validation_failure(
element: Any, context: str, external_id: str|None=None
) ->DocumentResult|None:
"""Return the element-level failure result, or None when Xero accepted it."""ifnotelement.validation_errors:
returnNoneerrors= [str(ve.message) forveinelement.validation_errors]
logger.warning("Xero %s validation errors: %s", context, errors)
returnDocumentResult(
success=False,
external_id=external_id,
error=" | ".join(errors),
validation_errors=errors,
)
- updated_quotes = response.quotes or []- updated = updated_quotes[0] if updated_quotes else None- if updated is not None and updated.validation_errors:- errors = [str(ve.message) for ve in updated.validation_errors]- logger.warning("Xero quote %s delete validation errors: %s", external_id, errors)- return DocumentResult(- success=False,- external_id=external_id,- error=" | ".join(errors),- validation_errors=errors,- )+ updated = next(iter(response.quotes or []), None)+ if updated is not None:+ rejected = self._validation_failure(+ updated, f"quote {external_id} delete", external_id+ )+ if rejected is not None:+ return rejected

As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/provider.py` around lines 340 - 357, Extract the repeated
element-level validation handling from create_quote, delete_quote, and
_create_or_update_purchase_order into one private helper, such as
_validation_failure, that maps messages, logs the context, and returns the
failed DocumentResult or None. Replace each inline implementation with the
helper while preserving each operation’s context and external_id, and simplify
delete_quote to guard the first response element directly instead of building
updated_quotes solely for indexing.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/rewrite-status.md`:
- Around line 876-887: Renumber the ordered backlog list in
docs/rewrite-status.md so the new entries around the split and ultrareview
cleanup items remain consistent with all subsequent entries. Update the
following existing numbered items to shift their source numbers accordingly, or
convert the entire list to `1.` markers while preserving its rendered order.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 324-325: Update persistDraftIfReady and the draft commit flow so
edits made while draft creation is in flight are queued or reconciled and
applied to the newly created line instead of being lost. Cover both the
description edit at frontend/src/features/job/costing/CostLineGrid.tsx:324-325
and numeric edit at frontend/src/features/job/costing/CostLineGrid.tsx:384-385;
retain the draft until all later edits are persisted, and add a delayed-create
test that edits a field before the initial POST resolves.
- Around line 381-383: Update the unit_cost handling in CostLineGrid so derived
unit_rev is recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.
In `@frontend/src/features/job/costing/useCostLines.test.ts`:
- Around line 50-63: Update the test around restoreDeletedLine to make lineX the
current cached entry with its distinct rejected-optimistic unit_rev, while
keeping snapshotX at 12.00. Preserve the existing assertions that only y is
reinserted and assert restored x retains lineX’s current value, distinguishing
it from the snapshot.
---
Nitpick comments:
In `@apps/xero/api.py`:
- Around line 573-589: Keep the delete path’s company selection semantics
unchanged: prefer job.company, then fall back to quote.company, and return the
existing error when neither exists. For readability, split the chained
conditional around the delete handler’s company selection into explicit steps
while preserving the XeroQuoteManager(company=company, job=job, ...) behavior.
In `@apps/xero/provider.py`:
- Around line 340-357: Extract the repeated element-level validation handling
from create_quote, delete_quote, and _create_or_update_purchase_order into one
private helper, such as _validation_failure, that maps messages, logs the
context, and returns the failed DocumentResult or None. Replace each inline
implementation with the helper while preserving each operation’s context and
external_id, and simplify delete_quote to guard the first response element
directly instead of building updated_quotes solely for indexing.
In `@apps/xero/tests/test_document_api.py`:
- Line 339: Document the intentional use of
Job.objects.filter(...).untracked_update in this test: state that it creates a
legacy row state without emitting a JobEvent, and explicitly note that normal
tracked updates are rejected because they would emit that event. Make clear this
is test-only behavior and not a production usage pattern.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 208-243: Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.
- Around line 272-290: Update test_post_persist_failure_voids_and_names_the_id
to assert that provider.delete_quote was called with the created quote’s
external ID, in addition to verifying the void operation and empty database;
ensure the assertion specifically validates the ID value rather than only call
count.
- Around line 244-270: Extend test_mirrored_row_on_another_job_raises to retain
the created mirrored Quote and, after manager.create_document raises ValueError,
assert that the other job’s Quote remains present and unvoided. Keep the
existing “different job” exception assertion and verify the safety property
enforced by _resolve_persist_collision.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9be30f0d-b9c7-4e0e-8913-25c45c8a6bbf

📥 Commits

Reviewing files that changed from the base of the PR and between cd5943d and c426af2.

📒 Files selected for processing (16)
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/xero/api.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/code-quality.md
  • docs/rewrite-status.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.test.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • stubs/xero_python/accounting/__init__.pyi

Comment on lines +876 to +887
5. Split `apps/xero` by capability — routers and provider modules for
connection, contacts, sales documents, purchasing, sync — keeping
invoice/quote/PO domain orchestration separate. `api.py` is ~1,200 lines
and `provider.py` ~600; the shared document-endpoint adapter (landed with
the quote hardening) stops the scaffolding drift, but the file split is
deliberate post-cutover structure work.
6. Ultrareview sub-cap cleanups from the quote slice: managers read
provider-private `_sub_total`/`_total` raw keys the readonly provider
must fabricate; `EMPTY_SERVER_SHAPE` could be a `Pick<CostLineOut, ...>`;
XeroQuoteCard/JobInvoiceCard are siblings with drift; the item picker's
stock search fires per keystroke undebounced; the quote tab duplicates
the HOURS formatter; a dead "No online URL" toast.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Renumber the backlog items.

The new entries are written as 5. (Line 876) and 6. (Line 882). Lines 888 and 891 already use 5. and 6.. Markdown renumbers ordered lists on render, so the published list will read 5, 6, 7, 8, … while the source text keeps the old numbers. Anyone citing "backlog item 9" will then reference a different entry in the source than in the rendered document. Number the new entries 5. and 6. and shift the following entries, or convert the whole list to 1. markers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/rewrite-status.md` around lines 876 - 887, Renumber the ordered backlog
list in docs/rewrite-status.md so the new entries around the split and
ultrareview cleanup items remain consistent with all subsequent entries. Update
the following existing numbered items to shift their source numbers accordingly,
or convert the entire list to `1.` markers while preserving its rendered order.

Comment on lines +324 to +325
context.updateDraft(gridRow.localId, patch)
context.commitDraftField(gridRow.localId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve edits made while draft creation is in flight.

persistDraftIfReady captures the earlier draft in the create request, ignores later commits while persistingRef contains the local ID, and removes the draft on success. An edit during that request is silently lost.

  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325: queue or reconcile a description edit made after draft creation starts.
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385: queue or reconcile a numeric edit made after draft creation starts.

Keep the draft until later edits are persisted to the created line. Add a delayed-create test that edits a field before the first POST resolves.

📍 Affects 1 file
  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325 (this comment)
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 324 - 325,
Update persistDraftIfReady and the draft commit flow so edits made while draft
creation is in flight are queued or reconciled and applied to the newly created
line instead of being lost. Cover both the description edit at
frontend/src/features/job/costing/CostLineGrid.tsx:324-325 and numeric edit at
frontend/src/features/job/costing/CostLineGrid.tsx:384-385; retain the draft
until all later edits are persisted, and add a delayed-create test that edits a
field before the initial POST resolves.

Comment on lines +381 to +383
if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recalculate draft revenue after every unit-cost edit.

After a failed create, a draft can retain derived unit_rev: "12.00". If the user changes unit_cost from "10" to "20", this condition skips recalculation and the retry submits inconsistent values. The server-row path already recalculates revenue for every cost edit.

Proposed fix
- if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {+ if (fieldName === 'unit_cost' && kind !== 'time') {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if(fieldName==='unit_cost'&&kind!=='time'&&gridRow.draft.unit_rev===null){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
if(fieldName==='unit_cost'&&kind!=='time'){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 381 - 383,
Update the unit_cost handling in CostLineGrid so derived unit_rev is
recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.

Comment on lines +50 to +63
it('re-inserts only the deleted line at its index, not the whole snapshot', () => {
const lineX = line({ id: 'x', unit_rev: 'rejected-optimistic' })
const snapshotX = line({ id: 'x', unit_rev: '12.00' })
const lineY = line({ id: 'y' })
// The current cache has X already rolled back by its own PATCH failure.
const current = [line({ id: 'x', unit_rev: '12.00' })]
const snapshot = [snapshotX, lineY]
void lineX

const restored = restoreDeletedLine(current, snapshot, 'y')

expect(restored.map((entry) => entry.id)).toEqual(['x', 'y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the preservation assertion distinguish current state from the snapshot.

current[0] and snapshotX both have unit_rev: "12.00". The test therefore passes if rollback replaces the current cache with the full snapshot. Use lineX as the current entry and assert that its distinct value remains.

Proposed fix
- const current = [line({ id: 'x', unit_rev: '12.00' })]+ const current = [lineX]
@@
- expect(restored[0]!.unit_rev).toBe('12.00')+ expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[line({id: 'x',unit_rev: '12.00'})]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[lineX]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/useCostLines.test.ts` around lines 50 - 63,
Update the test around restoreDeletedLine to make lineX the current cached entry
with its distinct rejected-optimistic unit_rev, while keeping snapshotX at
12.00. Preserve the existing assertions that only y is reinserted and assert
restored x retains lineX’s current value, distinguishing it from the snapshot.

@corrin
corrin merged commit 06038e3 into mainAug 9, 2026
3 checks passed
Sign up for freeto 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

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Quote-path hardening: ultrareview + user review findings - #49

Merged
corrin merged 5 commits into
mainfrom
xero/quote-hardening
Aug 9, 2026
Merged

Quote-path hardening: ultrareview + user review findings#49
corrin merged 5 commits into
mainfrom
xero/quote-hardening

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #48, fixing every verified correctness finding from the earmarked ultrareview and the user's own review. Full E2E gate green on this branch (39/39, writes live); 1716 unit tests, coverage 88.52%.

Backend

  • Provider quote calls adopt the PO validation pattern (summarize_errors=False + element-level validation_errors): deleting an ACCEPTED quote can no longer read as success and silently drop the local mirror while the quote lives on in Xero.
  • A compensation boundary covers EVERY failure after the remote write (_finalize_created_quote): totals validation, the insert, and the timestamp bump all void the orphan best-effort with the external id carried in the error — a real Xero quote can no longer become untracked, and a retry can no longer duplicate it. The IntegrityError inside it is discriminated by state: a same-xero_id row means the sync mirrored our own quote first and is adopted (linking the job the transform never sets) — never voided; only the job-constraint race voids. The persist happens at the catch site, which the handler-contract gate verified the hard way.
  • Quote deletion unbricked: no validate_company() on the delete path, and the endpoint falls back to the quote row's own company when job.company was cleared.
  • An unsynced company refuses with a readable 400 instead of a 500 (create path).
  • Retained diagnostic PDFs report their path (inspection JSON + the no-text error).
  • One document-endpoint adapter replaces the 7×-copied scaffolding across all six push endpoints — wire-identical by construction (ninja serializes every declared field), exported schema unchanged, and the error_type drift disappears with the copies. The full apps/xero capability split is recorded as post-cutover backlog, per "finish before improving".

Frontend

  • Draft rows derive unit_rev from unit_cost — a filled phantom used to silently never POST.
  • Draft commits skip the send-dedupe — retyping the same value after a failed POST retries.
  • The autosave buffer tracks dirtiness instead of copying the server value at focus (focus in the same tick as a sibling cell's state-updating blur captured a stale render).
  • Quantity edits make the phantom real; typed free-form rows infer adjust (v1 rule restored — material means a stock pick).
  • PATCH echoes merge only their own fields; a failed delete re-inserts only its line — interleaved optimistic edits can no longer be clobbered or resurrected.

Spec

The quote spec now hard-asserts the fresh job's line state before its repair pass (exactly one missing stock binding, nothing else wrong) and all-clear after — a regression in line creation fails the gate instead of being silently healed, while the repair pass keeps exercising the grid.

Declined with evidence in the review threads: the xero-python get_quote_as_pdf claim (the pinned SDK has it; the live spec exercises it) and the compact-match regex (equivalent in power to the existing check).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved quote creation and deletion error reporting, including detailed validation messages.
    • Quote cleanup now continues successfully when company details are unavailable or remote quotes no longer exist.
    • Prevented failed quote synchronizations from leaving incomplete remote records.
    • Improved retained diagnostic PDF handling and error messages.
  • Costing Improvements

    • Draft cost lines now classify correctly and calculate revenue values more reliably.
    • Autosave can retry failed updates and prevents stale edits from overwriting newer changes.
    • Failed deletions restore only the affected cost line.

corrinand others added 5 commits August 9, 2026 20:28
…ked deletes
Provider quote calls adopt the PO pattern (summarize_errors=False +
element-level validation_errors checks) so a rejected status change — e.g.
deleting an ACCEPTED quote — can never read as success. The post-create
tail moves into _finalize_created_quote: EVERY failure after the remote
write now compensates (totals validation, persist, the timestamp bump all
void the orphan best-effort with the external id in the error), and the
IntegrityError is discriminated by state — a same-xero_id row means the
sync mirrored our own quote first and is ADOPTED (linking the job the
transform never sets), never voided; only the job-constraint race voids.
Deletion no longer requires a Xero-valid company (the quote row carries
its own), an unsynced company refuses with a readable 400 instead of a
500, and a retained diagnostic PDF reports its path.
Findings: ultrareview (2) + user review (1, 2, 5) over PR #48.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 7x-copied scaffolding (token check, failure-to-payload mapping with the
status clamp, success-invariant raise + response construction) collapses
into _xero_auth_refusal/_document_failure/_document_success. Wire-identical
by construction — ninja serializes every declared field, so explicit Nones
equal the fields each endpoint used to omit — and the exported schema is
unchanged. The error_type drift (missing only from delete_purchase_order)
disappears with the copies. Full capability split of apps/xero stays a
recorded backlog item, not a pre-cutover change.
Findings: ultrareview sub-cap cleanup + user review (4, partial).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draft rows derive unit_rev from unit_cost like server rows (a filled
phantom used to silently never POST); draft commits skip the send-dedupe so
retyping the same value after a failed POST retries; the autosave buffer
tracks dirtiness instead of copying the server value in at focus (a focus
landing in the same tick as a sibling cell's state-updating blur copied a
stale render's value); quantity edits make the phantom real; typed
free-form rows infer adjust (v1 rule — material means a stock pick); the
PATCH echo merges only its own fields so it cannot clobber an interleaved
optimistic edit; a failed delete re-inserts only its line. The quote spec
now hard-asserts the fresh job's line state before the repair pass and
all-clear after it — a line-creation regression fails instead of being
silently healed.
Findings: ultrareview (3, 5, 6, 8, 9, 10) + user review (3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The handler-contract gate rightly flagged the IntegrityError handler: its
persist lived inside the delegate where the AST cannot see it. The persist
moves to the catch, which is where it belonged anyway. 1716 tests, 88.52%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates Xero quote validation and persistence, retains diagnostic PDFs for failed inspection, and improves costing draft autosave and optimistic state reconciliation. Tests and project metrics were updated for these behaviors.

Changes

Xero quote flow

Layer / File(s)Summary
Shared document response handling
apps/xero/api.py, stubs/xero_python/accounting/__init__.pyi
Shared authentication and document response helpers now serve invoice, quote, and purchase-order endpoints. Quote includes typed validation errors.
Provider validation errors
apps/xero/provider.py, apps/xero/tests/test_provider_documents.py
Quote creation and deletion expose element-level Xero validation messages and disable error summarization.
Quote finalization and cleanup
apps/xero/documents/quote.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py, docs/*
Quote finalization handles collisions, compensation, synchronization, and provider-total failures. Deletion works without a valid company relation. Tests and project metrics reflect the changes.

Quote PDF diagnostics

Layer / File(s)Summary
Retained diagnostic PDFs
apps/accounting/services/quote_pdf.py, apps/accounting/tests/test_quote_pdf.py
Non-matching PDFs are retained and returned through retained_pdf_path. Empty-text errors and command JSON include the retained path.

Costing draft and optimistic updates

Layer / File(s)Summary
Draft autosave and costing fields
frontend/src/features/job/costing/CostLineGrid.tsx, frontend/src/features/job/costing/useAutosaveField.ts, frontend/src/features/job/costing/CostLineGrid.test.tsx
Draft detection, free-form line kinds, derived revenue, and retry behavior now use dirty-field tracking.
Optimistic echo and rollback handling
frontend/src/features/job/costing/useCostLines.ts, frontend/src/features/job/costing/useCostLines.test.ts
Patch responses merge only requested fields, and failed deletes restore only the affected line.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant QuoteEndpoint
participant QuoteProvider
participant Xero
participant QuoteFinalizer
participant LocalDatabase
QuoteEndpoint->>QuoteProvider: create quote
QuoteProvider->>Xero: submit quote with summarize_errors=false
Xero-->>QuoteProvider: quote result and validation_errors
QuoteProvider-->>QuoteFinalizer: DocumentResult
QuoteFinalizer->>LocalDatabase: finalize or compensate local persistence
QuoteFinalizer-->>QuoteEndpoint: success or failure response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 41.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: hardening quote handling based on review findings.
Description check✅ PassedThe description explains the rationale, backend and frontend changes, specification updates, and test results, but omits the template headings and checklist.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xero/quote-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
apps/xero/tests/test_quote_manager.py (3)

208-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the adopted totals.

Adoption overwrites total_excl_tax and total_incl_tax from the provider raw payload (quote.py Lines 287-288), replacing the mirror's zeros. That overwrite is the part a regression would silently drop, because number and job_id would still look correct.

💚 Proposed assertion
 assert adopted.job_id == job.id
assert adopted.number == "QU-RAW-1"
+ assert adopted.total_excl_tax == Decimal("250.00")+ assert adopted.total_incl_tax == Decimal("287.50")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 208 - 243, Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.

272-290: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The test name promises an assertion the body does not make.

test_post_persist_failure_voids_and_names_the_id asserts the void and the empty table. It never checks that the external id appears anywhere. The raised error is RuntimeError("db gone") from the patched bump, which carries no id. Either assert the void argument or rename the test.

💚 Proposed fix
- provider.delete_quote.assert_called_once()+ external_id = provider.create_quote.return_value.external_id+ provider.delete_quote.assert_called_once_with(external_id)
assert Quote.objects.count() == 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 272 - 290, Update
test_post_persist_failure_voids_and_names_the_id to assert that
provider.delete_quote was called with the created quote’s external ID, in
addition to verifying the void operation and empty database; ensure the
assertion specifically validates the ID value rather than only call count.

244-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the safety property this test exists for.

_resolve_persist_collision documents that a row on another job must not be voided, because that would delete another job's document. The test asserts only the raise. Add the negative assertion so a future change that voids first still fails the test.

💚 Proposed assertion
 with pytest.raises(ValueError, match="different job"):
manager.create_document(breakdown=False)
++ provider.delete_quote.assert_not_called()+ assert Quote.objects.get(xero_id=external_id).job_id == other_job.id
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 244 - 270, Extend
test_mirrored_row_on_another_job_raises to retain the created mirrored Quote
and, after manager.create_document raises ValueError, assert that the other
job’s Quote remains present and unvoided. Keep the existing “different job”
exception assertion and verify the safety property enforced by
_resolve_persist_collision.
apps/xero/api.py (1)

573-589: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm the delete path never needs job.company semantics over quote.company.

The fallback selects quote.company when job.company is None. XeroQuoteManager.delete_document does not call validate_company, so the company is only bound for identity and logging. That matches the documented intent. One readability point: the chained conditional on Line 576 packs two decisions into one expression.

♻️ Optional: split the fallback
- quote = Quote.objects.filter(job=job).select_related("company").first()- company = job.company if job.company is not None else quote.company if quote else None+ quote = Quote.objects.filter(job=job).select_related("company").first()+ company = job.company+ if company is None and quote is not None:+ company = quote.company
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/api.py` around lines 573 - 589, Keep the delete path’s company
selection semantics unchanged: prefer job.company, then fall back to
quote.company, and return the existing error when neither exists. For
readability, split the chained conditional around the delete handler’s company
selection into explicit steps while preserving the
XeroQuoteManager(company=company, job=job, ...) behavior.
apps/xero/tests/test_document_api.py (1)

339-339: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document why untracked_update is used on a tracked field.

Job.untracked_update states: "Use only for migrations and bookkeeping fields that are in UNTRACKED_FIELDS." company is not in Job.UNTRACKED_FIELDS. The call is reasonable here, because the test must reproduce a legacy row state without emitting a JobEvent. Record that reason inline so the next reader does not treat it as a template for production code.

📝 Proposed comment
- Job.objects.filter(pk=job.pk).untracked_update(company=None)+ # untracked_update, not save(staff=...): the state under test is a row+ # whose company was cleared, and a tracked save would add a JobEvent+ # the scenario never produced.+ Job.objects.filter(pk=job.pk).untracked_update(company=None)

As per coding guidelines: "Comments must document the rejected obvious alternative and the factual constraint that rejected it."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_document_api.py` at line 339, Document the intentional
use of Job.objects.filter(...).untracked_update in this test: state that it
creates a legacy row state without emitting a JobEvent, and explicitly note that
normal tracked updates are rejected because they would emit that event. Make
clear this is test-only behavior and not a production usage pattern.

Source: Coding guidelines

apps/xero/provider.py (1)

340-357: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the validation-error mapping.

The same three steps now appear in create_quote (Lines 290-297), delete_quote (Lines 349-357), and _create_or_update_purchase_order (Lines 457-465): collect str(ve.message), log a warning, and return a failed DocumentResult with the joined text. A small private helper keeps one implementation of the element-error contract.

Also note Lines 347-348 build updated_quotes only to index element zero. A direct guard reads shorter.

♻️ Proposed helper
`@staticmethod`def_validation_failure(
element: Any, context: str, external_id: str|None=None
) ->DocumentResult|None:
"""Return the element-level failure result, or None when Xero accepted it."""ifnotelement.validation_errors:
returnNoneerrors= [str(ve.message) forveinelement.validation_errors]
logger.warning("Xero %s validation errors: %s", context, errors)
returnDocumentResult(
success=False,
external_id=external_id,
error=" | ".join(errors),
validation_errors=errors,
)
- updated_quotes = response.quotes or []- updated = updated_quotes[0] if updated_quotes else None- if updated is not None and updated.validation_errors:- errors = [str(ve.message) for ve in updated.validation_errors]- logger.warning("Xero quote %s delete validation errors: %s", external_id, errors)- return DocumentResult(- success=False,- external_id=external_id,- error=" | ".join(errors),- validation_errors=errors,- )+ updated = next(iter(response.quotes or []), None)+ if updated is not None:+ rejected = self._validation_failure(+ updated, f"quote {external_id} delete", external_id+ )+ if rejected is not None:+ return rejected

As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/provider.py` around lines 340 - 357, Extract the repeated
element-level validation handling from create_quote, delete_quote, and
_create_or_update_purchase_order into one private helper, such as
_validation_failure, that maps messages, logs the context, and returns the
failed DocumentResult or None. Replace each inline implementation with the
helper while preserving each operation’s context and external_id, and simplify
delete_quote to guard the first response element directly instead of building
updated_quotes solely for indexing.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/rewrite-status.md`:
- Around line 876-887: Renumber the ordered backlog list in
docs/rewrite-status.md so the new entries around the split and ultrareview
cleanup items remain consistent with all subsequent entries. Update the
following existing numbered items to shift their source numbers accordingly, or
convert the entire list to `1.` markers while preserving its rendered order.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 324-325: Update persistDraftIfReady and the draft commit flow so
edits made while draft creation is in flight are queued or reconciled and
applied to the newly created line instead of being lost. Cover both the
description edit at frontend/src/features/job/costing/CostLineGrid.tsx:324-325
and numeric edit at frontend/src/features/job/costing/CostLineGrid.tsx:384-385;
retain the draft until all later edits are persisted, and add a delayed-create
test that edits a field before the initial POST resolves.
- Around line 381-383: Update the unit_cost handling in CostLineGrid so derived
unit_rev is recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.
In `@frontend/src/features/job/costing/useCostLines.test.ts`:
- Around line 50-63: Update the test around restoreDeletedLine to make lineX the
current cached entry with its distinct rejected-optimistic unit_rev, while
keeping snapshotX at 12.00. Preserve the existing assertions that only y is
reinserted and assert restored x retains lineX’s current value, distinguishing
it from the snapshot.
---
Nitpick comments:
In `@apps/xero/api.py`:
- Around line 573-589: Keep the delete path’s company selection semantics
unchanged: prefer job.company, then fall back to quote.company, and return the
existing error when neither exists. For readability, split the chained
conditional around the delete handler’s company selection into explicit steps
while preserving the XeroQuoteManager(company=company, job=job, ...) behavior.
In `@apps/xero/provider.py`:
- Around line 340-357: Extract the repeated element-level validation handling
from create_quote, delete_quote, and _create_or_update_purchase_order into one
private helper, such as _validation_failure, that maps messages, logs the
context, and returns the failed DocumentResult or None. Replace each inline
implementation with the helper while preserving each operation’s context and
external_id, and simplify delete_quote to guard the first response element
directly instead of building updated_quotes solely for indexing.
In `@apps/xero/tests/test_document_api.py`:
- Line 339: Document the intentional use of
Job.objects.filter(...).untracked_update in this test: state that it creates a
legacy row state without emitting a JobEvent, and explicitly note that normal
tracked updates are rejected because they would emit that event. Make clear this
is test-only behavior and not a production usage pattern.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 208-243: Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.
- Around line 272-290: Update test_post_persist_failure_voids_and_names_the_id
to assert that provider.delete_quote was called with the created quote’s
external ID, in addition to verifying the void operation and empty database;
ensure the assertion specifically validates the ID value rather than only call
count.
- Around line 244-270: Extend test_mirrored_row_on_another_job_raises to retain
the created mirrored Quote and, after manager.create_document raises ValueError,
assert that the other job’s Quote remains present and unvoided. Keep the
existing “different job” exception assertion and verify the safety property
enforced by _resolve_persist_collision.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9be30f0d-b9c7-4e0e-8913-25c45c8a6bbf

📥 Commits

Reviewing files that changed from the base of the PR and between cd5943d and c426af2.

📒 Files selected for processing (16)
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/xero/api.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/code-quality.md
  • docs/rewrite-status.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.test.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • stubs/xero_python/accounting/__init__.pyi

Comment on lines +876 to +887
5. Split `apps/xero` by capability — routers and provider modules for
connection, contacts, sales documents, purchasing, sync — keeping
invoice/quote/PO domain orchestration separate. `api.py` is ~1,200 lines
and `provider.py` ~600; the shared document-endpoint adapter (landed with
the quote hardening) stops the scaffolding drift, but the file split is
deliberate post-cutover structure work.
6. Ultrareview sub-cap cleanups from the quote slice: managers read
provider-private `_sub_total`/`_total` raw keys the readonly provider
must fabricate; `EMPTY_SERVER_SHAPE` could be a `Pick<CostLineOut, ...>`;
XeroQuoteCard/JobInvoiceCard are siblings with drift; the item picker's
stock search fires per keystroke undebounced; the quote tab duplicates
the HOURS formatter; a dead "No online URL" toast.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Renumber the backlog items.

The new entries are written as 5. (Line 876) and 6. (Line 882). Lines 888 and 891 already use 5. and 6.. Markdown renumbers ordered lists on render, so the published list will read 5, 6, 7, 8, … while the source text keeps the old numbers. Anyone citing "backlog item 9" will then reference a different entry in the source than in the rendered document. Number the new entries 5. and 6. and shift the following entries, or convert the whole list to 1. markers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/rewrite-status.md` around lines 876 - 887, Renumber the ordered backlog
list in docs/rewrite-status.md so the new entries around the split and
ultrareview cleanup items remain consistent with all subsequent entries. Update
the following existing numbered items to shift their source numbers accordingly,
or convert the entire list to `1.` markers while preserving its rendered order.

Comment on lines +324 to +325
context.updateDraft(gridRow.localId, patch)
context.commitDraftField(gridRow.localId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve edits made while draft creation is in flight.

persistDraftIfReady captures the earlier draft in the create request, ignores later commits while persistingRef contains the local ID, and removes the draft on success. An edit during that request is silently lost.

  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325: queue or reconcile a description edit made after draft creation starts.
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385: queue or reconcile a numeric edit made after draft creation starts.

Keep the draft until later edits are persisted to the created line. Add a delayed-create test that edits a field before the first POST resolves.

📍 Affects 1 file
  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325 (this comment)
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 324 - 325,
Update persistDraftIfReady and the draft commit flow so edits made while draft
creation is in flight are queued or reconciled and applied to the newly created
line instead of being lost. Cover both the description edit at
frontend/src/features/job/costing/CostLineGrid.tsx:324-325 and numeric edit at
frontend/src/features/job/costing/CostLineGrid.tsx:384-385; retain the draft
until all later edits are persisted, and add a delayed-create test that edits a
field before the initial POST resolves.

Comment on lines +381 to +383
if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recalculate draft revenue after every unit-cost edit.

After a failed create, a draft can retain derived unit_rev: "12.00". If the user changes unit_cost from "10" to "20", this condition skips recalculation and the retry submits inconsistent values. The server-row path already recalculates revenue for every cost edit.

Proposed fix
- if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {+ if (fieldName === 'unit_cost' && kind !== 'time') {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if(fieldName==='unit_cost'&&kind!=='time'&&gridRow.draft.unit_rev===null){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
if(fieldName==='unit_cost'&&kind!=='time'){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 381 - 383,
Update the unit_cost handling in CostLineGrid so derived unit_rev is
recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.

Comment on lines +50 to +63
it('re-inserts only the deleted line at its index, not the whole snapshot', () => {
const lineX = line({ id: 'x', unit_rev: 'rejected-optimistic' })
const snapshotX = line({ id: 'x', unit_rev: '12.00' })
const lineY = line({ id: 'y' })
// The current cache has X already rolled back by its own PATCH failure.
const current = [line({ id: 'x', unit_rev: '12.00' })]
const snapshot = [snapshotX, lineY]
void lineX

const restored = restoreDeletedLine(current, snapshot, 'y')

expect(restored.map((entry) => entry.id)).toEqual(['x', 'y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the preservation assertion distinguish current state from the snapshot.

current[0] and snapshotX both have unit_rev: "12.00". The test therefore passes if rollback replaces the current cache with the full snapshot. Use lineX as the current entry and assert that its distinct value remains.

Proposed fix
- const current = [line({ id: 'x', unit_rev: '12.00' })]+ const current = [lineX]
@@
- expect(restored[0]!.unit_rev).toBe('12.00')+ expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[line({id: 'x',unit_rev: '12.00'})]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[lineX]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/useCostLines.test.ts` around lines 50 - 63,
Update the test around restoreDeletedLine to make lineX the current cached entry
with its distinct rejected-optimistic unit_rev, while keeping snapshotX at
12.00. Preserve the existing assertions that only y is reinserted and assert
restored x retains lineX’s current value, distinguishing it from the snapshot.

@corrin
corrin merged commit 06038e3 into mainAug 9, 2026
3 checks passed
Sign up for freeto 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

@corrin
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Quote-path hardening: ultrareview + user review findings - #49

Merged
corrin merged 5 commits into
mainfrom
xero/quote-hardening
Aug 9, 2026
Merged

Quote-path hardening: ultrareview + user review findings#49
corrin merged 5 commits into
mainfrom
xero/quote-hardening

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #48, fixing every verified correctness finding from the earmarked ultrareview and the user's own review. Full E2E gate green on this branch (39/39, writes live); 1716 unit tests, coverage 88.52%.

Backend

  • Provider quote calls adopt the PO validation pattern (summarize_errors=False + element-level validation_errors): deleting an ACCEPTED quote can no longer read as success and silently drop the local mirror while the quote lives on in Xero.
  • A compensation boundary covers EVERY failure after the remote write (_finalize_created_quote): totals validation, the insert, and the timestamp bump all void the orphan best-effort with the external id carried in the error — a real Xero quote can no longer become untracked, and a retry can no longer duplicate it. The IntegrityError inside it is discriminated by state: a same-xero_id row means the sync mirrored our own quote first and is adopted (linking the job the transform never sets) — never voided; only the job-constraint race voids. The persist happens at the catch site, which the handler-contract gate verified the hard way.
  • Quote deletion unbricked: no validate_company() on the delete path, and the endpoint falls back to the quote row's own company when job.company was cleared.
  • An unsynced company refuses with a readable 400 instead of a 500 (create path).
  • Retained diagnostic PDFs report their path (inspection JSON + the no-text error).
  • One document-endpoint adapter replaces the 7×-copied scaffolding across all six push endpoints — wire-identical by construction (ninja serializes every declared field), exported schema unchanged, and the error_type drift disappears with the copies. The full apps/xero capability split is recorded as post-cutover backlog, per "finish before improving".

Frontend

  • Draft rows derive unit_rev from unit_cost — a filled phantom used to silently never POST.
  • Draft commits skip the send-dedupe — retyping the same value after a failed POST retries.
  • The autosave buffer tracks dirtiness instead of copying the server value at focus (focus in the same tick as a sibling cell's state-updating blur captured a stale render).
  • Quantity edits make the phantom real; typed free-form rows infer adjust (v1 rule restored — material means a stock pick).
  • PATCH echoes merge only their own fields; a failed delete re-inserts only its line — interleaved optimistic edits can no longer be clobbered or resurrected.

Spec

The quote spec now hard-asserts the fresh job's line state before its repair pass (exactly one missing stock binding, nothing else wrong) and all-clear after — a regression in line creation fails the gate instead of being silently healed, while the repair pass keeps exercising the grid.

Declined with evidence in the review threads: the xero-python get_quote_as_pdf claim (the pinned SDK has it; the live spec exercises it) and the compact-match regex (equivalent in power to the existing check).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved quote creation and deletion error reporting, including detailed validation messages.
    • Quote cleanup now continues successfully when company details are unavailable or remote quotes no longer exist.
    • Prevented failed quote synchronizations from leaving incomplete remote records.
    • Improved retained diagnostic PDF handling and error messages.
  • Costing Improvements

    • Draft cost lines now classify correctly and calculate revenue values more reliably.
    • Autosave can retry failed updates and prevents stale edits from overwriting newer changes.
    • Failed deletions restore only the affected cost line.

corrinand others added 5 commits August 9, 2026 20:28
…ked deletes
Provider quote calls adopt the PO pattern (summarize_errors=False +
element-level validation_errors checks) so a rejected status change — e.g.
deleting an ACCEPTED quote — can never read as success. The post-create
tail moves into _finalize_created_quote: EVERY failure after the remote
write now compensates (totals validation, persist, the timestamp bump all
void the orphan best-effort with the external id in the error), and the
IntegrityError is discriminated by state — a same-xero_id row means the
sync mirrored our own quote first and is ADOPTED (linking the job the
transform never sets), never voided; only the job-constraint race voids.
Deletion no longer requires a Xero-valid company (the quote row carries
its own), an unsynced company refuses with a readable 400 instead of a
500, and a retained diagnostic PDF reports its path.
Findings: ultrareview (2) + user review (1, 2, 5) over PR #48.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 7x-copied scaffolding (token check, failure-to-payload mapping with the
status clamp, success-invariant raise + response construction) collapses
into _xero_auth_refusal/_document_failure/_document_success. Wire-identical
by construction — ninja serializes every declared field, so explicit Nones
equal the fields each endpoint used to omit — and the exported schema is
unchanged. The error_type drift (missing only from delete_purchase_order)
disappears with the copies. Full capability split of apps/xero stays a
recorded backlog item, not a pre-cutover change.
Findings: ultrareview sub-cap cleanup + user review (4, partial).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draft rows derive unit_rev from unit_cost like server rows (a filled
phantom used to silently never POST); draft commits skip the send-dedupe so
retyping the same value after a failed POST retries; the autosave buffer
tracks dirtiness instead of copying the server value in at focus (a focus
landing in the same tick as a sibling cell's state-updating blur copied a
stale render's value); quantity edits make the phantom real; typed
free-form rows infer adjust (v1 rule — material means a stock pick); the
PATCH echo merges only its own fields so it cannot clobber an interleaved
optimistic edit; a failed delete re-inserts only its line. The quote spec
now hard-asserts the fresh job's line state before the repair pass and
all-clear after it — a line-creation regression fails instead of being
silently healed.
Findings: ultrareview (3, 5, 6, 8, 9, 10) + user review (3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The handler-contract gate rightly flagged the IntegrityError handler: its
persist lived inside the delegate where the AST cannot see it. The persist
moves to the catch, which is where it belonged anyway. 1716 tests, 88.52%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates Xero quote validation and persistence, retains diagnostic PDFs for failed inspection, and improves costing draft autosave and optimistic state reconciliation. Tests and project metrics were updated for these behaviors.

Changes

Xero quote flow

Layer / File(s)Summary
Shared document response handling
apps/xero/api.py, stubs/xero_python/accounting/__init__.pyi
Shared authentication and document response helpers now serve invoice, quote, and purchase-order endpoints. Quote includes typed validation errors.
Provider validation errors
apps/xero/provider.py, apps/xero/tests/test_provider_documents.py
Quote creation and deletion expose element-level Xero validation messages and disable error summarization.
Quote finalization and cleanup
apps/xero/documents/quote.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py, docs/*
Quote finalization handles collisions, compensation, synchronization, and provider-total failures. Deletion works without a valid company relation. Tests and project metrics reflect the changes.

Quote PDF diagnostics

Layer / File(s)Summary
Retained diagnostic PDFs
apps/accounting/services/quote_pdf.py, apps/accounting/tests/test_quote_pdf.py
Non-matching PDFs are retained and returned through retained_pdf_path. Empty-text errors and command JSON include the retained path.

Costing draft and optimistic updates

Layer / File(s)Summary
Draft autosave and costing fields
frontend/src/features/job/costing/CostLineGrid.tsx, frontend/src/features/job/costing/useAutosaveField.ts, frontend/src/features/job/costing/CostLineGrid.test.tsx
Draft detection, free-form line kinds, derived revenue, and retry behavior now use dirty-field tracking.
Optimistic echo and rollback handling
frontend/src/features/job/costing/useCostLines.ts, frontend/src/features/job/costing/useCostLines.test.ts
Patch responses merge only requested fields, and failed deletes restore only the affected line.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant QuoteEndpoint
participant QuoteProvider
participant Xero
participant QuoteFinalizer
participant LocalDatabase
QuoteEndpoint->>QuoteProvider: create quote
QuoteProvider->>Xero: submit quote with summarize_errors=false
Xero-->>QuoteProvider: quote result and validation_errors
QuoteProvider-->>QuoteFinalizer: DocumentResult
QuoteFinalizer->>LocalDatabase: finalize or compensate local persistence
QuoteFinalizer-->>QuoteEndpoint: success or failure response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 41.67% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main change: hardening quote handling based on review findings.
Description check✅ PassedThe description explains the rationale, backend and frontend changes, specification updates, and test results, but omits the template headings and checklist.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xero/quote-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
apps/xero/tests/test_quote_manager.py (3)

208-243: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the adopted totals.

Adoption overwrites total_excl_tax and total_incl_tax from the provider raw payload (quote.py Lines 287-288), replacing the mirror's zeros. That overwrite is the part a regression would silently drop, because number and job_id would still look correct.

💚 Proposed assertion
 assert adopted.job_id == job.id
assert adopted.number == "QU-RAW-1"
+ assert adopted.total_excl_tax == Decimal("250.00")+ assert adopted.total_incl_tax == Decimal("287.50")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 208 - 243, Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.

272-290: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The test name promises an assertion the body does not make.

test_post_persist_failure_voids_and_names_the_id asserts the void and the empty table. It never checks that the external id appears anywhere. The raised error is RuntimeError("db gone") from the patched bump, which carries no id. Either assert the void argument or rename the test.

💚 Proposed fix
- provider.delete_quote.assert_called_once()+ external_id = provider.create_quote.return_value.external_id+ provider.delete_quote.assert_called_once_with(external_id)
assert Quote.objects.count() == 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 272 - 290, Update
test_post_persist_failure_voids_and_names_the_id to assert that
provider.delete_quote was called with the created quote’s external ID, in
addition to verifying the void operation and empty database; ensure the
assertion specifically validates the ID value rather than only call count.

244-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the safety property this test exists for.

_resolve_persist_collision documents that a row on another job must not be voided, because that would delete another job's document. The test asserts only the raise. Add the negative assertion so a future change that voids first still fails the test.

💚 Proposed assertion
 with pytest.raises(ValueError, match="different job"):
manager.create_document(breakdown=False)
++ provider.delete_quote.assert_not_called()+ assert Quote.objects.get(xero_id=external_id).job_id == other_job.id
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_quote_manager.py` around lines 244 - 270, Extend
test_mirrored_row_on_another_job_raises to retain the created mirrored Quote
and, after manager.create_document raises ValueError, assert that the other
job’s Quote remains present and unvoided. Keep the existing “different job”
exception assertion and verify the safety property enforced by
_resolve_persist_collision.
apps/xero/api.py (1)

573-589: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm the delete path never needs job.company semantics over quote.company.

The fallback selects quote.company when job.company is None. XeroQuoteManager.delete_document does not call validate_company, so the company is only bound for identity and logging. That matches the documented intent. One readability point: the chained conditional on Line 576 packs two decisions into one expression.

♻️ Optional: split the fallback
- quote = Quote.objects.filter(job=job).select_related("company").first()- company = job.company if job.company is not None else quote.company if quote else None+ quote = Quote.objects.filter(job=job).select_related("company").first()+ company = job.company+ if company is None and quote is not None:+ company = quote.company
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/api.py` around lines 573 - 589, Keep the delete path’s company
selection semantics unchanged: prefer job.company, then fall back to
quote.company, and return the existing error when neither exists. For
readability, split the chained conditional around the delete handler’s company
selection into explicit steps while preserving the
XeroQuoteManager(company=company, job=job, ...) behavior.
apps/xero/tests/test_document_api.py (1)

339-339: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document why untracked_update is used on a tracked field.

Job.untracked_update states: "Use only for migrations and bookkeeping fields that are in UNTRACKED_FIELDS." company is not in Job.UNTRACKED_FIELDS. The call is reasonable here, because the test must reproduce a legacy row state without emitting a JobEvent. Record that reason inline so the next reader does not treat it as a template for production code.

📝 Proposed comment
- Job.objects.filter(pk=job.pk).untracked_update(company=None)+ # untracked_update, not save(staff=...): the state under test is a row+ # whose company was cleared, and a tracked save would add a JobEvent+ # the scenario never produced.+ Job.objects.filter(pk=job.pk).untracked_update(company=None)

As per coding guidelines: "Comments must document the rejected obvious alternative and the factual constraint that rejected it."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/tests/test_document_api.py` at line 339, Document the intentional
use of Job.objects.filter(...).untracked_update in this test: state that it
creates a legacy row state without emitting a JobEvent, and explicitly note that
normal tracked updates are rejected because they would emit that event. Make
clear this is test-only behavior and not a production usage pattern.

Source: Coding guidelines

apps/xero/provider.py (1)

340-357: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the validation-error mapping.

The same three steps now appear in create_quote (Lines 290-297), delete_quote (Lines 349-357), and _create_or_update_purchase_order (Lines 457-465): collect str(ve.message), log a warning, and return a failed DocumentResult with the joined text. A small private helper keeps one implementation of the element-error contract.

Also note Lines 347-348 build updated_quotes only to index element zero. A direct guard reads shorter.

♻️ Proposed helper
`@staticmethod`def_validation_failure(
element: Any, context: str, external_id: str|None=None
) ->DocumentResult|None:
"""Return the element-level failure result, or None when Xero accepted it."""ifnotelement.validation_errors:
returnNoneerrors= [str(ve.message) forveinelement.validation_errors]
logger.warning("Xero %s validation errors: %s", context, errors)
returnDocumentResult(
success=False,
external_id=external_id,
error=" | ".join(errors),
validation_errors=errors,
)
- updated_quotes = response.quotes or []- updated = updated_quotes[0] if updated_quotes else None- if updated is not None and updated.validation_errors:- errors = [str(ve.message) for ve in updated.validation_errors]- logger.warning("Xero quote %s delete validation errors: %s", external_id, errors)- return DocumentResult(- success=False,- external_id=external_id,- error=" | ".join(errors),- validation_errors=errors,- )+ updated = next(iter(response.quotes or []), None)+ if updated is not None:+ rejected = self._validation_failure(+ updated, f"quote {external_id} delete", external_id+ )+ if rejected is not None:+ return rejected

As per coding guidelines: "Use one implementation per concept; search before implementing and extend a near-match instead of creating a parallel implementation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/xero/provider.py` around lines 340 - 357, Extract the repeated
element-level validation handling from create_quote, delete_quote, and
_create_or_update_purchase_order into one private helper, such as
_validation_failure, that maps messages, logs the context, and returns the
failed DocumentResult or None. Replace each inline implementation with the
helper while preserving each operation’s context and external_id, and simplify
delete_quote to guard the first response element directly instead of building
updated_quotes solely for indexing.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/rewrite-status.md`:
- Around line 876-887: Renumber the ordered backlog list in
docs/rewrite-status.md so the new entries around the split and ultrareview
cleanup items remain consistent with all subsequent entries. Update the
following existing numbered items to shift their source numbers accordingly, or
convert the entire list to `1.` markers while preserving its rendered order.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 324-325: Update persistDraftIfReady and the draft commit flow so
edits made while draft creation is in flight are queued or reconciled and
applied to the newly created line instead of being lost. Cover both the
description edit at frontend/src/features/job/costing/CostLineGrid.tsx:324-325
and numeric edit at frontend/src/features/job/costing/CostLineGrid.tsx:384-385;
retain the draft until all later edits are persisted, and add a delayed-create
test that edits a field before the initial POST resolves.
- Around line 381-383: Update the unit_cost handling in CostLineGrid so derived
unit_rev is recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.
In `@frontend/src/features/job/costing/useCostLines.test.ts`:
- Around line 50-63: Update the test around restoreDeletedLine to make lineX the
current cached entry with its distinct rejected-optimistic unit_rev, while
keeping snapshotX at 12.00. Preserve the existing assertions that only y is
reinserted and assert restored x retains lineX’s current value, distinguishing
it from the snapshot.
---
Nitpick comments:
In `@apps/xero/api.py`:
- Around line 573-589: Keep the delete path’s company selection semantics
unchanged: prefer job.company, then fall back to quote.company, and return the
existing error when neither exists. For readability, split the chained
conditional around the delete handler’s company selection into explicit steps
while preserving the XeroQuoteManager(company=company, job=job, ...) behavior.
In `@apps/xero/provider.py`:
- Around line 340-357: Extract the repeated element-level validation handling
from create_quote, delete_quote, and _create_or_update_purchase_order into one
private helper, such as _validation_failure, that maps messages, logs the
context, and returns the failed DocumentResult or None. Replace each inline
implementation with the helper while preserving each operation’s context and
external_id, and simplify delete_quote to guard the first response element
directly instead of building updated_quotes solely for indexing.
In `@apps/xero/tests/test_document_api.py`:
- Line 339: Document the intentional use of
Job.objects.filter(...).untracked_update in this test: state that it creates a
legacy row state without emitting a JobEvent, and explicitly note that normal
tracked updates are rejected because they would emit that event. Make clear this
is test-only behavior and not a production usage pattern.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 208-243: Extend
test_same_xero_id_collision_adopts_the_mirrored_row to assert the adopted
Quote’s total_excl_tax and total_incl_tax match the provider result’s raw
totals, confirming adoption overwrites the mirrored zero values.
- Around line 272-290: Update test_post_persist_failure_voids_and_names_the_id
to assert that provider.delete_quote was called with the created quote’s
external ID, in addition to verifying the void operation and empty database;
ensure the assertion specifically validates the ID value rather than only call
count.
- Around line 244-270: Extend test_mirrored_row_on_another_job_raises to retain
the created mirrored Quote and, after manager.create_document raises ValueError,
assert that the other job’s Quote remains present and unvoided. Keep the
existing “different job” exception assertion and verify the safety property
enforced by _resolve_persist_collision.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9be30f0d-b9c7-4e0e-8913-25c45c8a6bbf

📥 Commits

Reviewing files that changed from the base of the PR and between cd5943d and c426af2.

📒 Files selected for processing (16)
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/xero/api.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/code-quality.md
  • docs/rewrite-status.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.test.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • stubs/xero_python/accounting/__init__.pyi

Comment on lines +876 to +887
5. Split `apps/xero` by capability — routers and provider modules for
connection, contacts, sales documents, purchasing, sync — keeping
invoice/quote/PO domain orchestration separate. `api.py` is ~1,200 lines
and `provider.py` ~600; the shared document-endpoint adapter (landed with
the quote hardening) stops the scaffolding drift, but the file split is
deliberate post-cutover structure work.
6. Ultrareview sub-cap cleanups from the quote slice: managers read
provider-private `_sub_total`/`_total` raw keys the readonly provider
must fabricate; `EMPTY_SERVER_SHAPE` could be a `Pick<CostLineOut, ...>`;
XeroQuoteCard/JobInvoiceCard are siblings with drift; the item picker's
stock search fires per keystroke undebounced; the quote tab duplicates
the HOURS formatter; a dead "No online URL" toast.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Renumber the backlog items.

The new entries are written as 5. (Line 876) and 6. (Line 882). Lines 888 and 891 already use 5. and 6.. Markdown renumbers ordered lists on render, so the published list will read 5, 6, 7, 8, … while the source text keeps the old numbers. Anyone citing "backlog item 9" will then reference a different entry in the source than in the rendered document. Number the new entries 5. and 6. and shift the following entries, or convert the whole list to 1. markers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/rewrite-status.md` around lines 876 - 887, Renumber the ordered backlog
list in docs/rewrite-status.md so the new entries around the split and
ultrareview cleanup items remain consistent with all subsequent entries. Update
the following existing numbered items to shift their source numbers accordingly,
or convert the entire list to `1.` markers while preserving its rendered order.

Comment on lines +324 to +325
context.updateDraft(gridRow.localId, patch)
context.commitDraftField(gridRow.localId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve edits made while draft creation is in flight.

persistDraftIfReady captures the earlier draft in the create request, ignores later commits while persistingRef contains the local ID, and removes the draft on success. An edit during that request is silently lost.

  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325: queue or reconcile a description edit made after draft creation starts.
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385: queue or reconcile a numeric edit made after draft creation starts.

Keep the draft until later edits are persisted to the created line. Add a delayed-create test that edits a field before the first POST resolves.

📍 Affects 1 file
  • frontend/src/features/job/costing/CostLineGrid.tsx#L324-L325 (this comment)
  • frontend/src/features/job/costing/CostLineGrid.tsx#L384-L385
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 324 - 325,
Update persistDraftIfReady and the draft commit flow so edits made while draft
creation is in flight are queued or reconciled and applied to the newly created
line instead of being lost. Cover both the description edit at
frontend/src/features/job/costing/CostLineGrid.tsx:324-325 and numeric edit at
frontend/src/features/job/costing/CostLineGrid.tsx:384-385; retain the draft
until all later edits are persisted, and add a delayed-create test that edits a
field before the initial POST resolves.

Comment on lines +381 to +383
if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recalculate draft revenue after every unit-cost edit.

After a failed create, a draft can retain derived unit_rev: "12.00". If the user changes unit_cost from "10" to "20", this condition skips recalculation and the retry submits inconsistent values. The server-row path already recalculates revenue for every cost edit.

Proposed fix
- if (fieldName === 'unit_cost' && kind !== 'time' && gridRow.draft.unit_rev === null) {+ if (fieldName === 'unit_cost' && kind !== 'time') {
patch.unit_rev = derivedUnitRev(value, context.materialsMarkup)
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if(fieldName==='unit_cost'&&kind!=='time'&&gridRow.draft.unit_rev===null){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
if(fieldName==='unit_cost'&&kind!=='time'){
patch.unit_rev=derivedUnitRev(value,context.materialsMarkup)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/CostLineGrid.tsx` around lines 381 - 383,
Update the unit_cost handling in CostLineGrid so derived unit_rev is
recalculated for every unit-cost edit on non-time rows, removing the
draft.unit_rev === null guard. Keep using derivedUnitRev with the current value
and materialsMarkup, matching the existing server-row recalculation behavior.

Comment on lines +50 to +63
it('re-inserts only the deleted line at its index, not the whole snapshot', () => {
const lineX = line({ id: 'x', unit_rev: 'rejected-optimistic' })
const snapshotX = line({ id: 'x', unit_rev: '12.00' })
const lineY = line({ id: 'y' })
// The current cache has X already rolled back by its own PATCH failure.
const current = [line({ id: 'x', unit_rev: '12.00' })]
const snapshot = [snapshotX, lineY]
void lineX

const restored = restoreDeletedLine(current, snapshot, 'y')

expect(restored.map((entry) => entry.id)).toEqual(['x', 'y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the preservation assertion distinguish current state from the snapshot.

current[0] and snapshotX both have unit_rev: "12.00". The test therefore passes if rollback replaces the current cache with the full snapshot. Use lineX as the current entry and assert that its distinct value remains.

Proposed fix
- const current = [line({ id: 'x', unit_rev: '12.00' })]+ const current = [lineX]
@@
- expect(restored[0]!.unit_rev).toBe('12.00')+ expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[line({id: 'x',unit_rev: '12.00'})]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('12.00')
it('re-inserts only the deleted line at its index, not the whole snapshot',()=>{
constlineX=line({id: 'x',unit_rev: 'rejected-optimistic'})
constsnapshotX=line({id: 'x',unit_rev: '12.00'})
constlineY=line({id: 'y'})
// The current cache has X already rolled back by its own PATCH failure.
constcurrent=[lineX]
constsnapshot=[snapshotX,lineY]
voidlineX
constrestored=restoreDeletedLine(current,snapshot,'y')
expect(restored.map((entry)=>entry.id)).toEqual(['x','y'])
// X keeps its current (rolled-back) value, not the snapshot's state.
expect(restored[0]!.unit_rev).toBe('rejected-optimistic')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/features/job/costing/useCostLines.test.ts` around lines 50 - 63,
Update the test around restoreDeletedLine to make lineX the current cached entry
with its distinct rejected-optimistic unit_rev, while keeping snapshotX at
12.00. Preserve the existing assertions that only y is reinserted and assert
restored x retains lineX’s current value, distinguishing it from the snapshot.

@corrin
corrin merged commit 06038e3 into mainAug 9, 2026
3 checks passed
Sign up for freeto 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

@corrin