Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40) - #48

Merged
corrin merged 12 commits into
mainfrom
xero/quote-path
Aug 9, 2026
Merged

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40)#48
corrin merged 12 commits into
mainfrom
xero/quote-path

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Slice 2c — the last of the three slice-2 Xero PRs. Done means the spec is green: job-xero-quote.spec.ts passed writes-live against the demo tenant (real DRAFT quote created, native Xero PDF downloaded server-side and the configured terms text found in it), and the full run_e2e.sh gate passed all 39 tests across the 14 ported spec files on the final code.

Backend

  • apps/xero/documents/quote.py — expected refusals (already quoted, T&M pricing, empty quote cost set, blank breakdown descriptions, missing theme/terms config) return typed 400 values with the provider never called; unexpected failures persist once and re-raise. Total-only mode sends the cost-set summary revenue as one line; breakdown mode one sanitised line per cost line. The concurrent-push loser voids its orphan Xero quote (savepoint-guarded) before refusing; a quote deleted Xero-side comes back as a typed 404 from the provider pre-read and the manager cleans up the local row instead of bricking the job.
  • Provider: create_quote, delete_quote (soft delete via pre-read + DELETED upsert), download_quote_pdf. Readonly fabricates QU-E2E-* results and refuses the PDF download — a fabricated file would satisfy the text assertion against nothing.
  • Endpoints: POST /api/xero/create_quote/{job_id} (body {breakdown}), DELETE /api/xero/delete_quote/{job_id} (no id parameter — one quote per job) at v1-parity URL fragments; GET /api/job/jobs/{id}/quote/ serving {quote: QuoteOut | null} — enveloped because the generated axios client coerces a bare JSON null body to {} (ledgered, with the conditional-GET drop).
  • PDF inspection: apps/accounting/services/quote_pdf.py + the inspect_xero_quote_pdf command emitting the single JSON line the spec parses; the file survives every diagnostic path.

Frontend

  • features/job/costing/CostLineGrid.tsx — the one cost-line grid (estimate/actual arrive later as prop configs), on TanStack Table v8 with module-constant column defs (per-render defs would remount and blur every input). Full day-one selector contract: .smart-costlines-table, exactly one trailing phantom tbody row, SmartCostLinesTable-*/DataTable-row-*/data-grid-* from the visual index, ItemSelect-option-*, trigger named Select Item only when unbound.
  • useAutosaveField derives its display value (local buffer only while editing) — an effect-synced copy provably kept rejected input on screen when an optimistic write and its rollback coalesced into one render. 600ms debounce, blur flushes and cancels, deliberately no If-Match on cost-line CRUD (v1 parity).
  • JobQuoteTab + XeroQuoteCard: server-owned summary (ADR 0046), ping-gated create, "Export Quote to Xero" dialog (Send Total Only / Send Breakdown), open-in-Xero with noopener, delete. Lazy-loaded.
  • Deferred with attributes already in place: keyboard-nav behaviour, duplicate-line, unit-rev override bookkeeping, data-freshness polling.

Spec port

Recorded deviations from v1's spec: own job instead of the read-only sharedEditJobUrl fixture; no in-spec ping (global setup fails the run closed); waitForAutosave instead of 800ms sleeps; PDF inspector spawned via uv run python. The repair machinery ports near-verbatim — it is what exercises the grid every run (the fresh job's material line lacks a stock binding).

Review

Adversarial 2-subagent review pre-PR; all four backend should-fixes and the frontend blocker + should-fixes applied with regression tests (draft-POST failure recovery, same-value retry after rollback). Declined with reasons in the commit: the compact-match "tightening" (equivalent in power to the existing check) and the readonly totals type change (2b precedent).

Verification: 1708 unit tests, coverage 88.46%, job-xero-quote green live, full E2E gate green twice (before and after review fixes).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Quote workspace to job details for managing cost lines, labour, stock, pricing, and autosaving changes.
    • Added Xero quote creation with total-only or detailed line breakdown options.
    • Added options to open quotes in Xero and delete existing quotes.
    • Added job quote retrieval through the API, including quote numbers and summary details.
    • Added quote PDF inspection for validating terms, branding, page count, and extracted text.
  • Bug Fixes
    • Improved handling of missing invoice totals and clearer document deletion errors.
  • Documentation
    • Updated API and rewrite progress documentation for quote functionality.

corrinand others added 10 commits August 9, 2026 15:30
…otocol
QuotePayload carries required terms (Xero applies no default to API-created
quotes) and an expiry date; QuotePdfDocument hands the caller a temp file it
owns. Live provider mirrors the invoice shapes: constructed edit-URL, DELETED
upsert after a contact/date pre-read, PDF download that raises rather than
returning a partial result. Readonly fabricates QU-E2E-* results and refuses
the PDF download outright — a fabricated file would satisfy the text
assertion against nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… mirror
Expected refusals (already quoted, T&M pricing, empty quote cost set, blank
breakdown descriptions, missing theme/terms config) return typed 400 values
with the provider never called; unexpected failures persist once and re-raise
per the base contract. Total-only mode sends the cost-set summary revenue as
a single line; breakdown mode one sanitised line per cost line. The job's
updated_at bumps in-request so the tab refetch sees quoted=true. The
duplicated _create_job_event hoisted from invoice.py into the base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /xero/create_quote/{job_id} (body: breakdown) and DELETE
/xero/delete_quote/{job_id} mirror the invoice handlers; delete takes no id
parameter because a job holds at most one quote. GET /job/jobs/{id}/quote/
serves the Xero quote header or null — a plain GET, not v1's conditional-GET:
nothing external holds the URL and 304-with-empty-body reads as no-quote to
an axios consumer (ledger entry to follow with the slice docs).
XeroDocumentSuccessResponse gains nullable quote_id beside invoice_id; the
two xero operation renames are recorded in the work-list ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the v1 inspection: pypdf text extraction over non-blank pages,
space-normalised AND compact matching (Xero's text layer wraps mid-phrase
and sometimes drops word spaces), blank render raises rather than reporting
the marker absent, and the temp file survives every failure path for
diagnosis. The command emits exactly one sorted-keys JSON line — the
subprocess contract the E2E quote spec parses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-exports the cost-line/labour-rates/stock-search factories and the three
new quote operations through the api boundary. Adds @tanstack/react-table
and the shadcn popover + command primitives (installed, not hand-written).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TanStack Table v8 with module-constant column defs — rebuilding them per
render changes every cell component's identity and remounts (blurring) all
inputs, so cells reach live state through table meta instead. Day-one
contract: .smart-costlines-table, exactly one trailing phantom tbody row,
SmartCostLinesTable-*/DataTable-row-*/data-grid-* attributes derived from
the visual index, ItemSelect with labour-first options.
useAutosaveField DERIVES its display value (local buffer only while
editing): an optimistic write and its failure rollback can land between two
renders, so an effect keyed on the server value never fires — the unit net
caught a synced copy keeping rejected input on screen. 600ms debounce,
blur flushes and cancels, no If-Match on cost-line CRUD (deliberate, v1
parity). All failures toast; the tests fail on any console.error, matching
the E2E guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quote workspace: editable quote cost set, server-owned summary card
(ADR 0046), and the Xero card (ping-gated create, Export Quote to Xero
dialog with Send Total Only / Send Breakdown, open-in-Xero with noopener,
delete). Lazy-loaded from the job detail page.
The retrieve contract changed to {quote: QuoteOut | null}: the unit net
caught the generated axios client coercing a bare JSON null body to {},
which read as an existing quote and crashed the card — an envelope is the
only shape that round-trips absence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…check
Deliberate deviations from the v1 spec, each with its reason in place: a
dedicated job instead of the read-only sharedEditJobUrl fixture; no in-spec
Xero ping (global setup fails the run closed); waitForAutosave instead of
800ms sleeps around the 600ms debounce; and the PDF inspector spawns via
uv run python since nothing guarantees an activated interpreter under npm.
The repair machinery (Select Item pick, desc/unit-rev fills) ports
near-verbatim — it is the part of the spec that exercises the grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st states
Backend: the concurrent-push loser now voids its orphan Xero quote (under a
savepoint, so the compensation can persist its AppError) and refuses with
the readable 400; a quote deleted Xero-side returns a typed 404 from the
provider pre-read and the manager treats it as cleanup-allowed instead of
bricking the job; the local mirror stores the payload's date (midnight
span); present-but-null totals get the crafted message in both quote and
invoice managers; the not-found PDF keeps its file for diagnosis;
delete_invoice passes error_type through like its quote sibling.
Frontend: a failed draft POST clears the persisting guard so the row stays
retryable (was permanently bricked); draft inputs disable while the create
is in flight; the send-dedupe only skips a KNOWN-applied value so a rejected
edit can be retried; the quote card renders pending as pending, not as
create-state; error states stop masquerading as data (picker, ping,
summary); background-refetch errors keep the working grid; null profit
margin renders as a dash, not 0.0%; PATCH rollback reverts only the patched
fields against the current cache, with in-flight refetches cancelled first.
Spec: isEnabled guard before repairing a rev input; response schema
validates quote_id/online_url. Declined: the compact-match tightening (the
proposed regex is equivalent in power — its own counterexample defeats
both) and the readonly totals type change (2b precedent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:23 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f0ec6174-067a-4295-8ee5-20d5269c5cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 9ae11da and e19f4e6.

📒 Files selected for processing (11)
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/services/quote_pdf.py
  • docs/code-quality.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
📝 Walkthrough

Walkthrough

Added end-to-end Xero quote support across accounting providers, quote APIs, PDF inspection, job retrieval, frontend costing, and quote management UI. Added tests, OpenAPI updates, compatibility mappings, and E2E validation.

Changes

Xero quote backend

Layer / File(s)Summary
Provider contracts and integrations
apps/accounting/types.py, apps/accounting/provider.py, apps/xero/provider.py, apps/xero/readonly_provider.py, stubs/xero_python/..., apps/xero/tests/test_provider_documents.py
Added quote payload and PDF types, provider operations, Xero quote create/delete/PDF support, readonly behavior, and SDK stubs.
Quote manager and API lifecycle
apps/xero/documents/quote.py, apps/xero/api.py, apps/xero/documents/base.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py
Added quote validation, payload construction, persistence, compensation, deletion, audit events, authenticated endpoints, and lifecycle tests.
Quote PDF inspection tooling
apps/accounting/services/quote_pdf.py, apps/accounting/management/commands/..., apps/accounting/tests/test_quote_pdf.py
Added PDF text inspection, branding and page metadata, diagnostic-file handling, and JSON command output.
Job quote retrieval contract
apps/job/api.py, apps/job/schemas.py, apps/job/services/job_service.py, frontend/schema.v2.yml, frontend/src/api/index.ts
Added nullable enveloped quote retrieval with quote-number serialization and matching API schemas.
Cost-line editing workspace
frontend/src/features/job/costing/*, frontend/src/components/ui/*, frontend/package.json
Added cost-line types, calculations, autosave, optimistic CRUD, item selection, editable grid behavior, and supporting UI primitives.
Quote tab and Xero card
frontend/src/features/job/JobDetailPage.tsx, frontend/src/features/job/costing/JobQuoteTab.tsx, frontend/src/features/job/costing/XeroQuoteCard.tsx
Added the lazy-loaded quote tab, quote summary, cost grid, Xero quote creation modes, deletion, deep links, and mutation states.
End-to-end validation and status support
frontend/tests/e2e/job/job-xero-quote.spec.ts, docs/rewrite-status.md, docs/accepted-api-differences.yml, scripts/v1-frontend-operations.yml
Added E2E quote creation and PDF checks, diagnostic repair logic, status updates, API difference documentation, and operation mappings.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant User
participant JobQuoteTab
participant XeroQuoteCard
participant QuoteAPI
participant Xero
User->>JobQuoteTab: Open the quote tab
JobQuoteTab->>XeroQuoteCard: Load quote state
User->>XeroQuoteCard: Select quote mode and submit
XeroQuoteCard->>QuoteAPI: Create quote request
QuoteAPI->>Xero: Create quote
Xero-->>QuoteAPI: Return quote identifiers and totals
QuoteAPI-->>XeroQuoteCard: Return quote response
XeroQuoteCard-->>User: Display quote link and status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 32.57% 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 Xero quote path, cost-line grid, and related end-to-end test result.
Description check✅ PassedThe description thoroughly covers implementation details, scope, deviations, review outcomes, and verification results, despite omitting 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-path

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: 8

🧹 Nitpick comments (10)
frontend/src/features/job/costing/useCostLines.ts (1)

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

Document why deleteLine restores the whole snapshot.

patchLine carries a comment that rejects the wholesale snapshot restore and explains the interleaved-write constraint. deleteLine then uses that exact rejected strategy at Line 134 with no comment. A failed delete restores the full pre-delete cost set, so it also reverts any successful interleaved patch on another line until the onSettled refetch lands. Record the constraint that makes the wholesale restore correct here, for example that a removed row cannot be reconstructed field-by-field.

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 `@frontend/src/features/job/costing/useCostLines.ts` around lines 121 - 140,
Update deleteLine’s onError rollback comment to document why restoring the
entire snapshot is intentional: unlike patchLine, a deleted row cannot be
reconstructed field-by-field, so a wholesale restore is required despite
potentially reverting interleaved writes until invalidate refetches.

Source: Coding guidelines

frontend/src/features/job/costing/calc.test.ts (1)

123-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the two unresolved-reference fallbacks in itemLabel.

The suite does not exercise the fallback at calc.ts Line 79 (labour rate not in the list) or Line 86 (stock id not in the loaded page). The comment on calc.ts Line 85 states that the 'Stock item' fallback must not read as 'Select Item', because the E2E repair loop counts buttons by that exact name. A regression that returns 'Select Item' for an unresolved stock id would pass this suite and fail only in Playwright. Add the two cases.

🧪 Proposed additional cases
 it('names the labour subtype for a time line', () => {
expect(itemLabel(line({ kind: 'time', labour_subtype: 'workshop' }), stockById, rates)).toBe(
'Workshop',
)
})
++ it('falls back to the raw subtype when the rate is not loaded', () => {+ expect(itemLabel(line({ kind: 'time', labour_subtype: 'nightshift' }), stockById, rates)).toBe(+ 'nightshift',+ )+ })++ it('never reads as "Select Item" when bound to unloaded stock', () => {+ expect(itemLabel(line({ ext_refs: { stock_id: 'stock-unloaded' } }), stockById, rates)).toBe(+ 'Stock item',+ )+ })
})
🤖 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/calc.test.ts` around lines 123 - 140, Add
two `itemLabel` tests covering unresolved references: verify a time line with a
labour subtype absent from `rates` uses the labour-rate fallback, and verify a
bound line whose stock ID is absent from `stockById` returns the distinct “Stock
item” fallback rather than “Select Item”.
frontend/src/features/job/costing/CostLineGrid.test.tsx (1)

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

Annotate the labourRates and stockPage fixtures with the generated types.

materialLine and costSet are annotated, so a wire-type change breaks this file at compile time. labourRates and stockPage are bare literals, so the same change passes type-checking and fails only at runtime. calc.test.ts annotates the equivalent fixtures as JobLabourRateOut and StockItem. Add const labourRates: JobLabourRateOut[] and the generated page type for stockPage.

🤖 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.test.tsx` around lines 67 -
108, Annotate the labourRates fixture as JobLabourRateOut[] and annotate
stockPage with the generated page type used for StockItem results, matching the
equivalent fixtures in calc.test.ts. Preserve the existing fixture values while
ensuring wire-type changes are caught during compilation.
frontend/src/features/job/costing/ItemSelect.tsx (1)

88-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider debouncing the stock search input.

CommandInput calls onValueChange={setSearch} directly, and search is part of the purchasingStockSearchRetrieveOptions query key. Every keystroke while the popover is open fires a new server-side stock search request. The component's own comment notes that queries under 3 characters list everything, so short inputs during typing can each trigger a full unfiltered fetch.

Debounce the value passed to the query (for example with a small useDeferredValue or timer-based hook) so the request fires once typing pauses.

🤖 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/ItemSelect.tsx` around lines 88 - 92,
Debounce the search value used by the stock query in ItemSelect rather than
passing the raw search state into purchasingStockSearchRetrieveOptions on every
keystroke. Keep CommandInput responsive with the immediate value, and use a
small deferred or timer-based value so requests occur after typing pauses while
preserving the existing short-query behavior.
frontend/src/features/job/costing/XeroQuoteCard.test.tsx (1)

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

Use the user returned by renderWithProviders.

renderWithProviders already calls userEvent.setup() and returns the instance. Each test creates a second instance. Two sessions can hold separate pointer and keyboard state. Reuse the returned user in all five tests.

- const user = userEvent.setup()- renderWithProviders(<XeroQuoteCard jobId="job-1" />)+ const { user } = renderWithProviders(<XeroQuoteCard jobId="job-1" />)
🤖 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/XeroQuoteCard.test.tsx` around lines 72 -
73, Update all five tests in XeroQuoteCard.test.tsx to destructure and reuse the
user instance returned by renderWithProviders, removing each redundant
userEvent.setup() call while preserving the existing test interactions.
frontend/src/features/job/costing/CostLineGrid.tsx (1)

192-199: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Move the create call out of the setDrafts updater.

commitDraftField runs persistDraftIfReady inside a state updater. That updater executes during render. It starts a network mutation and calls syncPersisting (another setState) from render. React requires updaters to be pure. The persistingRef guard hides the StrictMode double invocation today, but any future replay of the updater still re-enters impure code.

Keep the latest drafts in a ref and run the persistence after the state update.

♻️ Proposed refactor: read drafts from a ref instead of a state updater
 const [drafts, setDrafts] = useState<DraftRow[]>([freshPhantom()])
+ // persistDraftIfReady must see the same render's updateDraft result, but a+ // network call inside a state updater is not a pure update; the ref carries+ // that latest value out of the updater instead.+ const draftsRef = useRef<DraftRow[]>(drafts)+ draftsRef.current = drafts
@@
updateDraft: (localId, patch) => {
setDrafts((current) => {
...
- return next+ draftsRef.current = next+ return next
})
},
commitDraftField: (localId) => {
- setDrafts((current) => {- persistDraftIfReady(current, localId)- return current- })+ persistDraftIfReady(draftsRef.current, localId)
},
🤖 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 192 - 199,
Refactor commitDraftField so persistDraftIfReady is no longer called inside the
setDrafts updater; keep the latest drafts synchronized in a ref and invoke
persistence after the state update using that ref. Ensure the setDrafts updater
remains pure while preserving the existing localId persistence behavior and
syncPersisting flow.
apps/xero/tests/test_provider_documents.py (2)

431-438: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the 404 status code, not only the error text.

XeroQuoteManager.delete_document (apps/xero/documents/quote.py, line 311) branches on result.status_code == 404 to clean up the local row when the quote is already gone from Xero. The error string is not part of that contract; the status code is. This test passes today even if delete_quote returns status 500 for an absent quote, which would brick the recovery path.

♻️ Proposed assertion
 result = provider.delete_quote(str(uuid.uuid4()))
assert not result.success
assert result.error is not None and "no quote" in result.error
+ # The manager keys its local-cleanup branch on this code, not the text.+ assert result.status_code == 404
🤖 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_provider_documents.py` around lines 431 - 438, Update
test_missing_quote_is_an_error_result to assert that the failed delete_quote
result has status_code 404, preserving the existing unsuccessful-result check
while treating the status code—not the error text—as the contract for a missing
quote.

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

Tighten the match, or drop "persists" from the test name.

match="quote" matches every ValueError this method raises, including the "returned N quotes" guard. The name promises a persistence assertion that the body does not make.

♻️ Proposed change
- with pytest.raises(ValueError, match="quote"):+ with pytest.raises(ValueError, match="for a request naming"):
provider.download_quote_pdf(str(uuid.uuid4()))
🤖 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_provider_documents.py` around lines 462 - 467, Align
test_quote_id_mismatch_raises_and_persists with its actual coverage: either
assert the specific quote-ID mismatch error message rather than the broad
“quote” match, and add the intended persistence assertion, or rename the test to
remove “persists” if persistence is not being verified.
apps/xero/tests/test_quote_manager.py (1)

170-197: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the unvoidable-orphan branch.

This test covers the race loser whose compensating void succeeds. The branch at apps/xero/documents/quote.py lines 216-224 is untested: when delete_quote also fails, the manager raises a ValueError carrying the orphan quote id. That id is the only record an operator has, because the AppError row cannot carry it. A regression that swallowed this raise would leave a real Xero quote orphaned with no trace.

♻️ Proposed companion test
+ def test_race_loser_raises_when_the_orphan_cannot_be_voided(+ self, company: Company, job: Job, office_staff: Staff+ ) -> None:+ """An unvoidable orphan must reach an operator with its Xero id."""+ provider = Mock()+ provider.get_account_code.return_value = "200"+ orphan = _success_result()++ def concurrent_winner_lands_first(payload: object) -> DocumentResult: # noqa: ARG001+ _existing_quote(job, company)+ return orphan++ provider.create_quote.side_effect = concurrent_winner_lands_first+ provider.delete_quote.return_value = DocumentResult(+ success=False, error="Quote is ACCEPTED", status_code=400+ )+ manager = _manager(company, job, office_staff, provider)++ with pytest.raises(ValueError, match=str(orphan.external_id)):+ manager.create_document(breakdown=False)++ assert Quote.objects.count() == 1 # only the winner's row
🤖 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 170 - 197, Add a
companion test for the race-loser path in create_document where
provider.delete_quote fails after Quote.objects.create is rejected; assert the
manager raises ValueError containing the orphan Xero quote id, while preserving
the existing assertions for the successful compensation case.
stubs/xero_python/accounting/__init__.pyi (1)

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

Add the Quote fields the provider sets.

apps/xero/provider.py (lines 271-272) passes line_amount_types and currency_code into Quote(...). The stub does not declare either attribute. The call type-checks today only because __init__ accepts **kwargs: Any. Any later attribute read of those fields would fail under strict mypy.

♻️ Proposed stub additions
 class Quote:
quote_id: str | None
quote_number: str | None
contact: Contact | None
date: Any
expiry_date: Any
status: str | None
line_items: list[LineItem] | None
+ line_amount_types: str | None+ currency_code: str | None
branding_theme_id: str | None
terms: str | None
reference: str | None
updated_date_utc: Any
🤖 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 `@stubs/xero_python/accounting/__init__.pyi` around lines 80 - 93, Update the
Quote stub by declaring the line_amount_types and currency_code attributes
alongside the existing quote fields, using types consistent with the values
passed by apps/xero/provider.py. Keep the existing constructor and serialization
declarations unchanged.
🤖 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 `@apps/accounting/management/commands/inspect_xero_quote_pdf.py`:
- Line 28: Replace the narration docstring near the command’s
validation/inspection flow in
apps/accounting/management/commands/inspect_xero_quote_pdf.py:28-28 with either
no comment or rationale explaining the rejected behavior and the subprocess
contract requiring the selected behavior. Update the comment in
frontend/tests/e2e/job/job-xero-quote.spec.ts:139-140 to explain that treating
the phantom row as a persisted cost line would create invalid repair work
because it has no persisted cost-line data.
In `@apps/accounting/services/quote_pdf.py`:
- Around line 32-69: Wrap the PdfReader usage in the inspection flow with a
context manager so its streams close before any cleanup. Ensure the reader is
closed on both successful validation and failure paths, while preserving the
diagnostic PDF when the expected text is absent and allowing unlink after the
reader scope ends.
In `@frontend/src/features/job/costing/calc.ts`:
- Around line 32-44: Update stockPickPatch to explicitly set labour_subtype to
null when constructing the material CostLineUpdateRequest, while preserving the
existing stock and cost field mappings.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 410-419: Update the onPickStock handler to preserve null for
missing patch.unit_cost and patch.unit_rev instead of converting absent values
to empty strings. Ensure the resulting DraftLine remains incomplete when either
price is unavailable, and omit null price fields when constructing the create
PATCH request.
In `@frontend/src/features/job/costing/useAutosaveField.ts`:
- Around line 52-64: Update dispatch to compare against a live serverValue ref
rather than the render-captured serverValue when the debounce callback runs.
Keep the existing knownApplied and untouched deduplication behavior, and ensure
the ref is synchronized with the latest server value before dispatch evaluates
parsed.
In `@frontend/src/features/job/costing/XeroQuoteCard.tsx`:
- Around line 170-190: Update the button label logic in the XeroQuoteCard
component to distinguish ping.isPending from a genuinely disconnected
xeroConnected state. While the Xero connection check is pending, show a
checking/loading label and prevent the logged-out “Login to Xero first” message;
retain the existing labels once the check resolves.
- Around line 81-95: The executeDelete handler currently deletes the quote
without confirmation. Update executeDelete to show a visible confirmation
dialog, such as window.confirm with a clear deletion message, and only call
deleteQuote.mutate when the user confirms; preserve the existing pending guard
and success/error callbacks.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Line 173: Update the AccountingApi quote PDF call to use the method name
provided by xero-python==15.0.0 instead of get_quote_as_pdf, avoiding the
AttributeError. If a local get_quote_as_pdf implementation is retained, pass
headers={"Accept": "application/pdf"} and write its response to a temporary file
before applying the return-type check, rather than treating the raw response as
a path.
---
Nitpick comments:
In `@apps/xero/tests/test_provider_documents.py`:
- Around line 431-438: Update test_missing_quote_is_an_error_result to assert
that the failed delete_quote result has status_code 404, preserving the existing
unsuccessful-result check while treating the status code—not the error text—as
the contract for a missing quote.
- Around line 462-467: Align test_quote_id_mismatch_raises_and_persists with its
actual coverage: either assert the specific quote-ID mismatch error message
rather than the broad “quote” match, and add the intended persistence assertion,
or rename the test to remove “persists” if persistence is not being verified.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 170-197: Add a companion test for the race-loser path in
create_document where provider.delete_quote fails after Quote.objects.create is
rejected; assert the manager raises ValueError containing the orphan Xero quote
id, while preserving the existing assertions for the successful compensation
case.
In `@frontend/src/features/job/costing/calc.test.ts`:
- Around line 123-140: Add two `itemLabel` tests covering unresolved references:
verify a time line with a labour subtype absent from `rates` uses the
labour-rate fallback, and verify a bound line whose stock ID is absent from
`stockById` returns the distinct “Stock item” fallback rather than “Select
Item”.
In `@frontend/src/features/job/costing/CostLineGrid.test.tsx`:
- Around line 67-108: Annotate the labourRates fixture as JobLabourRateOut[] and
annotate stockPage with the generated page type used for StockItem results,
matching the equivalent fixtures in calc.test.ts. Preserve the existing fixture
values while ensuring wire-type changes are caught during compilation.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 192-199: Refactor commitDraftField so persistDraftIfReady is no
longer called inside the setDrafts updater; keep the latest drafts synchronized
in a ref and invoke persistence after the state update using that ref. Ensure
the setDrafts updater remains pure while preserving the existing localId
persistence behavior and syncPersisting flow.
In `@frontend/src/features/job/costing/ItemSelect.tsx`:
- Around line 88-92: Debounce the search value used by the stock query in
ItemSelect rather than passing the raw search state into
purchasingStockSearchRetrieveOptions on every keystroke. Keep CommandInput
responsive with the immediate value, and use a small deferred or timer-based
value so requests occur after typing pauses while preserving the existing
short-query behavior.
In `@frontend/src/features/job/costing/useCostLines.ts`:
- Around line 121-140: Update deleteLine’s onError rollback comment to document
why restoring the entire snapshot is intentional: unlike patchLine, a deleted
row cannot be reconstructed field-by-field, so a wholesale restore is required
despite potentially reverting interleaved writes until invalidate refetches.
In `@frontend/src/features/job/costing/XeroQuoteCard.test.tsx`:
- Around line 72-73: Update all five tests in XeroQuoteCard.test.tsx to
destructure and reuse the user instance returned by renderWithProviders,
removing each redundant userEvent.setup() call while preserving the existing
test interactions.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Around line 80-93: Update the Quote stub by declaring the line_amount_types
and currency_code attributes alongside the existing quote fields, using types
consistent with the values passed by apps/xero/provider.py. Keep the existing
constructor and serialization declarations unchanged.
🪄 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: 6120c3fb-75ad-489f-9410-9c2daf5d7eaa

📥 Commits

Reviewing files that changed from the base of the PR and between cd24a0f and 9ae11da.

⛔ Files ignored due to path filters (6)
  • frontend/package-lock.json is excluded by !**/package-lock.json
  • frontend/src/api/generated/@tanstack/react-query.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/index.ts is excluded by !**/generated/**
  • frontend/src/api/generated/sdk.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/types.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/zod.gen.ts is excluded by !**/generated/**
📒 Files selected for processing (45)
  • apps/accounting/management/__init__.py
  • apps/accounting/management/commands/__init__.py
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/provider.py
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/accounting/types.py
  • apps/job/api.py
  • apps/job/schemas.py
  • apps/job/services/job_service.py
  • apps/job/tests/test_job_api.py
  • apps/xero/api.py
  • apps/xero/documents/base.py
  • apps/xero/documents/invoice.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/readonly_provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/accepted-api-differences.yml
  • docs/rewrite-status.md
  • frontend/package.json
  • frontend/schema.v2.yml
  • frontend/src/api/index.ts
  • frontend/src/components/ui/command.tsx
  • frontend/src/components/ui/popover.tsx
  • frontend/src/features/job/JobDetailPage.tsx
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/ItemSelect.tsx
  • frontend/src/features/job/costing/JobQuoteTab.test.tsx
  • frontend/src/features/job/costing/JobQuoteTab.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/types.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • frontend/src/lib/format.ts
  • frontend/src/test/setup.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
  • scripts/v1-frontend-operations.yml
  • stubs/xero_python/accounting/__init__.pyi

Comment threadapps/accounting/services/quote_pdf.py Outdated
Comment threadfrontend/src/features/job/costing/calc.ts
Comment threadfrontend/src/features/job/costing/CostLineGrid.tsx
Comment threadfrontend/src/features/job/costing/useAutosaveField.ts
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadstubs/xero_python/accounting/__init__.pyi
corrinand others added 2 commits August 9, 2026 18:13
Movements all belong to this slice: two call-time manager imports and test
fixtures (PLC0415), the provider's deliberate exception-to-result
conversions (BLE001, returns-instead), the TableMeta module augmentation
(eslint-disable), and the new manager/provider try shapes. Passthrough
stays pinned at zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est states
Close the PdfReader before any unlink (a held stream fails deletion on
Windows); a stock pick clears labour_subtype so a converted time line
carries no stale subtype; the draft item-pick preserves absence as null
instead of '' (an empty string would satisfy the persist-ready check);
the debounce dispatch compares against the live server value via a ref;
quote deletion asks for confirmation like a cost-line delete; a pending
Xero ping reads 'Checking Xero…' instead of the logged-out label; two
comments now state their rejected alternatives. Declined with evidence in
the thread: the claim that xero-python 15.0.0 lacks get_quote_as_pdf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@corrin
corrin merged commit cd5943d into mainAug 9, 2026
3 checks passed
corrin added a commit that referenced this pull request Aug 9, 2026
* Quote hardening B1-B5: explicit validation, full compensation, unbricked 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>
* One document-endpoint adapter: auth refusal, failure map, success build
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>
* Grid hardening F1-F6 + assertive spec: drafts persist, retries retry
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>
* Record the deferred structure work; regenerate metrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Persist the collision at its catch site; refresh derived rows
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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40) - #48

Merged
corrin merged 12 commits into
mainfrom
xero/quote-path
Aug 9, 2026
Merged

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40)#48
corrin merged 12 commits into
mainfrom
xero/quote-path

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Slice 2c — the last of the three slice-2 Xero PRs. Done means the spec is green: job-xero-quote.spec.ts passed writes-live against the demo tenant (real DRAFT quote created, native Xero PDF downloaded server-side and the configured terms text found in it), and the full run_e2e.sh gate passed all 39 tests across the 14 ported spec files on the final code.

Backend

  • apps/xero/documents/quote.py — expected refusals (already quoted, T&M pricing, empty quote cost set, blank breakdown descriptions, missing theme/terms config) return typed 400 values with the provider never called; unexpected failures persist once and re-raise. Total-only mode sends the cost-set summary revenue as one line; breakdown mode one sanitised line per cost line. The concurrent-push loser voids its orphan Xero quote (savepoint-guarded) before refusing; a quote deleted Xero-side comes back as a typed 404 from the provider pre-read and the manager cleans up the local row instead of bricking the job.
  • Provider: create_quote, delete_quote (soft delete via pre-read + DELETED upsert), download_quote_pdf. Readonly fabricates QU-E2E-* results and refuses the PDF download — a fabricated file would satisfy the text assertion against nothing.
  • Endpoints: POST /api/xero/create_quote/{job_id} (body {breakdown}), DELETE /api/xero/delete_quote/{job_id} (no id parameter — one quote per job) at v1-parity URL fragments; GET /api/job/jobs/{id}/quote/ serving {quote: QuoteOut | null} — enveloped because the generated axios client coerces a bare JSON null body to {} (ledgered, with the conditional-GET drop).
  • PDF inspection: apps/accounting/services/quote_pdf.py + the inspect_xero_quote_pdf command emitting the single JSON line the spec parses; the file survives every diagnostic path.

Frontend

  • features/job/costing/CostLineGrid.tsx — the one cost-line grid (estimate/actual arrive later as prop configs), on TanStack Table v8 with module-constant column defs (per-render defs would remount and blur every input). Full day-one selector contract: .smart-costlines-table, exactly one trailing phantom tbody row, SmartCostLinesTable-*/DataTable-row-*/data-grid-* from the visual index, ItemSelect-option-*, trigger named Select Item only when unbound.
  • useAutosaveField derives its display value (local buffer only while editing) — an effect-synced copy provably kept rejected input on screen when an optimistic write and its rollback coalesced into one render. 600ms debounce, blur flushes and cancels, deliberately no If-Match on cost-line CRUD (v1 parity).
  • JobQuoteTab + XeroQuoteCard: server-owned summary (ADR 0046), ping-gated create, "Export Quote to Xero" dialog (Send Total Only / Send Breakdown), open-in-Xero with noopener, delete. Lazy-loaded.
  • Deferred with attributes already in place: keyboard-nav behaviour, duplicate-line, unit-rev override bookkeeping, data-freshness polling.

Spec port

Recorded deviations from v1's spec: own job instead of the read-only sharedEditJobUrl fixture; no in-spec ping (global setup fails the run closed); waitForAutosave instead of 800ms sleeps; PDF inspector spawned via uv run python. The repair machinery ports near-verbatim — it is what exercises the grid every run (the fresh job's material line lacks a stock binding).

Review

Adversarial 2-subagent review pre-PR; all four backend should-fixes and the frontend blocker + should-fixes applied with regression tests (draft-POST failure recovery, same-value retry after rollback). Declined with reasons in the commit: the compact-match "tightening" (equivalent in power to the existing check) and the readonly totals type change (2b precedent).

Verification: 1708 unit tests, coverage 88.46%, job-xero-quote green live, full E2E gate green twice (before and after review fixes).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Quote workspace to job details for managing cost lines, labour, stock, pricing, and autosaving changes.
    • Added Xero quote creation with total-only or detailed line breakdown options.
    • Added options to open quotes in Xero and delete existing quotes.
    • Added job quote retrieval through the API, including quote numbers and summary details.
    • Added quote PDF inspection for validating terms, branding, page count, and extracted text.
  • Bug Fixes
    • Improved handling of missing invoice totals and clearer document deletion errors.
  • Documentation
    • Updated API and rewrite progress documentation for quote functionality.

corrinand others added 10 commits August 9, 2026 15:30
…otocol
QuotePayload carries required terms (Xero applies no default to API-created
quotes) and an expiry date; QuotePdfDocument hands the caller a temp file it
owns. Live provider mirrors the invoice shapes: constructed edit-URL, DELETED
upsert after a contact/date pre-read, PDF download that raises rather than
returning a partial result. Readonly fabricates QU-E2E-* results and refuses
the PDF download outright — a fabricated file would satisfy the text
assertion against nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… mirror
Expected refusals (already quoted, T&M pricing, empty quote cost set, blank
breakdown descriptions, missing theme/terms config) return typed 400 values
with the provider never called; unexpected failures persist once and re-raise
per the base contract. Total-only mode sends the cost-set summary revenue as
a single line; breakdown mode one sanitised line per cost line. The job's
updated_at bumps in-request so the tab refetch sees quoted=true. The
duplicated _create_job_event hoisted from invoice.py into the base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /xero/create_quote/{job_id} (body: breakdown) and DELETE
/xero/delete_quote/{job_id} mirror the invoice handlers; delete takes no id
parameter because a job holds at most one quote. GET /job/jobs/{id}/quote/
serves the Xero quote header or null — a plain GET, not v1's conditional-GET:
nothing external holds the URL and 304-with-empty-body reads as no-quote to
an axios consumer (ledger entry to follow with the slice docs).
XeroDocumentSuccessResponse gains nullable quote_id beside invoice_id; the
two xero operation renames are recorded in the work-list ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the v1 inspection: pypdf text extraction over non-blank pages,
space-normalised AND compact matching (Xero's text layer wraps mid-phrase
and sometimes drops word spaces), blank render raises rather than reporting
the marker absent, and the temp file survives every failure path for
diagnosis. The command emits exactly one sorted-keys JSON line — the
subprocess contract the E2E quote spec parses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-exports the cost-line/labour-rates/stock-search factories and the three
new quote operations through the api boundary. Adds @tanstack/react-table
and the shadcn popover + command primitives (installed, not hand-written).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TanStack Table v8 with module-constant column defs — rebuilding them per
render changes every cell component's identity and remounts (blurring) all
inputs, so cells reach live state through table meta instead. Day-one
contract: .smart-costlines-table, exactly one trailing phantom tbody row,
SmartCostLinesTable-*/DataTable-row-*/data-grid-* attributes derived from
the visual index, ItemSelect with labour-first options.
useAutosaveField DERIVES its display value (local buffer only while
editing): an optimistic write and its failure rollback can land between two
renders, so an effect keyed on the server value never fires — the unit net
caught a synced copy keeping rejected input on screen. 600ms debounce,
blur flushes and cancels, no If-Match on cost-line CRUD (deliberate, v1
parity). All failures toast; the tests fail on any console.error, matching
the E2E guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quote workspace: editable quote cost set, server-owned summary card
(ADR 0046), and the Xero card (ping-gated create, Export Quote to Xero
dialog with Send Total Only / Send Breakdown, open-in-Xero with noopener,
delete). Lazy-loaded from the job detail page.
The retrieve contract changed to {quote: QuoteOut | null}: the unit net
caught the generated axios client coercing a bare JSON null body to {},
which read as an existing quote and crashed the card — an envelope is the
only shape that round-trips absence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…check
Deliberate deviations from the v1 spec, each with its reason in place: a
dedicated job instead of the read-only sharedEditJobUrl fixture; no in-spec
Xero ping (global setup fails the run closed); waitForAutosave instead of
800ms sleeps around the 600ms debounce; and the PDF inspector spawns via
uv run python since nothing guarantees an activated interpreter under npm.
The repair machinery (Select Item pick, desc/unit-rev fills) ports
near-verbatim — it is the part of the spec that exercises the grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st states
Backend: the concurrent-push loser now voids its orphan Xero quote (under a
savepoint, so the compensation can persist its AppError) and refuses with
the readable 400; a quote deleted Xero-side returns a typed 404 from the
provider pre-read and the manager treats it as cleanup-allowed instead of
bricking the job; the local mirror stores the payload's date (midnight
span); present-but-null totals get the crafted message in both quote and
invoice managers; the not-found PDF keeps its file for diagnosis;
delete_invoice passes error_type through like its quote sibling.
Frontend: a failed draft POST clears the persisting guard so the row stays
retryable (was permanently bricked); draft inputs disable while the create
is in flight; the send-dedupe only skips a KNOWN-applied value so a rejected
edit can be retried; the quote card renders pending as pending, not as
create-state; error states stop masquerading as data (picker, ping,
summary); background-refetch errors keep the working grid; null profit
margin renders as a dash, not 0.0%; PATCH rollback reverts only the patched
fields against the current cache, with in-flight refetches cancelled first.
Spec: isEnabled guard before repairing a rev input; response schema
validates quote_id/online_url. Declined: the compact-match tightening (the
proposed regex is equivalent in power — its own counterexample defeats
both) and the readonly totals type change (2b precedent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:23 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f0ec6174-067a-4295-8ee5-20d5269c5cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 9ae11da and e19f4e6.

📒 Files selected for processing (11)
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/services/quote_pdf.py
  • docs/code-quality.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
📝 Walkthrough

Walkthrough

Added end-to-end Xero quote support across accounting providers, quote APIs, PDF inspection, job retrieval, frontend costing, and quote management UI. Added tests, OpenAPI updates, compatibility mappings, and E2E validation.

Changes

Xero quote backend

Layer / File(s)Summary
Provider contracts and integrations
apps/accounting/types.py, apps/accounting/provider.py, apps/xero/provider.py, apps/xero/readonly_provider.py, stubs/xero_python/..., apps/xero/tests/test_provider_documents.py
Added quote payload and PDF types, provider operations, Xero quote create/delete/PDF support, readonly behavior, and SDK stubs.
Quote manager and API lifecycle
apps/xero/documents/quote.py, apps/xero/api.py, apps/xero/documents/base.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py
Added quote validation, payload construction, persistence, compensation, deletion, audit events, authenticated endpoints, and lifecycle tests.
Quote PDF inspection tooling
apps/accounting/services/quote_pdf.py, apps/accounting/management/commands/..., apps/accounting/tests/test_quote_pdf.py
Added PDF text inspection, branding and page metadata, diagnostic-file handling, and JSON command output.
Job quote retrieval contract
apps/job/api.py, apps/job/schemas.py, apps/job/services/job_service.py, frontend/schema.v2.yml, frontend/src/api/index.ts
Added nullable enveloped quote retrieval with quote-number serialization and matching API schemas.
Cost-line editing workspace
frontend/src/features/job/costing/*, frontend/src/components/ui/*, frontend/package.json
Added cost-line types, calculations, autosave, optimistic CRUD, item selection, editable grid behavior, and supporting UI primitives.
Quote tab and Xero card
frontend/src/features/job/JobDetailPage.tsx, frontend/src/features/job/costing/JobQuoteTab.tsx, frontend/src/features/job/costing/XeroQuoteCard.tsx
Added the lazy-loaded quote tab, quote summary, cost grid, Xero quote creation modes, deletion, deep links, and mutation states.
End-to-end validation and status support
frontend/tests/e2e/job/job-xero-quote.spec.ts, docs/rewrite-status.md, docs/accepted-api-differences.yml, scripts/v1-frontend-operations.yml
Added E2E quote creation and PDF checks, diagnostic repair logic, status updates, API difference documentation, and operation mappings.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant User
participant JobQuoteTab
participant XeroQuoteCard
participant QuoteAPI
participant Xero
User->>JobQuoteTab: Open the quote tab
JobQuoteTab->>XeroQuoteCard: Load quote state
User->>XeroQuoteCard: Select quote mode and submit
XeroQuoteCard->>QuoteAPI: Create quote request
QuoteAPI->>Xero: Create quote
Xero-->>QuoteAPI: Return quote identifiers and totals
QuoteAPI-->>XeroQuoteCard: Return quote response
XeroQuoteCard-->>User: Display quote link and status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 32.57% 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 Xero quote path, cost-line grid, and related end-to-end test result.
Description check✅ PassedThe description thoroughly covers implementation details, scope, deviations, review outcomes, and verification results, despite omitting 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-path

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: 8

🧹 Nitpick comments (10)
frontend/src/features/job/costing/useCostLines.ts (1)

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

Document why deleteLine restores the whole snapshot.

patchLine carries a comment that rejects the wholesale snapshot restore and explains the interleaved-write constraint. deleteLine then uses that exact rejected strategy at Line 134 with no comment. A failed delete restores the full pre-delete cost set, so it also reverts any successful interleaved patch on another line until the onSettled refetch lands. Record the constraint that makes the wholesale restore correct here, for example that a removed row cannot be reconstructed field-by-field.

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 `@frontend/src/features/job/costing/useCostLines.ts` around lines 121 - 140,
Update deleteLine’s onError rollback comment to document why restoring the
entire snapshot is intentional: unlike patchLine, a deleted row cannot be
reconstructed field-by-field, so a wholesale restore is required despite
potentially reverting interleaved writes until invalidate refetches.

Source: Coding guidelines

frontend/src/features/job/costing/calc.test.ts (1)

123-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the two unresolved-reference fallbacks in itemLabel.

The suite does not exercise the fallback at calc.ts Line 79 (labour rate not in the list) or Line 86 (stock id not in the loaded page). The comment on calc.ts Line 85 states that the 'Stock item' fallback must not read as 'Select Item', because the E2E repair loop counts buttons by that exact name. A regression that returns 'Select Item' for an unresolved stock id would pass this suite and fail only in Playwright. Add the two cases.

🧪 Proposed additional cases
 it('names the labour subtype for a time line', () => {
expect(itemLabel(line({ kind: 'time', labour_subtype: 'workshop' }), stockById, rates)).toBe(
'Workshop',
)
})
++ it('falls back to the raw subtype when the rate is not loaded', () => {+ expect(itemLabel(line({ kind: 'time', labour_subtype: 'nightshift' }), stockById, rates)).toBe(+ 'nightshift',+ )+ })++ it('never reads as "Select Item" when bound to unloaded stock', () => {+ expect(itemLabel(line({ ext_refs: { stock_id: 'stock-unloaded' } }), stockById, rates)).toBe(+ 'Stock item',+ )+ })
})
🤖 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/calc.test.ts` around lines 123 - 140, Add
two `itemLabel` tests covering unresolved references: verify a time line with a
labour subtype absent from `rates` uses the labour-rate fallback, and verify a
bound line whose stock ID is absent from `stockById` returns the distinct “Stock
item” fallback rather than “Select Item”.
frontend/src/features/job/costing/CostLineGrid.test.tsx (1)

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

Annotate the labourRates and stockPage fixtures with the generated types.

materialLine and costSet are annotated, so a wire-type change breaks this file at compile time. labourRates and stockPage are bare literals, so the same change passes type-checking and fails only at runtime. calc.test.ts annotates the equivalent fixtures as JobLabourRateOut and StockItem. Add const labourRates: JobLabourRateOut[] and the generated page type for stockPage.

🤖 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.test.tsx` around lines 67 -
108, Annotate the labourRates fixture as JobLabourRateOut[] and annotate
stockPage with the generated page type used for StockItem results, matching the
equivalent fixtures in calc.test.ts. Preserve the existing fixture values while
ensuring wire-type changes are caught during compilation.
frontend/src/features/job/costing/ItemSelect.tsx (1)

88-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider debouncing the stock search input.

CommandInput calls onValueChange={setSearch} directly, and search is part of the purchasingStockSearchRetrieveOptions query key. Every keystroke while the popover is open fires a new server-side stock search request. The component's own comment notes that queries under 3 characters list everything, so short inputs during typing can each trigger a full unfiltered fetch.

Debounce the value passed to the query (for example with a small useDeferredValue or timer-based hook) so the request fires once typing pauses.

🤖 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/ItemSelect.tsx` around lines 88 - 92,
Debounce the search value used by the stock query in ItemSelect rather than
passing the raw search state into purchasingStockSearchRetrieveOptions on every
keystroke. Keep CommandInput responsive with the immediate value, and use a
small deferred or timer-based value so requests occur after typing pauses while
preserving the existing short-query behavior.
frontend/src/features/job/costing/XeroQuoteCard.test.tsx (1)

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

Use the user returned by renderWithProviders.

renderWithProviders already calls userEvent.setup() and returns the instance. Each test creates a second instance. Two sessions can hold separate pointer and keyboard state. Reuse the returned user in all five tests.

- const user = userEvent.setup()- renderWithProviders(<XeroQuoteCard jobId="job-1" />)+ const { user } = renderWithProviders(<XeroQuoteCard jobId="job-1" />)
🤖 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/XeroQuoteCard.test.tsx` around lines 72 -
73, Update all five tests in XeroQuoteCard.test.tsx to destructure and reuse the
user instance returned by renderWithProviders, removing each redundant
userEvent.setup() call while preserving the existing test interactions.
frontend/src/features/job/costing/CostLineGrid.tsx (1)

192-199: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Move the create call out of the setDrafts updater.

commitDraftField runs persistDraftIfReady inside a state updater. That updater executes during render. It starts a network mutation and calls syncPersisting (another setState) from render. React requires updaters to be pure. The persistingRef guard hides the StrictMode double invocation today, but any future replay of the updater still re-enters impure code.

Keep the latest drafts in a ref and run the persistence after the state update.

♻️ Proposed refactor: read drafts from a ref instead of a state updater
 const [drafts, setDrafts] = useState<DraftRow[]>([freshPhantom()])
+ // persistDraftIfReady must see the same render's updateDraft result, but a+ // network call inside a state updater is not a pure update; the ref carries+ // that latest value out of the updater instead.+ const draftsRef = useRef<DraftRow[]>(drafts)+ draftsRef.current = drafts
@@
updateDraft: (localId, patch) => {
setDrafts((current) => {
...
- return next+ draftsRef.current = next+ return next
})
},
commitDraftField: (localId) => {
- setDrafts((current) => {- persistDraftIfReady(current, localId)- return current- })+ persistDraftIfReady(draftsRef.current, localId)
},
🤖 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 192 - 199,
Refactor commitDraftField so persistDraftIfReady is no longer called inside the
setDrafts updater; keep the latest drafts synchronized in a ref and invoke
persistence after the state update using that ref. Ensure the setDrafts updater
remains pure while preserving the existing localId persistence behavior and
syncPersisting flow.
apps/xero/tests/test_provider_documents.py (2)

431-438: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the 404 status code, not only the error text.

XeroQuoteManager.delete_document (apps/xero/documents/quote.py, line 311) branches on result.status_code == 404 to clean up the local row when the quote is already gone from Xero. The error string is not part of that contract; the status code is. This test passes today even if delete_quote returns status 500 for an absent quote, which would brick the recovery path.

♻️ Proposed assertion
 result = provider.delete_quote(str(uuid.uuid4()))
assert not result.success
assert result.error is not None and "no quote" in result.error
+ # The manager keys its local-cleanup branch on this code, not the text.+ assert result.status_code == 404
🤖 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_provider_documents.py` around lines 431 - 438, Update
test_missing_quote_is_an_error_result to assert that the failed delete_quote
result has status_code 404, preserving the existing unsuccessful-result check
while treating the status code—not the error text—as the contract for a missing
quote.

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

Tighten the match, or drop "persists" from the test name.

match="quote" matches every ValueError this method raises, including the "returned N quotes" guard. The name promises a persistence assertion that the body does not make.

♻️ Proposed change
- with pytest.raises(ValueError, match="quote"):+ with pytest.raises(ValueError, match="for a request naming"):
provider.download_quote_pdf(str(uuid.uuid4()))
🤖 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_provider_documents.py` around lines 462 - 467, Align
test_quote_id_mismatch_raises_and_persists with its actual coverage: either
assert the specific quote-ID mismatch error message rather than the broad
“quote” match, and add the intended persistence assertion, or rename the test to
remove “persists” if persistence is not being verified.
apps/xero/tests/test_quote_manager.py (1)

170-197: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the unvoidable-orphan branch.

This test covers the race loser whose compensating void succeeds. The branch at apps/xero/documents/quote.py lines 216-224 is untested: when delete_quote also fails, the manager raises a ValueError carrying the orphan quote id. That id is the only record an operator has, because the AppError row cannot carry it. A regression that swallowed this raise would leave a real Xero quote orphaned with no trace.

♻️ Proposed companion test
+ def test_race_loser_raises_when_the_orphan_cannot_be_voided(+ self, company: Company, job: Job, office_staff: Staff+ ) -> None:+ """An unvoidable orphan must reach an operator with its Xero id."""+ provider = Mock()+ provider.get_account_code.return_value = "200"+ orphan = _success_result()++ def concurrent_winner_lands_first(payload: object) -> DocumentResult: # noqa: ARG001+ _existing_quote(job, company)+ return orphan++ provider.create_quote.side_effect = concurrent_winner_lands_first+ provider.delete_quote.return_value = DocumentResult(+ success=False, error="Quote is ACCEPTED", status_code=400+ )+ manager = _manager(company, job, office_staff, provider)++ with pytest.raises(ValueError, match=str(orphan.external_id)):+ manager.create_document(breakdown=False)++ assert Quote.objects.count() == 1 # only the winner's row
🤖 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 170 - 197, Add a
companion test for the race-loser path in create_document where
provider.delete_quote fails after Quote.objects.create is rejected; assert the
manager raises ValueError containing the orphan Xero quote id, while preserving
the existing assertions for the successful compensation case.
stubs/xero_python/accounting/__init__.pyi (1)

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

Add the Quote fields the provider sets.

apps/xero/provider.py (lines 271-272) passes line_amount_types and currency_code into Quote(...). The stub does not declare either attribute. The call type-checks today only because __init__ accepts **kwargs: Any. Any later attribute read of those fields would fail under strict mypy.

♻️ Proposed stub additions
 class Quote:
quote_id: str | None
quote_number: str | None
contact: Contact | None
date: Any
expiry_date: Any
status: str | None
line_items: list[LineItem] | None
+ line_amount_types: str | None+ currency_code: str | None
branding_theme_id: str | None
terms: str | None
reference: str | None
updated_date_utc: Any
🤖 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 `@stubs/xero_python/accounting/__init__.pyi` around lines 80 - 93, Update the
Quote stub by declaring the line_amount_types and currency_code attributes
alongside the existing quote fields, using types consistent with the values
passed by apps/xero/provider.py. Keep the existing constructor and serialization
declarations unchanged.
🤖 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 `@apps/accounting/management/commands/inspect_xero_quote_pdf.py`:
- Line 28: Replace the narration docstring near the command’s
validation/inspection flow in
apps/accounting/management/commands/inspect_xero_quote_pdf.py:28-28 with either
no comment or rationale explaining the rejected behavior and the subprocess
contract requiring the selected behavior. Update the comment in
frontend/tests/e2e/job/job-xero-quote.spec.ts:139-140 to explain that treating
the phantom row as a persisted cost line would create invalid repair work
because it has no persisted cost-line data.
In `@apps/accounting/services/quote_pdf.py`:
- Around line 32-69: Wrap the PdfReader usage in the inspection flow with a
context manager so its streams close before any cleanup. Ensure the reader is
closed on both successful validation and failure paths, while preserving the
diagnostic PDF when the expected text is absent and allowing unlink after the
reader scope ends.
In `@frontend/src/features/job/costing/calc.ts`:
- Around line 32-44: Update stockPickPatch to explicitly set labour_subtype to
null when constructing the material CostLineUpdateRequest, while preserving the
existing stock and cost field mappings.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 410-419: Update the onPickStock handler to preserve null for
missing patch.unit_cost and patch.unit_rev instead of converting absent values
to empty strings. Ensure the resulting DraftLine remains incomplete when either
price is unavailable, and omit null price fields when constructing the create
PATCH request.
In `@frontend/src/features/job/costing/useAutosaveField.ts`:
- Around line 52-64: Update dispatch to compare against a live serverValue ref
rather than the render-captured serverValue when the debounce callback runs.
Keep the existing knownApplied and untouched deduplication behavior, and ensure
the ref is synchronized with the latest server value before dispatch evaluates
parsed.
In `@frontend/src/features/job/costing/XeroQuoteCard.tsx`:
- Around line 170-190: Update the button label logic in the XeroQuoteCard
component to distinguish ping.isPending from a genuinely disconnected
xeroConnected state. While the Xero connection check is pending, show a
checking/loading label and prevent the logged-out “Login to Xero first” message;
retain the existing labels once the check resolves.
- Around line 81-95: The executeDelete handler currently deletes the quote
without confirmation. Update executeDelete to show a visible confirmation
dialog, such as window.confirm with a clear deletion message, and only call
deleteQuote.mutate when the user confirms; preserve the existing pending guard
and success/error callbacks.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Line 173: Update the AccountingApi quote PDF call to use the method name
provided by xero-python==15.0.0 instead of get_quote_as_pdf, avoiding the
AttributeError. If a local get_quote_as_pdf implementation is retained, pass
headers={"Accept": "application/pdf"} and write its response to a temporary file
before applying the return-type check, rather than treating the raw response as
a path.
---
Nitpick comments:
In `@apps/xero/tests/test_provider_documents.py`:
- Around line 431-438: Update test_missing_quote_is_an_error_result to assert
that the failed delete_quote result has status_code 404, preserving the existing
unsuccessful-result check while treating the status code—not the error text—as
the contract for a missing quote.
- Around line 462-467: Align test_quote_id_mismatch_raises_and_persists with its
actual coverage: either assert the specific quote-ID mismatch error message
rather than the broad “quote” match, and add the intended persistence assertion,
or rename the test to remove “persists” if persistence is not being verified.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 170-197: Add a companion test for the race-loser path in
create_document where provider.delete_quote fails after Quote.objects.create is
rejected; assert the manager raises ValueError containing the orphan Xero quote
id, while preserving the existing assertions for the successful compensation
case.
In `@frontend/src/features/job/costing/calc.test.ts`:
- Around line 123-140: Add two `itemLabel` tests covering unresolved references:
verify a time line with a labour subtype absent from `rates` uses the
labour-rate fallback, and verify a bound line whose stock ID is absent from
`stockById` returns the distinct “Stock item” fallback rather than “Select
Item”.
In `@frontend/src/features/job/costing/CostLineGrid.test.tsx`:
- Around line 67-108: Annotate the labourRates fixture as JobLabourRateOut[] and
annotate stockPage with the generated page type used for StockItem results,
matching the equivalent fixtures in calc.test.ts. Preserve the existing fixture
values while ensuring wire-type changes are caught during compilation.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 192-199: Refactor commitDraftField so persistDraftIfReady is no
longer called inside the setDrafts updater; keep the latest drafts synchronized
in a ref and invoke persistence after the state update using that ref. Ensure
the setDrafts updater remains pure while preserving the existing localId
persistence behavior and syncPersisting flow.
In `@frontend/src/features/job/costing/ItemSelect.tsx`:
- Around line 88-92: Debounce the search value used by the stock query in
ItemSelect rather than passing the raw search state into
purchasingStockSearchRetrieveOptions on every keystroke. Keep CommandInput
responsive with the immediate value, and use a small deferred or timer-based
value so requests occur after typing pauses while preserving the existing
short-query behavior.
In `@frontend/src/features/job/costing/useCostLines.ts`:
- Around line 121-140: Update deleteLine’s onError rollback comment to document
why restoring the entire snapshot is intentional: unlike patchLine, a deleted
row cannot be reconstructed field-by-field, so a wholesale restore is required
despite potentially reverting interleaved writes until invalidate refetches.
In `@frontend/src/features/job/costing/XeroQuoteCard.test.tsx`:
- Around line 72-73: Update all five tests in XeroQuoteCard.test.tsx to
destructure and reuse the user instance returned by renderWithProviders,
removing each redundant userEvent.setup() call while preserving the existing
test interactions.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Around line 80-93: Update the Quote stub by declaring the line_amount_types
and currency_code attributes alongside the existing quote fields, using types
consistent with the values passed by apps/xero/provider.py. Keep the existing
constructor and serialization declarations unchanged.
🪄 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: 6120c3fb-75ad-489f-9410-9c2daf5d7eaa

📥 Commits

Reviewing files that changed from the base of the PR and between cd24a0f and 9ae11da.

⛔ Files ignored due to path filters (6)
  • frontend/package-lock.json is excluded by !**/package-lock.json
  • frontend/src/api/generated/@tanstack/react-query.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/index.ts is excluded by !**/generated/**
  • frontend/src/api/generated/sdk.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/types.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/zod.gen.ts is excluded by !**/generated/**
📒 Files selected for processing (45)
  • apps/accounting/management/__init__.py
  • apps/accounting/management/commands/__init__.py
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/provider.py
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/accounting/types.py
  • apps/job/api.py
  • apps/job/schemas.py
  • apps/job/services/job_service.py
  • apps/job/tests/test_job_api.py
  • apps/xero/api.py
  • apps/xero/documents/base.py
  • apps/xero/documents/invoice.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/readonly_provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/accepted-api-differences.yml
  • docs/rewrite-status.md
  • frontend/package.json
  • frontend/schema.v2.yml
  • frontend/src/api/index.ts
  • frontend/src/components/ui/command.tsx
  • frontend/src/components/ui/popover.tsx
  • frontend/src/features/job/JobDetailPage.tsx
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/ItemSelect.tsx
  • frontend/src/features/job/costing/JobQuoteTab.test.tsx
  • frontend/src/features/job/costing/JobQuoteTab.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/types.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • frontend/src/lib/format.ts
  • frontend/src/test/setup.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
  • scripts/v1-frontend-operations.yml
  • stubs/xero_python/accounting/__init__.pyi

Comment threadapps/accounting/services/quote_pdf.py Outdated
Comment threadfrontend/src/features/job/costing/calc.ts
Comment threadfrontend/src/features/job/costing/CostLineGrid.tsx
Comment threadfrontend/src/features/job/costing/useAutosaveField.ts
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadstubs/xero_python/accounting/__init__.pyi
corrinand others added 2 commits August 9, 2026 18:13
Movements all belong to this slice: two call-time manager imports and test
fixtures (PLC0415), the provider's deliberate exception-to-result
conversions (BLE001, returns-instead), the TableMeta module augmentation
(eslint-disable), and the new manager/provider try shapes. Passthrough
stays pinned at zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est states
Close the PdfReader before any unlink (a held stream fails deletion on
Windows); a stock pick clears labour_subtype so a converted time line
carries no stale subtype; the draft item-pick preserves absence as null
instead of '' (an empty string would satisfy the persist-ready check);
the debounce dispatch compares against the live server value via a ref;
quote deletion asks for confirmation like a cost-line delete; a pending
Xero ping reads 'Checking Xero…' instead of the logged-out label; two
comments now state their rejected alternatives. Declined with evidence in
the thread: the claim that xero-python 15.0.0 lacks get_quote_as_pdf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@corrin
corrin merged commit cd5943d into mainAug 9, 2026
3 checks passed
corrin added a commit that referenced this pull request Aug 9, 2026
* Quote hardening B1-B5: explicit validation, full compensation, unbricked 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>
* One document-endpoint adapter: auth refusal, failure map, success build
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>
* Grid hardening F1-F6 + assertive spec: drafts persist, retries retry
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>
* Record the deferred structure work; regenerate metrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Persist the collision at its catch site; refresh derived rows
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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40) - #48

Merged
corrin merged 12 commits into
mainfrom
xero/quote-path
Aug 9, 2026
Merged

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40)#48
corrin merged 12 commits into
mainfrom
xero/quote-path

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Slice 2c — the last of the three slice-2 Xero PRs. Done means the spec is green: job-xero-quote.spec.ts passed writes-live against the demo tenant (real DRAFT quote created, native Xero PDF downloaded server-side and the configured terms text found in it), and the full run_e2e.sh gate passed all 39 tests across the 14 ported spec files on the final code.

Backend

  • apps/xero/documents/quote.py — expected refusals (already quoted, T&M pricing, empty quote cost set, blank breakdown descriptions, missing theme/terms config) return typed 400 values with the provider never called; unexpected failures persist once and re-raise. Total-only mode sends the cost-set summary revenue as one line; breakdown mode one sanitised line per cost line. The concurrent-push loser voids its orphan Xero quote (savepoint-guarded) before refusing; a quote deleted Xero-side comes back as a typed 404 from the provider pre-read and the manager cleans up the local row instead of bricking the job.
  • Provider: create_quote, delete_quote (soft delete via pre-read + DELETED upsert), download_quote_pdf. Readonly fabricates QU-E2E-* results and refuses the PDF download — a fabricated file would satisfy the text assertion against nothing.
  • Endpoints: POST /api/xero/create_quote/{job_id} (body {breakdown}), DELETE /api/xero/delete_quote/{job_id} (no id parameter — one quote per job) at v1-parity URL fragments; GET /api/job/jobs/{id}/quote/ serving {quote: QuoteOut | null} — enveloped because the generated axios client coerces a bare JSON null body to {} (ledgered, with the conditional-GET drop).
  • PDF inspection: apps/accounting/services/quote_pdf.py + the inspect_xero_quote_pdf command emitting the single JSON line the spec parses; the file survives every diagnostic path.

Frontend

  • features/job/costing/CostLineGrid.tsx — the one cost-line grid (estimate/actual arrive later as prop configs), on TanStack Table v8 with module-constant column defs (per-render defs would remount and blur every input). Full day-one selector contract: .smart-costlines-table, exactly one trailing phantom tbody row, SmartCostLinesTable-*/DataTable-row-*/data-grid-* from the visual index, ItemSelect-option-*, trigger named Select Item only when unbound.
  • useAutosaveField derives its display value (local buffer only while editing) — an effect-synced copy provably kept rejected input on screen when an optimistic write and its rollback coalesced into one render. 600ms debounce, blur flushes and cancels, deliberately no If-Match on cost-line CRUD (v1 parity).
  • JobQuoteTab + XeroQuoteCard: server-owned summary (ADR 0046), ping-gated create, "Export Quote to Xero" dialog (Send Total Only / Send Breakdown), open-in-Xero with noopener, delete. Lazy-loaded.
  • Deferred with attributes already in place: keyboard-nav behaviour, duplicate-line, unit-rev override bookkeeping, data-freshness polling.

Spec port

Recorded deviations from v1's spec: own job instead of the read-only sharedEditJobUrl fixture; no in-spec ping (global setup fails the run closed); waitForAutosave instead of 800ms sleeps; PDF inspector spawned via uv run python. The repair machinery ports near-verbatim — it is what exercises the grid every run (the fresh job's material line lacks a stock binding).

Review

Adversarial 2-subagent review pre-PR; all four backend should-fixes and the frontend blocker + should-fixes applied with regression tests (draft-POST failure recovery, same-value retry after rollback). Declined with reasons in the commit: the compact-match "tightening" (equivalent in power to the existing check) and the readonly totals type change (2b precedent).

Verification: 1708 unit tests, coverage 88.46%, job-xero-quote green live, full E2E gate green twice (before and after review fixes).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Quote workspace to job details for managing cost lines, labour, stock, pricing, and autosaving changes.
    • Added Xero quote creation with total-only or detailed line breakdown options.
    • Added options to open quotes in Xero and delete existing quotes.
    • Added job quote retrieval through the API, including quote numbers and summary details.
    • Added quote PDF inspection for validating terms, branding, page count, and extracted text.
  • Bug Fixes
    • Improved handling of missing invoice totals and clearer document deletion errors.
  • Documentation
    • Updated API and rewrite progress documentation for quote functionality.

corrinand others added 10 commits August 9, 2026 15:30
…otocol
QuotePayload carries required terms (Xero applies no default to API-created
quotes) and an expiry date; QuotePdfDocument hands the caller a temp file it
owns. Live provider mirrors the invoice shapes: constructed edit-URL, DELETED
upsert after a contact/date pre-read, PDF download that raises rather than
returning a partial result. Readonly fabricates QU-E2E-* results and refuses
the PDF download outright — a fabricated file would satisfy the text
assertion against nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… mirror
Expected refusals (already quoted, T&M pricing, empty quote cost set, blank
breakdown descriptions, missing theme/terms config) return typed 400 values
with the provider never called; unexpected failures persist once and re-raise
per the base contract. Total-only mode sends the cost-set summary revenue as
a single line; breakdown mode one sanitised line per cost line. The job's
updated_at bumps in-request so the tab refetch sees quoted=true. The
duplicated _create_job_event hoisted from invoice.py into the base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /xero/create_quote/{job_id} (body: breakdown) and DELETE
/xero/delete_quote/{job_id} mirror the invoice handlers; delete takes no id
parameter because a job holds at most one quote. GET /job/jobs/{id}/quote/
serves the Xero quote header or null — a plain GET, not v1's conditional-GET:
nothing external holds the URL and 304-with-empty-body reads as no-quote to
an axios consumer (ledger entry to follow with the slice docs).
XeroDocumentSuccessResponse gains nullable quote_id beside invoice_id; the
two xero operation renames are recorded in the work-list ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the v1 inspection: pypdf text extraction over non-blank pages,
space-normalised AND compact matching (Xero's text layer wraps mid-phrase
and sometimes drops word spaces), blank render raises rather than reporting
the marker absent, and the temp file survives every failure path for
diagnosis. The command emits exactly one sorted-keys JSON line — the
subprocess contract the E2E quote spec parses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-exports the cost-line/labour-rates/stock-search factories and the three
new quote operations through the api boundary. Adds @tanstack/react-table
and the shadcn popover + command primitives (installed, not hand-written).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TanStack Table v8 with module-constant column defs — rebuilding them per
render changes every cell component's identity and remounts (blurring) all
inputs, so cells reach live state through table meta instead. Day-one
contract: .smart-costlines-table, exactly one trailing phantom tbody row,
SmartCostLinesTable-*/DataTable-row-*/data-grid-* attributes derived from
the visual index, ItemSelect with labour-first options.
useAutosaveField DERIVES its display value (local buffer only while
editing): an optimistic write and its failure rollback can land between two
renders, so an effect keyed on the server value never fires — the unit net
caught a synced copy keeping rejected input on screen. 600ms debounce,
blur flushes and cancels, no If-Match on cost-line CRUD (deliberate, v1
parity). All failures toast; the tests fail on any console.error, matching
the E2E guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quote workspace: editable quote cost set, server-owned summary card
(ADR 0046), and the Xero card (ping-gated create, Export Quote to Xero
dialog with Send Total Only / Send Breakdown, open-in-Xero with noopener,
delete). Lazy-loaded from the job detail page.
The retrieve contract changed to {quote: QuoteOut | null}: the unit net
caught the generated axios client coercing a bare JSON null body to {},
which read as an existing quote and crashed the card — an envelope is the
only shape that round-trips absence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…check
Deliberate deviations from the v1 spec, each with its reason in place: a
dedicated job instead of the read-only sharedEditJobUrl fixture; no in-spec
Xero ping (global setup fails the run closed); waitForAutosave instead of
800ms sleeps around the 600ms debounce; and the PDF inspector spawns via
uv run python since nothing guarantees an activated interpreter under npm.
The repair machinery (Select Item pick, desc/unit-rev fills) ports
near-verbatim — it is the part of the spec that exercises the grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st states
Backend: the concurrent-push loser now voids its orphan Xero quote (under a
savepoint, so the compensation can persist its AppError) and refuses with
the readable 400; a quote deleted Xero-side returns a typed 404 from the
provider pre-read and the manager treats it as cleanup-allowed instead of
bricking the job; the local mirror stores the payload's date (midnight
span); present-but-null totals get the crafted message in both quote and
invoice managers; the not-found PDF keeps its file for diagnosis;
delete_invoice passes error_type through like its quote sibling.
Frontend: a failed draft POST clears the persisting guard so the row stays
retryable (was permanently bricked); draft inputs disable while the create
is in flight; the send-dedupe only skips a KNOWN-applied value so a rejected
edit can be retried; the quote card renders pending as pending, not as
create-state; error states stop masquerading as data (picker, ping,
summary); background-refetch errors keep the working grid; null profit
margin renders as a dash, not 0.0%; PATCH rollback reverts only the patched
fields against the current cache, with in-flight refetches cancelled first.
Spec: isEnabled guard before repairing a rev input; response schema
validates quote_id/online_url. Declined: the compact-match tightening (the
proposed regex is equivalent in power — its own counterexample defeats
both) and the readonly totals type change (2b precedent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:23 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f0ec6174-067a-4295-8ee5-20d5269c5cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 9ae11da and e19f4e6.

📒 Files selected for processing (11)
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/services/quote_pdf.py
  • docs/code-quality.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
📝 Walkthrough

Walkthrough

Added end-to-end Xero quote support across accounting providers, quote APIs, PDF inspection, job retrieval, frontend costing, and quote management UI. Added tests, OpenAPI updates, compatibility mappings, and E2E validation.

Changes

Xero quote backend

Layer / File(s)Summary
Provider contracts and integrations
apps/accounting/types.py, apps/accounting/provider.py, apps/xero/provider.py, apps/xero/readonly_provider.py, stubs/xero_python/..., apps/xero/tests/test_provider_documents.py
Added quote payload and PDF types, provider operations, Xero quote create/delete/PDF support, readonly behavior, and SDK stubs.
Quote manager and API lifecycle
apps/xero/documents/quote.py, apps/xero/api.py, apps/xero/documents/base.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py
Added quote validation, payload construction, persistence, compensation, deletion, audit events, authenticated endpoints, and lifecycle tests.
Quote PDF inspection tooling
apps/accounting/services/quote_pdf.py, apps/accounting/management/commands/..., apps/accounting/tests/test_quote_pdf.py
Added PDF text inspection, branding and page metadata, diagnostic-file handling, and JSON command output.
Job quote retrieval contract
apps/job/api.py, apps/job/schemas.py, apps/job/services/job_service.py, frontend/schema.v2.yml, frontend/src/api/index.ts
Added nullable enveloped quote retrieval with quote-number serialization and matching API schemas.
Cost-line editing workspace
frontend/src/features/job/costing/*, frontend/src/components/ui/*, frontend/package.json
Added cost-line types, calculations, autosave, optimistic CRUD, item selection, editable grid behavior, and supporting UI primitives.
Quote tab and Xero card
frontend/src/features/job/JobDetailPage.tsx, frontend/src/features/job/costing/JobQuoteTab.tsx, frontend/src/features/job/costing/XeroQuoteCard.tsx
Added the lazy-loaded quote tab, quote summary, cost grid, Xero quote creation modes, deletion, deep links, and mutation states.
End-to-end validation and status support
frontend/tests/e2e/job/job-xero-quote.spec.ts, docs/rewrite-status.md, docs/accepted-api-differences.yml, scripts/v1-frontend-operations.yml
Added E2E quote creation and PDF checks, diagnostic repair logic, status updates, API difference documentation, and operation mappings.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant User
participant JobQuoteTab
participant XeroQuoteCard
participant QuoteAPI
participant Xero
User->>JobQuoteTab: Open the quote tab
JobQuoteTab->>XeroQuoteCard: Load quote state
User->>XeroQuoteCard: Select quote mode and submit
XeroQuoteCard->>QuoteAPI: Create quote request
QuoteAPI->>Xero: Create quote
Xero-->>QuoteAPI: Return quote identifiers and totals
QuoteAPI-->>XeroQuoteCard: Return quote response
XeroQuoteCard-->>User: Display quote link and status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 32.57% 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 Xero quote path, cost-line grid, and related end-to-end test result.
Description check✅ PassedThe description thoroughly covers implementation details, scope, deviations, review outcomes, and verification results, despite omitting 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-path

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: 8

🧹 Nitpick comments (10)
frontend/src/features/job/costing/useCostLines.ts (1)

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

Document why deleteLine restores the whole snapshot.

patchLine carries a comment that rejects the wholesale snapshot restore and explains the interleaved-write constraint. deleteLine then uses that exact rejected strategy at Line 134 with no comment. A failed delete restores the full pre-delete cost set, so it also reverts any successful interleaved patch on another line until the onSettled refetch lands. Record the constraint that makes the wholesale restore correct here, for example that a removed row cannot be reconstructed field-by-field.

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 `@frontend/src/features/job/costing/useCostLines.ts` around lines 121 - 140,
Update deleteLine’s onError rollback comment to document why restoring the
entire snapshot is intentional: unlike patchLine, a deleted row cannot be
reconstructed field-by-field, so a wholesale restore is required despite
potentially reverting interleaved writes until invalidate refetches.

Source: Coding guidelines

frontend/src/features/job/costing/calc.test.ts (1)

123-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the two unresolved-reference fallbacks in itemLabel.

The suite does not exercise the fallback at calc.ts Line 79 (labour rate not in the list) or Line 86 (stock id not in the loaded page). The comment on calc.ts Line 85 states that the 'Stock item' fallback must not read as 'Select Item', because the E2E repair loop counts buttons by that exact name. A regression that returns 'Select Item' for an unresolved stock id would pass this suite and fail only in Playwright. Add the two cases.

🧪 Proposed additional cases
 it('names the labour subtype for a time line', () => {
expect(itemLabel(line({ kind: 'time', labour_subtype: 'workshop' }), stockById, rates)).toBe(
'Workshop',
)
})
++ it('falls back to the raw subtype when the rate is not loaded', () => {+ expect(itemLabel(line({ kind: 'time', labour_subtype: 'nightshift' }), stockById, rates)).toBe(+ 'nightshift',+ )+ })++ it('never reads as "Select Item" when bound to unloaded stock', () => {+ expect(itemLabel(line({ ext_refs: { stock_id: 'stock-unloaded' } }), stockById, rates)).toBe(+ 'Stock item',+ )+ })
})
🤖 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/calc.test.ts` around lines 123 - 140, Add
two `itemLabel` tests covering unresolved references: verify a time line with a
labour subtype absent from `rates` uses the labour-rate fallback, and verify a
bound line whose stock ID is absent from `stockById` returns the distinct “Stock
item” fallback rather than “Select Item”.
frontend/src/features/job/costing/CostLineGrid.test.tsx (1)

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

Annotate the labourRates and stockPage fixtures with the generated types.

materialLine and costSet are annotated, so a wire-type change breaks this file at compile time. labourRates and stockPage are bare literals, so the same change passes type-checking and fails only at runtime. calc.test.ts annotates the equivalent fixtures as JobLabourRateOut and StockItem. Add const labourRates: JobLabourRateOut[] and the generated page type for stockPage.

🤖 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.test.tsx` around lines 67 -
108, Annotate the labourRates fixture as JobLabourRateOut[] and annotate
stockPage with the generated page type used for StockItem results, matching the
equivalent fixtures in calc.test.ts. Preserve the existing fixture values while
ensuring wire-type changes are caught during compilation.
frontend/src/features/job/costing/ItemSelect.tsx (1)

88-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider debouncing the stock search input.

CommandInput calls onValueChange={setSearch} directly, and search is part of the purchasingStockSearchRetrieveOptions query key. Every keystroke while the popover is open fires a new server-side stock search request. The component's own comment notes that queries under 3 characters list everything, so short inputs during typing can each trigger a full unfiltered fetch.

Debounce the value passed to the query (for example with a small useDeferredValue or timer-based hook) so the request fires once typing pauses.

🤖 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/ItemSelect.tsx` around lines 88 - 92,
Debounce the search value used by the stock query in ItemSelect rather than
passing the raw search state into purchasingStockSearchRetrieveOptions on every
keystroke. Keep CommandInput responsive with the immediate value, and use a
small deferred or timer-based value so requests occur after typing pauses while
preserving the existing short-query behavior.
frontend/src/features/job/costing/XeroQuoteCard.test.tsx (1)

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

Use the user returned by renderWithProviders.

renderWithProviders already calls userEvent.setup() and returns the instance. Each test creates a second instance. Two sessions can hold separate pointer and keyboard state. Reuse the returned user in all five tests.

- const user = userEvent.setup()- renderWithProviders(<XeroQuoteCard jobId="job-1" />)+ const { user } = renderWithProviders(<XeroQuoteCard jobId="job-1" />)
🤖 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/XeroQuoteCard.test.tsx` around lines 72 -
73, Update all five tests in XeroQuoteCard.test.tsx to destructure and reuse the
user instance returned by renderWithProviders, removing each redundant
userEvent.setup() call while preserving the existing test interactions.
frontend/src/features/job/costing/CostLineGrid.tsx (1)

192-199: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Move the create call out of the setDrafts updater.

commitDraftField runs persistDraftIfReady inside a state updater. That updater executes during render. It starts a network mutation and calls syncPersisting (another setState) from render. React requires updaters to be pure. The persistingRef guard hides the StrictMode double invocation today, but any future replay of the updater still re-enters impure code.

Keep the latest drafts in a ref and run the persistence after the state update.

♻️ Proposed refactor: read drafts from a ref instead of a state updater
 const [drafts, setDrafts] = useState<DraftRow[]>([freshPhantom()])
+ // persistDraftIfReady must see the same render's updateDraft result, but a+ // network call inside a state updater is not a pure update; the ref carries+ // that latest value out of the updater instead.+ const draftsRef = useRef<DraftRow[]>(drafts)+ draftsRef.current = drafts
@@
updateDraft: (localId, patch) => {
setDrafts((current) => {
...
- return next+ draftsRef.current = next+ return next
})
},
commitDraftField: (localId) => {
- setDrafts((current) => {- persistDraftIfReady(current, localId)- return current- })+ persistDraftIfReady(draftsRef.current, localId)
},
🤖 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 192 - 199,
Refactor commitDraftField so persistDraftIfReady is no longer called inside the
setDrafts updater; keep the latest drafts synchronized in a ref and invoke
persistence after the state update using that ref. Ensure the setDrafts updater
remains pure while preserving the existing localId persistence behavior and
syncPersisting flow.
apps/xero/tests/test_provider_documents.py (2)

431-438: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the 404 status code, not only the error text.

XeroQuoteManager.delete_document (apps/xero/documents/quote.py, line 311) branches on result.status_code == 404 to clean up the local row when the quote is already gone from Xero. The error string is not part of that contract; the status code is. This test passes today even if delete_quote returns status 500 for an absent quote, which would brick the recovery path.

♻️ Proposed assertion
 result = provider.delete_quote(str(uuid.uuid4()))
assert not result.success
assert result.error is not None and "no quote" in result.error
+ # The manager keys its local-cleanup branch on this code, not the text.+ assert result.status_code == 404
🤖 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_provider_documents.py` around lines 431 - 438, Update
test_missing_quote_is_an_error_result to assert that the failed delete_quote
result has status_code 404, preserving the existing unsuccessful-result check
while treating the status code—not the error text—as the contract for a missing
quote.

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

Tighten the match, or drop "persists" from the test name.

match="quote" matches every ValueError this method raises, including the "returned N quotes" guard. The name promises a persistence assertion that the body does not make.

♻️ Proposed change
- with pytest.raises(ValueError, match="quote"):+ with pytest.raises(ValueError, match="for a request naming"):
provider.download_quote_pdf(str(uuid.uuid4()))
🤖 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_provider_documents.py` around lines 462 - 467, Align
test_quote_id_mismatch_raises_and_persists with its actual coverage: either
assert the specific quote-ID mismatch error message rather than the broad
“quote” match, and add the intended persistence assertion, or rename the test to
remove “persists” if persistence is not being verified.
apps/xero/tests/test_quote_manager.py (1)

170-197: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the unvoidable-orphan branch.

This test covers the race loser whose compensating void succeeds. The branch at apps/xero/documents/quote.py lines 216-224 is untested: when delete_quote also fails, the manager raises a ValueError carrying the orphan quote id. That id is the only record an operator has, because the AppError row cannot carry it. A regression that swallowed this raise would leave a real Xero quote orphaned with no trace.

♻️ Proposed companion test
+ def test_race_loser_raises_when_the_orphan_cannot_be_voided(+ self, company: Company, job: Job, office_staff: Staff+ ) -> None:+ """An unvoidable orphan must reach an operator with its Xero id."""+ provider = Mock()+ provider.get_account_code.return_value = "200"+ orphan = _success_result()++ def concurrent_winner_lands_first(payload: object) -> DocumentResult: # noqa: ARG001+ _existing_quote(job, company)+ return orphan++ provider.create_quote.side_effect = concurrent_winner_lands_first+ provider.delete_quote.return_value = DocumentResult(+ success=False, error="Quote is ACCEPTED", status_code=400+ )+ manager = _manager(company, job, office_staff, provider)++ with pytest.raises(ValueError, match=str(orphan.external_id)):+ manager.create_document(breakdown=False)++ assert Quote.objects.count() == 1 # only the winner's row
🤖 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 170 - 197, Add a
companion test for the race-loser path in create_document where
provider.delete_quote fails after Quote.objects.create is rejected; assert the
manager raises ValueError containing the orphan Xero quote id, while preserving
the existing assertions for the successful compensation case.
stubs/xero_python/accounting/__init__.pyi (1)

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

Add the Quote fields the provider sets.

apps/xero/provider.py (lines 271-272) passes line_amount_types and currency_code into Quote(...). The stub does not declare either attribute. The call type-checks today only because __init__ accepts **kwargs: Any. Any later attribute read of those fields would fail under strict mypy.

♻️ Proposed stub additions
 class Quote:
quote_id: str | None
quote_number: str | None
contact: Contact | None
date: Any
expiry_date: Any
status: str | None
line_items: list[LineItem] | None
+ line_amount_types: str | None+ currency_code: str | None
branding_theme_id: str | None
terms: str | None
reference: str | None
updated_date_utc: Any
🤖 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 `@stubs/xero_python/accounting/__init__.pyi` around lines 80 - 93, Update the
Quote stub by declaring the line_amount_types and currency_code attributes
alongside the existing quote fields, using types consistent with the values
passed by apps/xero/provider.py. Keep the existing constructor and serialization
declarations unchanged.
🤖 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 `@apps/accounting/management/commands/inspect_xero_quote_pdf.py`:
- Line 28: Replace the narration docstring near the command’s
validation/inspection flow in
apps/accounting/management/commands/inspect_xero_quote_pdf.py:28-28 with either
no comment or rationale explaining the rejected behavior and the subprocess
contract requiring the selected behavior. Update the comment in
frontend/tests/e2e/job/job-xero-quote.spec.ts:139-140 to explain that treating
the phantom row as a persisted cost line would create invalid repair work
because it has no persisted cost-line data.
In `@apps/accounting/services/quote_pdf.py`:
- Around line 32-69: Wrap the PdfReader usage in the inspection flow with a
context manager so its streams close before any cleanup. Ensure the reader is
closed on both successful validation and failure paths, while preserving the
diagnostic PDF when the expected text is absent and allowing unlink after the
reader scope ends.
In `@frontend/src/features/job/costing/calc.ts`:
- Around line 32-44: Update stockPickPatch to explicitly set labour_subtype to
null when constructing the material CostLineUpdateRequest, while preserving the
existing stock and cost field mappings.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 410-419: Update the onPickStock handler to preserve null for
missing patch.unit_cost and patch.unit_rev instead of converting absent values
to empty strings. Ensure the resulting DraftLine remains incomplete when either
price is unavailable, and omit null price fields when constructing the create
PATCH request.
In `@frontend/src/features/job/costing/useAutosaveField.ts`:
- Around line 52-64: Update dispatch to compare against a live serverValue ref
rather than the render-captured serverValue when the debounce callback runs.
Keep the existing knownApplied and untouched deduplication behavior, and ensure
the ref is synchronized with the latest server value before dispatch evaluates
parsed.
In `@frontend/src/features/job/costing/XeroQuoteCard.tsx`:
- Around line 170-190: Update the button label logic in the XeroQuoteCard
component to distinguish ping.isPending from a genuinely disconnected
xeroConnected state. While the Xero connection check is pending, show a
checking/loading label and prevent the logged-out “Login to Xero first” message;
retain the existing labels once the check resolves.
- Around line 81-95: The executeDelete handler currently deletes the quote
without confirmation. Update executeDelete to show a visible confirmation
dialog, such as window.confirm with a clear deletion message, and only call
deleteQuote.mutate when the user confirms; preserve the existing pending guard
and success/error callbacks.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Line 173: Update the AccountingApi quote PDF call to use the method name
provided by xero-python==15.0.0 instead of get_quote_as_pdf, avoiding the
AttributeError. If a local get_quote_as_pdf implementation is retained, pass
headers={"Accept": "application/pdf"} and write its response to a temporary file
before applying the return-type check, rather than treating the raw response as
a path.
---
Nitpick comments:
In `@apps/xero/tests/test_provider_documents.py`:
- Around line 431-438: Update test_missing_quote_is_an_error_result to assert
that the failed delete_quote result has status_code 404, preserving the existing
unsuccessful-result check while treating the status code—not the error text—as
the contract for a missing quote.
- Around line 462-467: Align test_quote_id_mismatch_raises_and_persists with its
actual coverage: either assert the specific quote-ID mismatch error message
rather than the broad “quote” match, and add the intended persistence assertion,
or rename the test to remove “persists” if persistence is not being verified.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 170-197: Add a companion test for the race-loser path in
create_document where provider.delete_quote fails after Quote.objects.create is
rejected; assert the manager raises ValueError containing the orphan Xero quote
id, while preserving the existing assertions for the successful compensation
case.
In `@frontend/src/features/job/costing/calc.test.ts`:
- Around line 123-140: Add two `itemLabel` tests covering unresolved references:
verify a time line with a labour subtype absent from `rates` uses the
labour-rate fallback, and verify a bound line whose stock ID is absent from
`stockById` returns the distinct “Stock item” fallback rather than “Select
Item”.
In `@frontend/src/features/job/costing/CostLineGrid.test.tsx`:
- Around line 67-108: Annotate the labourRates fixture as JobLabourRateOut[] and
annotate stockPage with the generated page type used for StockItem results,
matching the equivalent fixtures in calc.test.ts. Preserve the existing fixture
values while ensuring wire-type changes are caught during compilation.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 192-199: Refactor commitDraftField so persistDraftIfReady is no
longer called inside the setDrafts updater; keep the latest drafts synchronized
in a ref and invoke persistence after the state update using that ref. Ensure
the setDrafts updater remains pure while preserving the existing localId
persistence behavior and syncPersisting flow.
In `@frontend/src/features/job/costing/ItemSelect.tsx`:
- Around line 88-92: Debounce the search value used by the stock query in
ItemSelect rather than passing the raw search state into
purchasingStockSearchRetrieveOptions on every keystroke. Keep CommandInput
responsive with the immediate value, and use a small deferred or timer-based
value so requests occur after typing pauses while preserving the existing
short-query behavior.
In `@frontend/src/features/job/costing/useCostLines.ts`:
- Around line 121-140: Update deleteLine’s onError rollback comment to document
why restoring the entire snapshot is intentional: unlike patchLine, a deleted
row cannot be reconstructed field-by-field, so a wholesale restore is required
despite potentially reverting interleaved writes until invalidate refetches.
In `@frontend/src/features/job/costing/XeroQuoteCard.test.tsx`:
- Around line 72-73: Update all five tests in XeroQuoteCard.test.tsx to
destructure and reuse the user instance returned by renderWithProviders,
removing each redundant userEvent.setup() call while preserving the existing
test interactions.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Around line 80-93: Update the Quote stub by declaring the line_amount_types
and currency_code attributes alongside the existing quote fields, using types
consistent with the values passed by apps/xero/provider.py. Keep the existing
constructor and serialization declarations unchanged.
🪄 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: 6120c3fb-75ad-489f-9410-9c2daf5d7eaa

📥 Commits

Reviewing files that changed from the base of the PR and between cd24a0f and 9ae11da.

⛔ Files ignored due to path filters (6)
  • frontend/package-lock.json is excluded by !**/package-lock.json
  • frontend/src/api/generated/@tanstack/react-query.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/index.ts is excluded by !**/generated/**
  • frontend/src/api/generated/sdk.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/types.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/zod.gen.ts is excluded by !**/generated/**
📒 Files selected for processing (45)
  • apps/accounting/management/__init__.py
  • apps/accounting/management/commands/__init__.py
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/provider.py
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/accounting/types.py
  • apps/job/api.py
  • apps/job/schemas.py
  • apps/job/services/job_service.py
  • apps/job/tests/test_job_api.py
  • apps/xero/api.py
  • apps/xero/documents/base.py
  • apps/xero/documents/invoice.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/readonly_provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/accepted-api-differences.yml
  • docs/rewrite-status.md
  • frontend/package.json
  • frontend/schema.v2.yml
  • frontend/src/api/index.ts
  • frontend/src/components/ui/command.tsx
  • frontend/src/components/ui/popover.tsx
  • frontend/src/features/job/JobDetailPage.tsx
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/ItemSelect.tsx
  • frontend/src/features/job/costing/JobQuoteTab.test.tsx
  • frontend/src/features/job/costing/JobQuoteTab.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/types.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • frontend/src/lib/format.ts
  • frontend/src/test/setup.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
  • scripts/v1-frontend-operations.yml
  • stubs/xero_python/accounting/__init__.pyi

Comment threadapps/accounting/services/quote_pdf.py Outdated
Comment threadfrontend/src/features/job/costing/calc.ts
Comment threadfrontend/src/features/job/costing/CostLineGrid.tsx
Comment threadfrontend/src/features/job/costing/useAutosaveField.ts
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadstubs/xero_python/accounting/__init__.pyi
corrinand others added 2 commits August 9, 2026 18:13
Movements all belong to this slice: two call-time manager imports and test
fixtures (PLC0415), the provider's deliberate exception-to-result
conversions (BLE001, returns-instead), the TableMeta module augmentation
(eslint-disable), and the new manager/provider try shapes. Passthrough
stays pinned at zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est states
Close the PdfReader before any unlink (a held stream fails deletion on
Windows); a stock pick clears labour_subtype so a converted time line
carries no stale subtype; the draft item-pick preserves absence as null
instead of '' (an empty string would satisfy the persist-ready check);
the debounce dispatch compares against the live server value via a ref;
quote deletion asks for confirmation like a cost-line delete; a pending
Xero ping reads 'Checking Xero…' instead of the logged-out label; two
comments now state their rejected alternatives. Declined with evidence in
the thread: the claim that xero-python 15.0.0 lacks get_quote_as_pdf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@corrin
corrin merged commit cd5943d into mainAug 9, 2026
3 checks passed
corrin added a commit that referenced this pull request Aug 9, 2026
* Quote hardening B1-B5: explicit validation, full compensation, unbricked 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>
* One document-endpoint adapter: auth refusal, failure map, success build
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>
* Grid hardening F1-F6 + assertive spec: drafts persist, retries retry
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>
* Record the deferred structure work; regenerate metrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Persist the collision at its catch site; refresh derived rows
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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40) - #48

Merged
corrin merged 12 commits into
mainfrom
xero/quote-path
Aug 9, 2026
Merged

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40)#48
corrin merged 12 commits into
mainfrom
xero/quote-path

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Slice 2c — the last of the three slice-2 Xero PRs. Done means the spec is green: job-xero-quote.spec.ts passed writes-live against the demo tenant (real DRAFT quote created, native Xero PDF downloaded server-side and the configured terms text found in it), and the full run_e2e.sh gate passed all 39 tests across the 14 ported spec files on the final code.

Backend

  • apps/xero/documents/quote.py — expected refusals (already quoted, T&M pricing, empty quote cost set, blank breakdown descriptions, missing theme/terms config) return typed 400 values with the provider never called; unexpected failures persist once and re-raise. Total-only mode sends the cost-set summary revenue as one line; breakdown mode one sanitised line per cost line. The concurrent-push loser voids its orphan Xero quote (savepoint-guarded) before refusing; a quote deleted Xero-side comes back as a typed 404 from the provider pre-read and the manager cleans up the local row instead of bricking the job.
  • Provider: create_quote, delete_quote (soft delete via pre-read + DELETED upsert), download_quote_pdf. Readonly fabricates QU-E2E-* results and refuses the PDF download — a fabricated file would satisfy the text assertion against nothing.
  • Endpoints: POST /api/xero/create_quote/{job_id} (body {breakdown}), DELETE /api/xero/delete_quote/{job_id} (no id parameter — one quote per job) at v1-parity URL fragments; GET /api/job/jobs/{id}/quote/ serving {quote: QuoteOut | null} — enveloped because the generated axios client coerces a bare JSON null body to {} (ledgered, with the conditional-GET drop).
  • PDF inspection: apps/accounting/services/quote_pdf.py + the inspect_xero_quote_pdf command emitting the single JSON line the spec parses; the file survives every diagnostic path.

Frontend

  • features/job/costing/CostLineGrid.tsx — the one cost-line grid (estimate/actual arrive later as prop configs), on TanStack Table v8 with module-constant column defs (per-render defs would remount and blur every input). Full day-one selector contract: .smart-costlines-table, exactly one trailing phantom tbody row, SmartCostLinesTable-*/DataTable-row-*/data-grid-* from the visual index, ItemSelect-option-*, trigger named Select Item only when unbound.
  • useAutosaveField derives its display value (local buffer only while editing) — an effect-synced copy provably kept rejected input on screen when an optimistic write and its rollback coalesced into one render. 600ms debounce, blur flushes and cancels, deliberately no If-Match on cost-line CRUD (v1 parity).
  • JobQuoteTab + XeroQuoteCard: server-owned summary (ADR 0046), ping-gated create, "Export Quote to Xero" dialog (Send Total Only / Send Breakdown), open-in-Xero with noopener, delete. Lazy-loaded.
  • Deferred with attributes already in place: keyboard-nav behaviour, duplicate-line, unit-rev override bookkeeping, data-freshness polling.

Spec port

Recorded deviations from v1's spec: own job instead of the read-only sharedEditJobUrl fixture; no in-spec ping (global setup fails the run closed); waitForAutosave instead of 800ms sleeps; PDF inspector spawned via uv run python. The repair machinery ports near-verbatim — it is what exercises the grid every run (the fresh job's material line lacks a stock binding).

Review

Adversarial 2-subagent review pre-PR; all four backend should-fixes and the frontend blocker + should-fixes applied with regression tests (draft-POST failure recovery, same-value retry after rollback). Declined with reasons in the commit: the compact-match "tightening" (equivalent in power to the existing check) and the readonly totals type change (2b precedent).

Verification: 1708 unit tests, coverage 88.46%, job-xero-quote green live, full E2E gate green twice (before and after review fixes).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Quote workspace to job details for managing cost lines, labour, stock, pricing, and autosaving changes.
    • Added Xero quote creation with total-only or detailed line breakdown options.
    • Added options to open quotes in Xero and delete existing quotes.
    • Added job quote retrieval through the API, including quote numbers and summary details.
    • Added quote PDF inspection for validating terms, branding, page count, and extracted text.
  • Bug Fixes
    • Improved handling of missing invoice totals and clearer document deletion errors.
  • Documentation
    • Updated API and rewrite progress documentation for quote functionality.

corrinand others added 10 commits August 9, 2026 15:30
…otocol
QuotePayload carries required terms (Xero applies no default to API-created
quotes) and an expiry date; QuotePdfDocument hands the caller a temp file it
owns. Live provider mirrors the invoice shapes: constructed edit-URL, DELETED
upsert after a contact/date pre-read, PDF download that raises rather than
returning a partial result. Readonly fabricates QU-E2E-* results and refuses
the PDF download outright — a fabricated file would satisfy the text
assertion against nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… mirror
Expected refusals (already quoted, T&M pricing, empty quote cost set, blank
breakdown descriptions, missing theme/terms config) return typed 400 values
with the provider never called; unexpected failures persist once and re-raise
per the base contract. Total-only mode sends the cost-set summary revenue as
a single line; breakdown mode one sanitised line per cost line. The job's
updated_at bumps in-request so the tab refetch sees quoted=true. The
duplicated _create_job_event hoisted from invoice.py into the base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /xero/create_quote/{job_id} (body: breakdown) and DELETE
/xero/delete_quote/{job_id} mirror the invoice handlers; delete takes no id
parameter because a job holds at most one quote. GET /job/jobs/{id}/quote/
serves the Xero quote header or null — a plain GET, not v1's conditional-GET:
nothing external holds the URL and 304-with-empty-body reads as no-quote to
an axios consumer (ledger entry to follow with the slice docs).
XeroDocumentSuccessResponse gains nullable quote_id beside invoice_id; the
two xero operation renames are recorded in the work-list ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the v1 inspection: pypdf text extraction over non-blank pages,
space-normalised AND compact matching (Xero's text layer wraps mid-phrase
and sometimes drops word spaces), blank render raises rather than reporting
the marker absent, and the temp file survives every failure path for
diagnosis. The command emits exactly one sorted-keys JSON line — the
subprocess contract the E2E quote spec parses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-exports the cost-line/labour-rates/stock-search factories and the three
new quote operations through the api boundary. Adds @tanstack/react-table
and the shadcn popover + command primitives (installed, not hand-written).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TanStack Table v8 with module-constant column defs — rebuilding them per
render changes every cell component's identity and remounts (blurring) all
inputs, so cells reach live state through table meta instead. Day-one
contract: .smart-costlines-table, exactly one trailing phantom tbody row,
SmartCostLinesTable-*/DataTable-row-*/data-grid-* attributes derived from
the visual index, ItemSelect with labour-first options.
useAutosaveField DERIVES its display value (local buffer only while
editing): an optimistic write and its failure rollback can land between two
renders, so an effect keyed on the server value never fires — the unit net
caught a synced copy keeping rejected input on screen. 600ms debounce,
blur flushes and cancels, no If-Match on cost-line CRUD (deliberate, v1
parity). All failures toast; the tests fail on any console.error, matching
the E2E guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quote workspace: editable quote cost set, server-owned summary card
(ADR 0046), and the Xero card (ping-gated create, Export Quote to Xero
dialog with Send Total Only / Send Breakdown, open-in-Xero with noopener,
delete). Lazy-loaded from the job detail page.
The retrieve contract changed to {quote: QuoteOut | null}: the unit net
caught the generated axios client coercing a bare JSON null body to {},
which read as an existing quote and crashed the card — an envelope is the
only shape that round-trips absence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…check
Deliberate deviations from the v1 spec, each with its reason in place: a
dedicated job instead of the read-only sharedEditJobUrl fixture; no in-spec
Xero ping (global setup fails the run closed); waitForAutosave instead of
800ms sleeps around the 600ms debounce; and the PDF inspector spawns via
uv run python since nothing guarantees an activated interpreter under npm.
The repair machinery (Select Item pick, desc/unit-rev fills) ports
near-verbatim — it is the part of the spec that exercises the grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st states
Backend: the concurrent-push loser now voids its orphan Xero quote (under a
savepoint, so the compensation can persist its AppError) and refuses with
the readable 400; a quote deleted Xero-side returns a typed 404 from the
provider pre-read and the manager treats it as cleanup-allowed instead of
bricking the job; the local mirror stores the payload's date (midnight
span); present-but-null totals get the crafted message in both quote and
invoice managers; the not-found PDF keeps its file for diagnosis;
delete_invoice passes error_type through like its quote sibling.
Frontend: a failed draft POST clears the persisting guard so the row stays
retryable (was permanently bricked); draft inputs disable while the create
is in flight; the send-dedupe only skips a KNOWN-applied value so a rejected
edit can be retried; the quote card renders pending as pending, not as
create-state; error states stop masquerading as data (picker, ping,
summary); background-refetch errors keep the working grid; null profit
margin renders as a dash, not 0.0%; PATCH rollback reverts only the patched
fields against the current cache, with in-flight refetches cancelled first.
Spec: isEnabled guard before repairing a rev input; response schema
validates quote_id/online_url. Declined: the compact-match tightening (the
proposed regex is equivalent in power — its own counterexample defeats
both) and the readonly totals type change (2b precedent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:23 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f0ec6174-067a-4295-8ee5-20d5269c5cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 9ae11da and e19f4e6.

📒 Files selected for processing (11)
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/services/quote_pdf.py
  • docs/code-quality.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
📝 Walkthrough

Walkthrough

Added end-to-end Xero quote support across accounting providers, quote APIs, PDF inspection, job retrieval, frontend costing, and quote management UI. Added tests, OpenAPI updates, compatibility mappings, and E2E validation.

Changes

Xero quote backend

Layer / File(s)Summary
Provider contracts and integrations
apps/accounting/types.py, apps/accounting/provider.py, apps/xero/provider.py, apps/xero/readonly_provider.py, stubs/xero_python/..., apps/xero/tests/test_provider_documents.py
Added quote payload and PDF types, provider operations, Xero quote create/delete/PDF support, readonly behavior, and SDK stubs.
Quote manager and API lifecycle
apps/xero/documents/quote.py, apps/xero/api.py, apps/xero/documents/base.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py
Added quote validation, payload construction, persistence, compensation, deletion, audit events, authenticated endpoints, and lifecycle tests.
Quote PDF inspection tooling
apps/accounting/services/quote_pdf.py, apps/accounting/management/commands/..., apps/accounting/tests/test_quote_pdf.py
Added PDF text inspection, branding and page metadata, diagnostic-file handling, and JSON command output.
Job quote retrieval contract
apps/job/api.py, apps/job/schemas.py, apps/job/services/job_service.py, frontend/schema.v2.yml, frontend/src/api/index.ts
Added nullable enveloped quote retrieval with quote-number serialization and matching API schemas.
Cost-line editing workspace
frontend/src/features/job/costing/*, frontend/src/components/ui/*, frontend/package.json
Added cost-line types, calculations, autosave, optimistic CRUD, item selection, editable grid behavior, and supporting UI primitives.
Quote tab and Xero card
frontend/src/features/job/JobDetailPage.tsx, frontend/src/features/job/costing/JobQuoteTab.tsx, frontend/src/features/job/costing/XeroQuoteCard.tsx
Added the lazy-loaded quote tab, quote summary, cost grid, Xero quote creation modes, deletion, deep links, and mutation states.
End-to-end validation and status support
frontend/tests/e2e/job/job-xero-quote.spec.ts, docs/rewrite-status.md, docs/accepted-api-differences.yml, scripts/v1-frontend-operations.yml
Added E2E quote creation and PDF checks, diagnostic repair logic, status updates, API difference documentation, and operation mappings.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant User
participant JobQuoteTab
participant XeroQuoteCard
participant QuoteAPI
participant Xero
User->>JobQuoteTab: Open the quote tab
JobQuoteTab->>XeroQuoteCard: Load quote state
User->>XeroQuoteCard: Select quote mode and submit
XeroQuoteCard->>QuoteAPI: Create quote request
QuoteAPI->>Xero: Create quote
Xero-->>QuoteAPI: Return quote identifiers and totals
QuoteAPI-->>XeroQuoteCard: Return quote response
XeroQuoteCard-->>User: Display quote link and status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 32.57% 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 Xero quote path, cost-line grid, and related end-to-end test result.
Description check✅ PassedThe description thoroughly covers implementation details, scope, deviations, review outcomes, and verification results, despite omitting 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-path

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: 8

🧹 Nitpick comments (10)
frontend/src/features/job/costing/useCostLines.ts (1)

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

Document why deleteLine restores the whole snapshot.

patchLine carries a comment that rejects the wholesale snapshot restore and explains the interleaved-write constraint. deleteLine then uses that exact rejected strategy at Line 134 with no comment. A failed delete restores the full pre-delete cost set, so it also reverts any successful interleaved patch on another line until the onSettled refetch lands. Record the constraint that makes the wholesale restore correct here, for example that a removed row cannot be reconstructed field-by-field.

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 `@frontend/src/features/job/costing/useCostLines.ts` around lines 121 - 140,
Update deleteLine’s onError rollback comment to document why restoring the
entire snapshot is intentional: unlike patchLine, a deleted row cannot be
reconstructed field-by-field, so a wholesale restore is required despite
potentially reverting interleaved writes until invalidate refetches.

Source: Coding guidelines

frontend/src/features/job/costing/calc.test.ts (1)

123-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the two unresolved-reference fallbacks in itemLabel.

The suite does not exercise the fallback at calc.ts Line 79 (labour rate not in the list) or Line 86 (stock id not in the loaded page). The comment on calc.ts Line 85 states that the 'Stock item' fallback must not read as 'Select Item', because the E2E repair loop counts buttons by that exact name. A regression that returns 'Select Item' for an unresolved stock id would pass this suite and fail only in Playwright. Add the two cases.

🧪 Proposed additional cases
 it('names the labour subtype for a time line', () => {
expect(itemLabel(line({ kind: 'time', labour_subtype: 'workshop' }), stockById, rates)).toBe(
'Workshop',
)
})
++ it('falls back to the raw subtype when the rate is not loaded', () => {+ expect(itemLabel(line({ kind: 'time', labour_subtype: 'nightshift' }), stockById, rates)).toBe(+ 'nightshift',+ )+ })++ it('never reads as "Select Item" when bound to unloaded stock', () => {+ expect(itemLabel(line({ ext_refs: { stock_id: 'stock-unloaded' } }), stockById, rates)).toBe(+ 'Stock item',+ )+ })
})
🤖 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/calc.test.ts` around lines 123 - 140, Add
two `itemLabel` tests covering unresolved references: verify a time line with a
labour subtype absent from `rates` uses the labour-rate fallback, and verify a
bound line whose stock ID is absent from `stockById` returns the distinct “Stock
item” fallback rather than “Select Item”.
frontend/src/features/job/costing/CostLineGrid.test.tsx (1)

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

Annotate the labourRates and stockPage fixtures with the generated types.

materialLine and costSet are annotated, so a wire-type change breaks this file at compile time. labourRates and stockPage are bare literals, so the same change passes type-checking and fails only at runtime. calc.test.ts annotates the equivalent fixtures as JobLabourRateOut and StockItem. Add const labourRates: JobLabourRateOut[] and the generated page type for stockPage.

🤖 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.test.tsx` around lines 67 -
108, Annotate the labourRates fixture as JobLabourRateOut[] and annotate
stockPage with the generated page type used for StockItem results, matching the
equivalent fixtures in calc.test.ts. Preserve the existing fixture values while
ensuring wire-type changes are caught during compilation.
frontend/src/features/job/costing/ItemSelect.tsx (1)

88-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider debouncing the stock search input.

CommandInput calls onValueChange={setSearch} directly, and search is part of the purchasingStockSearchRetrieveOptions query key. Every keystroke while the popover is open fires a new server-side stock search request. The component's own comment notes that queries under 3 characters list everything, so short inputs during typing can each trigger a full unfiltered fetch.

Debounce the value passed to the query (for example with a small useDeferredValue or timer-based hook) so the request fires once typing pauses.

🤖 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/ItemSelect.tsx` around lines 88 - 92,
Debounce the search value used by the stock query in ItemSelect rather than
passing the raw search state into purchasingStockSearchRetrieveOptions on every
keystroke. Keep CommandInput responsive with the immediate value, and use a
small deferred or timer-based value so requests occur after typing pauses while
preserving the existing short-query behavior.
frontend/src/features/job/costing/XeroQuoteCard.test.tsx (1)

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

Use the user returned by renderWithProviders.

renderWithProviders already calls userEvent.setup() and returns the instance. Each test creates a second instance. Two sessions can hold separate pointer and keyboard state. Reuse the returned user in all five tests.

- const user = userEvent.setup()- renderWithProviders(<XeroQuoteCard jobId="job-1" />)+ const { user } = renderWithProviders(<XeroQuoteCard jobId="job-1" />)
🤖 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/XeroQuoteCard.test.tsx` around lines 72 -
73, Update all five tests in XeroQuoteCard.test.tsx to destructure and reuse the
user instance returned by renderWithProviders, removing each redundant
userEvent.setup() call while preserving the existing test interactions.
frontend/src/features/job/costing/CostLineGrid.tsx (1)

192-199: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Move the create call out of the setDrafts updater.

commitDraftField runs persistDraftIfReady inside a state updater. That updater executes during render. It starts a network mutation and calls syncPersisting (another setState) from render. React requires updaters to be pure. The persistingRef guard hides the StrictMode double invocation today, but any future replay of the updater still re-enters impure code.

Keep the latest drafts in a ref and run the persistence after the state update.

♻️ Proposed refactor: read drafts from a ref instead of a state updater
 const [drafts, setDrafts] = useState<DraftRow[]>([freshPhantom()])
+ // persistDraftIfReady must see the same render's updateDraft result, but a+ // network call inside a state updater is not a pure update; the ref carries+ // that latest value out of the updater instead.+ const draftsRef = useRef<DraftRow[]>(drafts)+ draftsRef.current = drafts
@@
updateDraft: (localId, patch) => {
setDrafts((current) => {
...
- return next+ draftsRef.current = next+ return next
})
},
commitDraftField: (localId) => {
- setDrafts((current) => {- persistDraftIfReady(current, localId)- return current- })+ persistDraftIfReady(draftsRef.current, localId)
},
🤖 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 192 - 199,
Refactor commitDraftField so persistDraftIfReady is no longer called inside the
setDrafts updater; keep the latest drafts synchronized in a ref and invoke
persistence after the state update using that ref. Ensure the setDrafts updater
remains pure while preserving the existing localId persistence behavior and
syncPersisting flow.
apps/xero/tests/test_provider_documents.py (2)

431-438: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the 404 status code, not only the error text.

XeroQuoteManager.delete_document (apps/xero/documents/quote.py, line 311) branches on result.status_code == 404 to clean up the local row when the quote is already gone from Xero. The error string is not part of that contract; the status code is. This test passes today even if delete_quote returns status 500 for an absent quote, which would brick the recovery path.

♻️ Proposed assertion
 result = provider.delete_quote(str(uuid.uuid4()))
assert not result.success
assert result.error is not None and "no quote" in result.error
+ # The manager keys its local-cleanup branch on this code, not the text.+ assert result.status_code == 404
🤖 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_provider_documents.py` around lines 431 - 438, Update
test_missing_quote_is_an_error_result to assert that the failed delete_quote
result has status_code 404, preserving the existing unsuccessful-result check
while treating the status code—not the error text—as the contract for a missing
quote.

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

Tighten the match, or drop "persists" from the test name.

match="quote" matches every ValueError this method raises, including the "returned N quotes" guard. The name promises a persistence assertion that the body does not make.

♻️ Proposed change
- with pytest.raises(ValueError, match="quote"):+ with pytest.raises(ValueError, match="for a request naming"):
provider.download_quote_pdf(str(uuid.uuid4()))
🤖 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_provider_documents.py` around lines 462 - 467, Align
test_quote_id_mismatch_raises_and_persists with its actual coverage: either
assert the specific quote-ID mismatch error message rather than the broad
“quote” match, and add the intended persistence assertion, or rename the test to
remove “persists” if persistence is not being verified.
apps/xero/tests/test_quote_manager.py (1)

170-197: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the unvoidable-orphan branch.

This test covers the race loser whose compensating void succeeds. The branch at apps/xero/documents/quote.py lines 216-224 is untested: when delete_quote also fails, the manager raises a ValueError carrying the orphan quote id. That id is the only record an operator has, because the AppError row cannot carry it. A regression that swallowed this raise would leave a real Xero quote orphaned with no trace.

♻️ Proposed companion test
+ def test_race_loser_raises_when_the_orphan_cannot_be_voided(+ self, company: Company, job: Job, office_staff: Staff+ ) -> None:+ """An unvoidable orphan must reach an operator with its Xero id."""+ provider = Mock()+ provider.get_account_code.return_value = "200"+ orphan = _success_result()++ def concurrent_winner_lands_first(payload: object) -> DocumentResult: # noqa: ARG001+ _existing_quote(job, company)+ return orphan++ provider.create_quote.side_effect = concurrent_winner_lands_first+ provider.delete_quote.return_value = DocumentResult(+ success=False, error="Quote is ACCEPTED", status_code=400+ )+ manager = _manager(company, job, office_staff, provider)++ with pytest.raises(ValueError, match=str(orphan.external_id)):+ manager.create_document(breakdown=False)++ assert Quote.objects.count() == 1 # only the winner's row
🤖 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 170 - 197, Add a
companion test for the race-loser path in create_document where
provider.delete_quote fails after Quote.objects.create is rejected; assert the
manager raises ValueError containing the orphan Xero quote id, while preserving
the existing assertions for the successful compensation case.
stubs/xero_python/accounting/__init__.pyi (1)

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

Add the Quote fields the provider sets.

apps/xero/provider.py (lines 271-272) passes line_amount_types and currency_code into Quote(...). The stub does not declare either attribute. The call type-checks today only because __init__ accepts **kwargs: Any. Any later attribute read of those fields would fail under strict mypy.

♻️ Proposed stub additions
 class Quote:
quote_id: str | None
quote_number: str | None
contact: Contact | None
date: Any
expiry_date: Any
status: str | None
line_items: list[LineItem] | None
+ line_amount_types: str | None+ currency_code: str | None
branding_theme_id: str | None
terms: str | None
reference: str | None
updated_date_utc: Any
🤖 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 `@stubs/xero_python/accounting/__init__.pyi` around lines 80 - 93, Update the
Quote stub by declaring the line_amount_types and currency_code attributes
alongside the existing quote fields, using types consistent with the values
passed by apps/xero/provider.py. Keep the existing constructor and serialization
declarations unchanged.
🤖 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 `@apps/accounting/management/commands/inspect_xero_quote_pdf.py`:
- Line 28: Replace the narration docstring near the command’s
validation/inspection flow in
apps/accounting/management/commands/inspect_xero_quote_pdf.py:28-28 with either
no comment or rationale explaining the rejected behavior and the subprocess
contract requiring the selected behavior. Update the comment in
frontend/tests/e2e/job/job-xero-quote.spec.ts:139-140 to explain that treating
the phantom row as a persisted cost line would create invalid repair work
because it has no persisted cost-line data.
In `@apps/accounting/services/quote_pdf.py`:
- Around line 32-69: Wrap the PdfReader usage in the inspection flow with a
context manager so its streams close before any cleanup. Ensure the reader is
closed on both successful validation and failure paths, while preserving the
diagnostic PDF when the expected text is absent and allowing unlink after the
reader scope ends.
In `@frontend/src/features/job/costing/calc.ts`:
- Around line 32-44: Update stockPickPatch to explicitly set labour_subtype to
null when constructing the material CostLineUpdateRequest, while preserving the
existing stock and cost field mappings.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 410-419: Update the onPickStock handler to preserve null for
missing patch.unit_cost and patch.unit_rev instead of converting absent values
to empty strings. Ensure the resulting DraftLine remains incomplete when either
price is unavailable, and omit null price fields when constructing the create
PATCH request.
In `@frontend/src/features/job/costing/useAutosaveField.ts`:
- Around line 52-64: Update dispatch to compare against a live serverValue ref
rather than the render-captured serverValue when the debounce callback runs.
Keep the existing knownApplied and untouched deduplication behavior, and ensure
the ref is synchronized with the latest server value before dispatch evaluates
parsed.
In `@frontend/src/features/job/costing/XeroQuoteCard.tsx`:
- Around line 170-190: Update the button label logic in the XeroQuoteCard
component to distinguish ping.isPending from a genuinely disconnected
xeroConnected state. While the Xero connection check is pending, show a
checking/loading label and prevent the logged-out “Login to Xero first” message;
retain the existing labels once the check resolves.
- Around line 81-95: The executeDelete handler currently deletes the quote
without confirmation. Update executeDelete to show a visible confirmation
dialog, such as window.confirm with a clear deletion message, and only call
deleteQuote.mutate when the user confirms; preserve the existing pending guard
and success/error callbacks.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Line 173: Update the AccountingApi quote PDF call to use the method name
provided by xero-python==15.0.0 instead of get_quote_as_pdf, avoiding the
AttributeError. If a local get_quote_as_pdf implementation is retained, pass
headers={"Accept": "application/pdf"} and write its response to a temporary file
before applying the return-type check, rather than treating the raw response as
a path.
---
Nitpick comments:
In `@apps/xero/tests/test_provider_documents.py`:
- Around line 431-438: Update test_missing_quote_is_an_error_result to assert
that the failed delete_quote result has status_code 404, preserving the existing
unsuccessful-result check while treating the status code—not the error text—as
the contract for a missing quote.
- Around line 462-467: Align test_quote_id_mismatch_raises_and_persists with its
actual coverage: either assert the specific quote-ID mismatch error message
rather than the broad “quote” match, and add the intended persistence assertion,
or rename the test to remove “persists” if persistence is not being verified.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 170-197: Add a companion test for the race-loser path in
create_document where provider.delete_quote fails after Quote.objects.create is
rejected; assert the manager raises ValueError containing the orphan Xero quote
id, while preserving the existing assertions for the successful compensation
case.
In `@frontend/src/features/job/costing/calc.test.ts`:
- Around line 123-140: Add two `itemLabel` tests covering unresolved references:
verify a time line with a labour subtype absent from `rates` uses the
labour-rate fallback, and verify a bound line whose stock ID is absent from
`stockById` returns the distinct “Stock item” fallback rather than “Select
Item”.
In `@frontend/src/features/job/costing/CostLineGrid.test.tsx`:
- Around line 67-108: Annotate the labourRates fixture as JobLabourRateOut[] and
annotate stockPage with the generated page type used for StockItem results,
matching the equivalent fixtures in calc.test.ts. Preserve the existing fixture
values while ensuring wire-type changes are caught during compilation.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 192-199: Refactor commitDraftField so persistDraftIfReady is no
longer called inside the setDrafts updater; keep the latest drafts synchronized
in a ref and invoke persistence after the state update using that ref. Ensure
the setDrafts updater remains pure while preserving the existing localId
persistence behavior and syncPersisting flow.
In `@frontend/src/features/job/costing/ItemSelect.tsx`:
- Around line 88-92: Debounce the search value used by the stock query in
ItemSelect rather than passing the raw search state into
purchasingStockSearchRetrieveOptions on every keystroke. Keep CommandInput
responsive with the immediate value, and use a small deferred or timer-based
value so requests occur after typing pauses while preserving the existing
short-query behavior.
In `@frontend/src/features/job/costing/useCostLines.ts`:
- Around line 121-140: Update deleteLine’s onError rollback comment to document
why restoring the entire snapshot is intentional: unlike patchLine, a deleted
row cannot be reconstructed field-by-field, so a wholesale restore is required
despite potentially reverting interleaved writes until invalidate refetches.
In `@frontend/src/features/job/costing/XeroQuoteCard.test.tsx`:
- Around line 72-73: Update all five tests in XeroQuoteCard.test.tsx to
destructure and reuse the user instance returned by renderWithProviders,
removing each redundant userEvent.setup() call while preserving the existing
test interactions.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Around line 80-93: Update the Quote stub by declaring the line_amount_types
and currency_code attributes alongside the existing quote fields, using types
consistent with the values passed by apps/xero/provider.py. Keep the existing
constructor and serialization declarations unchanged.
🪄 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: 6120c3fb-75ad-489f-9410-9c2daf5d7eaa

📥 Commits

Reviewing files that changed from the base of the PR and between cd24a0f and 9ae11da.

⛔ Files ignored due to path filters (6)
  • frontend/package-lock.json is excluded by !**/package-lock.json
  • frontend/src/api/generated/@tanstack/react-query.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/index.ts is excluded by !**/generated/**
  • frontend/src/api/generated/sdk.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/types.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/zod.gen.ts is excluded by !**/generated/**
📒 Files selected for processing (45)
  • apps/accounting/management/__init__.py
  • apps/accounting/management/commands/__init__.py
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/provider.py
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/accounting/types.py
  • apps/job/api.py
  • apps/job/schemas.py
  • apps/job/services/job_service.py
  • apps/job/tests/test_job_api.py
  • apps/xero/api.py
  • apps/xero/documents/base.py
  • apps/xero/documents/invoice.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/readonly_provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/accepted-api-differences.yml
  • docs/rewrite-status.md
  • frontend/package.json
  • frontend/schema.v2.yml
  • frontend/src/api/index.ts
  • frontend/src/components/ui/command.tsx
  • frontend/src/components/ui/popover.tsx
  • frontend/src/features/job/JobDetailPage.tsx
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/ItemSelect.tsx
  • frontend/src/features/job/costing/JobQuoteTab.test.tsx
  • frontend/src/features/job/costing/JobQuoteTab.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/types.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • frontend/src/lib/format.ts
  • frontend/src/test/setup.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
  • scripts/v1-frontend-operations.yml
  • stubs/xero_python/accounting/__init__.pyi

Comment threadapps/accounting/services/quote_pdf.py Outdated
Comment threadfrontend/src/features/job/costing/calc.ts
Comment threadfrontend/src/features/job/costing/CostLineGrid.tsx
Comment threadfrontend/src/features/job/costing/useAutosaveField.ts
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadstubs/xero_python/accounting/__init__.pyi
corrinand others added 2 commits August 9, 2026 18:13
Movements all belong to this slice: two call-time manager imports and test
fixtures (PLC0415), the provider's deliberate exception-to-result
conversions (BLE001, returns-instead), the TableMeta module augmentation
(eslint-disable), and the new manager/provider try shapes. Passthrough
stays pinned at zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est states
Close the PdfReader before any unlink (a held stream fails deletion on
Windows); a stock pick clears labour_subtype so a converted time line
carries no stale subtype; the draft item-pick preserves absence as null
instead of '' (an empty string would satisfy the persist-ready check);
the debounce dispatch compares against the live server value via a ref;
quote deletion asks for confirmation like a cost-line delete; a pending
Xero ping reads 'Checking Xero…' instead of the logged-out label; two
comments now state their rejected alternatives. Declined with evidence in
the thread: the claim that xero-python 15.0.0 lacks get_quote_as_pdf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@corrin
corrin merged commit cd5943d into mainAug 9, 2026
3 checks passed
corrin added a commit that referenced this pull request Aug 9, 2026
* Quote hardening B1-B5: explicit validation, full compensation, unbricked 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>
* One document-endpoint adapter: auth refusal, failure map, success build
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>
* Grid hardening F1-F6 + assertive spec: drafts persist, retries retry
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>
* Record the deferred structure work; regenerate metrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Persist the collision at its catch site; refresh derived rows
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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40) - #48

Merged
corrin merged 12 commits into
mainfrom
xero/quote-path
Aug 9, 2026
Merged

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40)#48
corrin merged 12 commits into
mainfrom
xero/quote-path

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Slice 2c — the last of the three slice-2 Xero PRs. Done means the spec is green: job-xero-quote.spec.ts passed writes-live against the demo tenant (real DRAFT quote created, native Xero PDF downloaded server-side and the configured terms text found in it), and the full run_e2e.sh gate passed all 39 tests across the 14 ported spec files on the final code.

Backend

  • apps/xero/documents/quote.py — expected refusals (already quoted, T&M pricing, empty quote cost set, blank breakdown descriptions, missing theme/terms config) return typed 400 values with the provider never called; unexpected failures persist once and re-raise. Total-only mode sends the cost-set summary revenue as one line; breakdown mode one sanitised line per cost line. The concurrent-push loser voids its orphan Xero quote (savepoint-guarded) before refusing; a quote deleted Xero-side comes back as a typed 404 from the provider pre-read and the manager cleans up the local row instead of bricking the job.
  • Provider: create_quote, delete_quote (soft delete via pre-read + DELETED upsert), download_quote_pdf. Readonly fabricates QU-E2E-* results and refuses the PDF download — a fabricated file would satisfy the text assertion against nothing.
  • Endpoints: POST /api/xero/create_quote/{job_id} (body {breakdown}), DELETE /api/xero/delete_quote/{job_id} (no id parameter — one quote per job) at v1-parity URL fragments; GET /api/job/jobs/{id}/quote/ serving {quote: QuoteOut | null} — enveloped because the generated axios client coerces a bare JSON null body to {} (ledgered, with the conditional-GET drop).
  • PDF inspection: apps/accounting/services/quote_pdf.py + the inspect_xero_quote_pdf command emitting the single JSON line the spec parses; the file survives every diagnostic path.

Frontend

  • features/job/costing/CostLineGrid.tsx — the one cost-line grid (estimate/actual arrive later as prop configs), on TanStack Table v8 with module-constant column defs (per-render defs would remount and blur every input). Full day-one selector contract: .smart-costlines-table, exactly one trailing phantom tbody row, SmartCostLinesTable-*/DataTable-row-*/data-grid-* from the visual index, ItemSelect-option-*, trigger named Select Item only when unbound.
  • useAutosaveField derives its display value (local buffer only while editing) — an effect-synced copy provably kept rejected input on screen when an optimistic write and its rollback coalesced into one render. 600ms debounce, blur flushes and cancels, deliberately no If-Match on cost-line CRUD (v1 parity).
  • JobQuoteTab + XeroQuoteCard: server-owned summary (ADR 0046), ping-gated create, "Export Quote to Xero" dialog (Send Total Only / Send Breakdown), open-in-Xero with noopener, delete. Lazy-loaded.
  • Deferred with attributes already in place: keyboard-nav behaviour, duplicate-line, unit-rev override bookkeeping, data-freshness polling.

Spec port

Recorded deviations from v1's spec: own job instead of the read-only sharedEditJobUrl fixture; no in-spec ping (global setup fails the run closed); waitForAutosave instead of 800ms sleeps; PDF inspector spawned via uv run python. The repair machinery ports near-verbatim — it is what exercises the grid every run (the fresh job's material line lacks a stock binding).

Review

Adversarial 2-subagent review pre-PR; all four backend should-fixes and the frontend blocker + should-fixes applied with regression tests (draft-POST failure recovery, same-value retry after rollback). Declined with reasons in the commit: the compact-match "tightening" (equivalent in power to the existing check) and the readonly totals type change (2b precedent).

Verification: 1708 unit tests, coverage 88.46%, job-xero-quote green live, full E2E gate green twice (before and after review fixes).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Quote workspace to job details for managing cost lines, labour, stock, pricing, and autosaving changes.
    • Added Xero quote creation with total-only or detailed line breakdown options.
    • Added options to open quotes in Xero and delete existing quotes.
    • Added job quote retrieval through the API, including quote numbers and summary details.
    • Added quote PDF inspection for validating terms, branding, page count, and extracted text.
  • Bug Fixes
    • Improved handling of missing invoice totals and clearer document deletion errors.
  • Documentation
    • Updated API and rewrite progress documentation for quote functionality.

corrinand others added 10 commits August 9, 2026 15:30
…otocol
QuotePayload carries required terms (Xero applies no default to API-created
quotes) and an expiry date; QuotePdfDocument hands the caller a temp file it
owns. Live provider mirrors the invoice shapes: constructed edit-URL, DELETED
upsert after a contact/date pre-read, PDF download that raises rather than
returning a partial result. Readonly fabricates QU-E2E-* results and refuses
the PDF download outright — a fabricated file would satisfy the text
assertion against nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… mirror
Expected refusals (already quoted, T&M pricing, empty quote cost set, blank
breakdown descriptions, missing theme/terms config) return typed 400 values
with the provider never called; unexpected failures persist once and re-raise
per the base contract. Total-only mode sends the cost-set summary revenue as
a single line; breakdown mode one sanitised line per cost line. The job's
updated_at bumps in-request so the tab refetch sees quoted=true. The
duplicated _create_job_event hoisted from invoice.py into the base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /xero/create_quote/{job_id} (body: breakdown) and DELETE
/xero/delete_quote/{job_id} mirror the invoice handlers; delete takes no id
parameter because a job holds at most one quote. GET /job/jobs/{id}/quote/
serves the Xero quote header or null — a plain GET, not v1's conditional-GET:
nothing external holds the URL and 304-with-empty-body reads as no-quote to
an axios consumer (ledger entry to follow with the slice docs).
XeroDocumentSuccessResponse gains nullable quote_id beside invoice_id; the
two xero operation renames are recorded in the work-list ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the v1 inspection: pypdf text extraction over non-blank pages,
space-normalised AND compact matching (Xero's text layer wraps mid-phrase
and sometimes drops word spaces), blank render raises rather than reporting
the marker absent, and the temp file survives every failure path for
diagnosis. The command emits exactly one sorted-keys JSON line — the
subprocess contract the E2E quote spec parses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-exports the cost-line/labour-rates/stock-search factories and the three
new quote operations through the api boundary. Adds @tanstack/react-table
and the shadcn popover + command primitives (installed, not hand-written).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TanStack Table v8 with module-constant column defs — rebuilding them per
render changes every cell component's identity and remounts (blurring) all
inputs, so cells reach live state through table meta instead. Day-one
contract: .smart-costlines-table, exactly one trailing phantom tbody row,
SmartCostLinesTable-*/DataTable-row-*/data-grid-* attributes derived from
the visual index, ItemSelect with labour-first options.
useAutosaveField DERIVES its display value (local buffer only while
editing): an optimistic write and its failure rollback can land between two
renders, so an effect keyed on the server value never fires — the unit net
caught a synced copy keeping rejected input on screen. 600ms debounce,
blur flushes and cancels, no If-Match on cost-line CRUD (deliberate, v1
parity). All failures toast; the tests fail on any console.error, matching
the E2E guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quote workspace: editable quote cost set, server-owned summary card
(ADR 0046), and the Xero card (ping-gated create, Export Quote to Xero
dialog with Send Total Only / Send Breakdown, open-in-Xero with noopener,
delete). Lazy-loaded from the job detail page.
The retrieve contract changed to {quote: QuoteOut | null}: the unit net
caught the generated axios client coercing a bare JSON null body to {},
which read as an existing quote and crashed the card — an envelope is the
only shape that round-trips absence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…check
Deliberate deviations from the v1 spec, each with its reason in place: a
dedicated job instead of the read-only sharedEditJobUrl fixture; no in-spec
Xero ping (global setup fails the run closed); waitForAutosave instead of
800ms sleeps around the 600ms debounce; and the PDF inspector spawns via
uv run python since nothing guarantees an activated interpreter under npm.
The repair machinery (Select Item pick, desc/unit-rev fills) ports
near-verbatim — it is the part of the spec that exercises the grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st states
Backend: the concurrent-push loser now voids its orphan Xero quote (under a
savepoint, so the compensation can persist its AppError) and refuses with
the readable 400; a quote deleted Xero-side returns a typed 404 from the
provider pre-read and the manager treats it as cleanup-allowed instead of
bricking the job; the local mirror stores the payload's date (midnight
span); present-but-null totals get the crafted message in both quote and
invoice managers; the not-found PDF keeps its file for diagnosis;
delete_invoice passes error_type through like its quote sibling.
Frontend: a failed draft POST clears the persisting guard so the row stays
retryable (was permanently bricked); draft inputs disable while the create
is in flight; the send-dedupe only skips a KNOWN-applied value so a rejected
edit can be retried; the quote card renders pending as pending, not as
create-state; error states stop masquerading as data (picker, ping,
summary); background-refetch errors keep the working grid; null profit
margin renders as a dash, not 0.0%; PATCH rollback reverts only the patched
fields against the current cache, with in-flight refetches cancelled first.
Spec: isEnabled guard before repairing a rev input; response schema
validates quote_id/online_url. Declined: the compact-match tightening (the
proposed regex is equivalent in power — its own counterexample defeats
both) and the readonly totals type change (2b precedent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:23 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f0ec6174-067a-4295-8ee5-20d5269c5cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 9ae11da and e19f4e6.

📒 Files selected for processing (11)
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/services/quote_pdf.py
  • docs/code-quality.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
📝 Walkthrough

Walkthrough

Added end-to-end Xero quote support across accounting providers, quote APIs, PDF inspection, job retrieval, frontend costing, and quote management UI. Added tests, OpenAPI updates, compatibility mappings, and E2E validation.

Changes

Xero quote backend

Layer / File(s)Summary
Provider contracts and integrations
apps/accounting/types.py, apps/accounting/provider.py, apps/xero/provider.py, apps/xero/readonly_provider.py, stubs/xero_python/..., apps/xero/tests/test_provider_documents.py
Added quote payload and PDF types, provider operations, Xero quote create/delete/PDF support, readonly behavior, and SDK stubs.
Quote manager and API lifecycle
apps/xero/documents/quote.py, apps/xero/api.py, apps/xero/documents/base.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py
Added quote validation, payload construction, persistence, compensation, deletion, audit events, authenticated endpoints, and lifecycle tests.
Quote PDF inspection tooling
apps/accounting/services/quote_pdf.py, apps/accounting/management/commands/..., apps/accounting/tests/test_quote_pdf.py
Added PDF text inspection, branding and page metadata, diagnostic-file handling, and JSON command output.
Job quote retrieval contract
apps/job/api.py, apps/job/schemas.py, apps/job/services/job_service.py, frontend/schema.v2.yml, frontend/src/api/index.ts
Added nullable enveloped quote retrieval with quote-number serialization and matching API schemas.
Cost-line editing workspace
frontend/src/features/job/costing/*, frontend/src/components/ui/*, frontend/package.json
Added cost-line types, calculations, autosave, optimistic CRUD, item selection, editable grid behavior, and supporting UI primitives.
Quote tab and Xero card
frontend/src/features/job/JobDetailPage.tsx, frontend/src/features/job/costing/JobQuoteTab.tsx, frontend/src/features/job/costing/XeroQuoteCard.tsx
Added the lazy-loaded quote tab, quote summary, cost grid, Xero quote creation modes, deletion, deep links, and mutation states.
End-to-end validation and status support
frontend/tests/e2e/job/job-xero-quote.spec.ts, docs/rewrite-status.md, docs/accepted-api-differences.yml, scripts/v1-frontend-operations.yml
Added E2E quote creation and PDF checks, diagnostic repair logic, status updates, API difference documentation, and operation mappings.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant User
participant JobQuoteTab
participant XeroQuoteCard
participant QuoteAPI
participant Xero
User->>JobQuoteTab: Open the quote tab
JobQuoteTab->>XeroQuoteCard: Load quote state
User->>XeroQuoteCard: Select quote mode and submit
XeroQuoteCard->>QuoteAPI: Create quote request
QuoteAPI->>Xero: Create quote
Xero-->>QuoteAPI: Return quote identifiers and totals
QuoteAPI-->>XeroQuoteCard: Return quote response
XeroQuoteCard-->>User: Display quote link and status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 32.57% 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 Xero quote path, cost-line grid, and related end-to-end test result.
Description check✅ PassedThe description thoroughly covers implementation details, scope, deviations, review outcomes, and verification results, despite omitting 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-path

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: 8

🧹 Nitpick comments (10)
frontend/src/features/job/costing/useCostLines.ts (1)

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

Document why deleteLine restores the whole snapshot.

patchLine carries a comment that rejects the wholesale snapshot restore and explains the interleaved-write constraint. deleteLine then uses that exact rejected strategy at Line 134 with no comment. A failed delete restores the full pre-delete cost set, so it also reverts any successful interleaved patch on another line until the onSettled refetch lands. Record the constraint that makes the wholesale restore correct here, for example that a removed row cannot be reconstructed field-by-field.

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 `@frontend/src/features/job/costing/useCostLines.ts` around lines 121 - 140,
Update deleteLine’s onError rollback comment to document why restoring the
entire snapshot is intentional: unlike patchLine, a deleted row cannot be
reconstructed field-by-field, so a wholesale restore is required despite
potentially reverting interleaved writes until invalidate refetches.

Source: Coding guidelines

frontend/src/features/job/costing/calc.test.ts (1)

123-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the two unresolved-reference fallbacks in itemLabel.

The suite does not exercise the fallback at calc.ts Line 79 (labour rate not in the list) or Line 86 (stock id not in the loaded page). The comment on calc.ts Line 85 states that the 'Stock item' fallback must not read as 'Select Item', because the E2E repair loop counts buttons by that exact name. A regression that returns 'Select Item' for an unresolved stock id would pass this suite and fail only in Playwright. Add the two cases.

🧪 Proposed additional cases
 it('names the labour subtype for a time line', () => {
expect(itemLabel(line({ kind: 'time', labour_subtype: 'workshop' }), stockById, rates)).toBe(
'Workshop',
)
})
++ it('falls back to the raw subtype when the rate is not loaded', () => {+ expect(itemLabel(line({ kind: 'time', labour_subtype: 'nightshift' }), stockById, rates)).toBe(+ 'nightshift',+ )+ })++ it('never reads as "Select Item" when bound to unloaded stock', () => {+ expect(itemLabel(line({ ext_refs: { stock_id: 'stock-unloaded' } }), stockById, rates)).toBe(+ 'Stock item',+ )+ })
})
🤖 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/calc.test.ts` around lines 123 - 140, Add
two `itemLabel` tests covering unresolved references: verify a time line with a
labour subtype absent from `rates` uses the labour-rate fallback, and verify a
bound line whose stock ID is absent from `stockById` returns the distinct “Stock
item” fallback rather than “Select Item”.
frontend/src/features/job/costing/CostLineGrid.test.tsx (1)

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

Annotate the labourRates and stockPage fixtures with the generated types.

materialLine and costSet are annotated, so a wire-type change breaks this file at compile time. labourRates and stockPage are bare literals, so the same change passes type-checking and fails only at runtime. calc.test.ts annotates the equivalent fixtures as JobLabourRateOut and StockItem. Add const labourRates: JobLabourRateOut[] and the generated page type for stockPage.

🤖 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.test.tsx` around lines 67 -
108, Annotate the labourRates fixture as JobLabourRateOut[] and annotate
stockPage with the generated page type used for StockItem results, matching the
equivalent fixtures in calc.test.ts. Preserve the existing fixture values while
ensuring wire-type changes are caught during compilation.
frontend/src/features/job/costing/ItemSelect.tsx (1)

88-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider debouncing the stock search input.

CommandInput calls onValueChange={setSearch} directly, and search is part of the purchasingStockSearchRetrieveOptions query key. Every keystroke while the popover is open fires a new server-side stock search request. The component's own comment notes that queries under 3 characters list everything, so short inputs during typing can each trigger a full unfiltered fetch.

Debounce the value passed to the query (for example with a small useDeferredValue or timer-based hook) so the request fires once typing pauses.

🤖 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/ItemSelect.tsx` around lines 88 - 92,
Debounce the search value used by the stock query in ItemSelect rather than
passing the raw search state into purchasingStockSearchRetrieveOptions on every
keystroke. Keep CommandInput responsive with the immediate value, and use a
small deferred or timer-based value so requests occur after typing pauses while
preserving the existing short-query behavior.
frontend/src/features/job/costing/XeroQuoteCard.test.tsx (1)

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

Use the user returned by renderWithProviders.

renderWithProviders already calls userEvent.setup() and returns the instance. Each test creates a second instance. Two sessions can hold separate pointer and keyboard state. Reuse the returned user in all five tests.

- const user = userEvent.setup()- renderWithProviders(<XeroQuoteCard jobId="job-1" />)+ const { user } = renderWithProviders(<XeroQuoteCard jobId="job-1" />)
🤖 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/XeroQuoteCard.test.tsx` around lines 72 -
73, Update all five tests in XeroQuoteCard.test.tsx to destructure and reuse the
user instance returned by renderWithProviders, removing each redundant
userEvent.setup() call while preserving the existing test interactions.
frontend/src/features/job/costing/CostLineGrid.tsx (1)

192-199: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Move the create call out of the setDrafts updater.

commitDraftField runs persistDraftIfReady inside a state updater. That updater executes during render. It starts a network mutation and calls syncPersisting (another setState) from render. React requires updaters to be pure. The persistingRef guard hides the StrictMode double invocation today, but any future replay of the updater still re-enters impure code.

Keep the latest drafts in a ref and run the persistence after the state update.

♻️ Proposed refactor: read drafts from a ref instead of a state updater
 const [drafts, setDrafts] = useState<DraftRow[]>([freshPhantom()])
+ // persistDraftIfReady must see the same render's updateDraft result, but a+ // network call inside a state updater is not a pure update; the ref carries+ // that latest value out of the updater instead.+ const draftsRef = useRef<DraftRow[]>(drafts)+ draftsRef.current = drafts
@@
updateDraft: (localId, patch) => {
setDrafts((current) => {
...
- return next+ draftsRef.current = next+ return next
})
},
commitDraftField: (localId) => {
- setDrafts((current) => {- persistDraftIfReady(current, localId)- return current- })+ persistDraftIfReady(draftsRef.current, localId)
},
🤖 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 192 - 199,
Refactor commitDraftField so persistDraftIfReady is no longer called inside the
setDrafts updater; keep the latest drafts synchronized in a ref and invoke
persistence after the state update using that ref. Ensure the setDrafts updater
remains pure while preserving the existing localId persistence behavior and
syncPersisting flow.
apps/xero/tests/test_provider_documents.py (2)

431-438: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the 404 status code, not only the error text.

XeroQuoteManager.delete_document (apps/xero/documents/quote.py, line 311) branches on result.status_code == 404 to clean up the local row when the quote is already gone from Xero. The error string is not part of that contract; the status code is. This test passes today even if delete_quote returns status 500 for an absent quote, which would brick the recovery path.

♻️ Proposed assertion
 result = provider.delete_quote(str(uuid.uuid4()))
assert not result.success
assert result.error is not None and "no quote" in result.error
+ # The manager keys its local-cleanup branch on this code, not the text.+ assert result.status_code == 404
🤖 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_provider_documents.py` around lines 431 - 438, Update
test_missing_quote_is_an_error_result to assert that the failed delete_quote
result has status_code 404, preserving the existing unsuccessful-result check
while treating the status code—not the error text—as the contract for a missing
quote.

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

Tighten the match, or drop "persists" from the test name.

match="quote" matches every ValueError this method raises, including the "returned N quotes" guard. The name promises a persistence assertion that the body does not make.

♻️ Proposed change
- with pytest.raises(ValueError, match="quote"):+ with pytest.raises(ValueError, match="for a request naming"):
provider.download_quote_pdf(str(uuid.uuid4()))
🤖 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_provider_documents.py` around lines 462 - 467, Align
test_quote_id_mismatch_raises_and_persists with its actual coverage: either
assert the specific quote-ID mismatch error message rather than the broad
“quote” match, and add the intended persistence assertion, or rename the test to
remove “persists” if persistence is not being verified.
apps/xero/tests/test_quote_manager.py (1)

170-197: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the unvoidable-orphan branch.

This test covers the race loser whose compensating void succeeds. The branch at apps/xero/documents/quote.py lines 216-224 is untested: when delete_quote also fails, the manager raises a ValueError carrying the orphan quote id. That id is the only record an operator has, because the AppError row cannot carry it. A regression that swallowed this raise would leave a real Xero quote orphaned with no trace.

♻️ Proposed companion test
+ def test_race_loser_raises_when_the_orphan_cannot_be_voided(+ self, company: Company, job: Job, office_staff: Staff+ ) -> None:+ """An unvoidable orphan must reach an operator with its Xero id."""+ provider = Mock()+ provider.get_account_code.return_value = "200"+ orphan = _success_result()++ def concurrent_winner_lands_first(payload: object) -> DocumentResult: # noqa: ARG001+ _existing_quote(job, company)+ return orphan++ provider.create_quote.side_effect = concurrent_winner_lands_first+ provider.delete_quote.return_value = DocumentResult(+ success=False, error="Quote is ACCEPTED", status_code=400+ )+ manager = _manager(company, job, office_staff, provider)++ with pytest.raises(ValueError, match=str(orphan.external_id)):+ manager.create_document(breakdown=False)++ assert Quote.objects.count() == 1 # only the winner's row
🤖 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 170 - 197, Add a
companion test for the race-loser path in create_document where
provider.delete_quote fails after Quote.objects.create is rejected; assert the
manager raises ValueError containing the orphan Xero quote id, while preserving
the existing assertions for the successful compensation case.
stubs/xero_python/accounting/__init__.pyi (1)

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

Add the Quote fields the provider sets.

apps/xero/provider.py (lines 271-272) passes line_amount_types and currency_code into Quote(...). The stub does not declare either attribute. The call type-checks today only because __init__ accepts **kwargs: Any. Any later attribute read of those fields would fail under strict mypy.

♻️ Proposed stub additions
 class Quote:
quote_id: str | None
quote_number: str | None
contact: Contact | None
date: Any
expiry_date: Any
status: str | None
line_items: list[LineItem] | None
+ line_amount_types: str | None+ currency_code: str | None
branding_theme_id: str | None
terms: str | None
reference: str | None
updated_date_utc: Any
🤖 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 `@stubs/xero_python/accounting/__init__.pyi` around lines 80 - 93, Update the
Quote stub by declaring the line_amount_types and currency_code attributes
alongside the existing quote fields, using types consistent with the values
passed by apps/xero/provider.py. Keep the existing constructor and serialization
declarations unchanged.
🤖 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 `@apps/accounting/management/commands/inspect_xero_quote_pdf.py`:
- Line 28: Replace the narration docstring near the command’s
validation/inspection flow in
apps/accounting/management/commands/inspect_xero_quote_pdf.py:28-28 with either
no comment or rationale explaining the rejected behavior and the subprocess
contract requiring the selected behavior. Update the comment in
frontend/tests/e2e/job/job-xero-quote.spec.ts:139-140 to explain that treating
the phantom row as a persisted cost line would create invalid repair work
because it has no persisted cost-line data.
In `@apps/accounting/services/quote_pdf.py`:
- Around line 32-69: Wrap the PdfReader usage in the inspection flow with a
context manager so its streams close before any cleanup. Ensure the reader is
closed on both successful validation and failure paths, while preserving the
diagnostic PDF when the expected text is absent and allowing unlink after the
reader scope ends.
In `@frontend/src/features/job/costing/calc.ts`:
- Around line 32-44: Update stockPickPatch to explicitly set labour_subtype to
null when constructing the material CostLineUpdateRequest, while preserving the
existing stock and cost field mappings.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 410-419: Update the onPickStock handler to preserve null for
missing patch.unit_cost and patch.unit_rev instead of converting absent values
to empty strings. Ensure the resulting DraftLine remains incomplete when either
price is unavailable, and omit null price fields when constructing the create
PATCH request.
In `@frontend/src/features/job/costing/useAutosaveField.ts`:
- Around line 52-64: Update dispatch to compare against a live serverValue ref
rather than the render-captured serverValue when the debounce callback runs.
Keep the existing knownApplied and untouched deduplication behavior, and ensure
the ref is synchronized with the latest server value before dispatch evaluates
parsed.
In `@frontend/src/features/job/costing/XeroQuoteCard.tsx`:
- Around line 170-190: Update the button label logic in the XeroQuoteCard
component to distinguish ping.isPending from a genuinely disconnected
xeroConnected state. While the Xero connection check is pending, show a
checking/loading label and prevent the logged-out “Login to Xero first” message;
retain the existing labels once the check resolves.
- Around line 81-95: The executeDelete handler currently deletes the quote
without confirmation. Update executeDelete to show a visible confirmation
dialog, such as window.confirm with a clear deletion message, and only call
deleteQuote.mutate when the user confirms; preserve the existing pending guard
and success/error callbacks.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Line 173: Update the AccountingApi quote PDF call to use the method name
provided by xero-python==15.0.0 instead of get_quote_as_pdf, avoiding the
AttributeError. If a local get_quote_as_pdf implementation is retained, pass
headers={"Accept": "application/pdf"} and write its response to a temporary file
before applying the return-type check, rather than treating the raw response as
a path.
---
Nitpick comments:
In `@apps/xero/tests/test_provider_documents.py`:
- Around line 431-438: Update test_missing_quote_is_an_error_result to assert
that the failed delete_quote result has status_code 404, preserving the existing
unsuccessful-result check while treating the status code—not the error text—as
the contract for a missing quote.
- Around line 462-467: Align test_quote_id_mismatch_raises_and_persists with its
actual coverage: either assert the specific quote-ID mismatch error message
rather than the broad “quote” match, and add the intended persistence assertion,
or rename the test to remove “persists” if persistence is not being verified.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 170-197: Add a companion test for the race-loser path in
create_document where provider.delete_quote fails after Quote.objects.create is
rejected; assert the manager raises ValueError containing the orphan Xero quote
id, while preserving the existing assertions for the successful compensation
case.
In `@frontend/src/features/job/costing/calc.test.ts`:
- Around line 123-140: Add two `itemLabel` tests covering unresolved references:
verify a time line with a labour subtype absent from `rates` uses the
labour-rate fallback, and verify a bound line whose stock ID is absent from
`stockById` returns the distinct “Stock item” fallback rather than “Select
Item”.
In `@frontend/src/features/job/costing/CostLineGrid.test.tsx`:
- Around line 67-108: Annotate the labourRates fixture as JobLabourRateOut[] and
annotate stockPage with the generated page type used for StockItem results,
matching the equivalent fixtures in calc.test.ts. Preserve the existing fixture
values while ensuring wire-type changes are caught during compilation.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 192-199: Refactor commitDraftField so persistDraftIfReady is no
longer called inside the setDrafts updater; keep the latest drafts synchronized
in a ref and invoke persistence after the state update using that ref. Ensure
the setDrafts updater remains pure while preserving the existing localId
persistence behavior and syncPersisting flow.
In `@frontend/src/features/job/costing/ItemSelect.tsx`:
- Around line 88-92: Debounce the search value used by the stock query in
ItemSelect rather than passing the raw search state into
purchasingStockSearchRetrieveOptions on every keystroke. Keep CommandInput
responsive with the immediate value, and use a small deferred or timer-based
value so requests occur after typing pauses while preserving the existing
short-query behavior.
In `@frontend/src/features/job/costing/useCostLines.ts`:
- Around line 121-140: Update deleteLine’s onError rollback comment to document
why restoring the entire snapshot is intentional: unlike patchLine, a deleted
row cannot be reconstructed field-by-field, so a wholesale restore is required
despite potentially reverting interleaved writes until invalidate refetches.
In `@frontend/src/features/job/costing/XeroQuoteCard.test.tsx`:
- Around line 72-73: Update all five tests in XeroQuoteCard.test.tsx to
destructure and reuse the user instance returned by renderWithProviders,
removing each redundant userEvent.setup() call while preserving the existing
test interactions.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Around line 80-93: Update the Quote stub by declaring the line_amount_types
and currency_code attributes alongside the existing quote fields, using types
consistent with the values passed by apps/xero/provider.py. Keep the existing
constructor and serialization declarations unchanged.
🪄 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: 6120c3fb-75ad-489f-9410-9c2daf5d7eaa

📥 Commits

Reviewing files that changed from the base of the PR and between cd24a0f and 9ae11da.

⛔ Files ignored due to path filters (6)
  • frontend/package-lock.json is excluded by !**/package-lock.json
  • frontend/src/api/generated/@tanstack/react-query.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/index.ts is excluded by !**/generated/**
  • frontend/src/api/generated/sdk.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/types.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/zod.gen.ts is excluded by !**/generated/**
📒 Files selected for processing (45)
  • apps/accounting/management/__init__.py
  • apps/accounting/management/commands/__init__.py
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/provider.py
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/accounting/types.py
  • apps/job/api.py
  • apps/job/schemas.py
  • apps/job/services/job_service.py
  • apps/job/tests/test_job_api.py
  • apps/xero/api.py
  • apps/xero/documents/base.py
  • apps/xero/documents/invoice.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/readonly_provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/accepted-api-differences.yml
  • docs/rewrite-status.md
  • frontend/package.json
  • frontend/schema.v2.yml
  • frontend/src/api/index.ts
  • frontend/src/components/ui/command.tsx
  • frontend/src/components/ui/popover.tsx
  • frontend/src/features/job/JobDetailPage.tsx
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/ItemSelect.tsx
  • frontend/src/features/job/costing/JobQuoteTab.test.tsx
  • frontend/src/features/job/costing/JobQuoteTab.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/types.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • frontend/src/lib/format.ts
  • frontend/src/test/setup.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
  • scripts/v1-frontend-operations.yml
  • stubs/xero_python/accounting/__init__.pyi

Comment threadapps/accounting/services/quote_pdf.py Outdated
Comment threadfrontend/src/features/job/costing/calc.ts
Comment threadfrontend/src/features/job/costing/CostLineGrid.tsx
Comment threadfrontend/src/features/job/costing/useAutosaveField.ts
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadstubs/xero_python/accounting/__init__.pyi
corrinand others added 2 commits August 9, 2026 18:13
Movements all belong to this slice: two call-time manager imports and test
fixtures (PLC0415), the provider's deliberate exception-to-result
conversions (BLE001, returns-instead), the TableMeta module augmentation
(eslint-disable), and the new manager/provider try shapes. Passthrough
stays pinned at zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est states
Close the PdfReader before any unlink (a held stream fails deletion on
Windows); a stock pick clears labour_subtype so a converted time line
carries no stale subtype; the draft item-pick preserves absence as null
instead of '' (an empty string would satisfy the persist-ready check);
the debounce dispatch compares against the live server value via a ref;
quote deletion asks for confirmation like a cost-line delete; a pending
Xero ping reads 'Checking Xero…' instead of the logged-out label; two
comments now state their rejected alternatives. Declined with evidence in
the thread: the claim that xero-python 15.0.0 lacks get_quote_as_pdf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@corrin
corrin merged commit cd5943d into mainAug 9, 2026
3 checks passed
corrin added a commit that referenced this pull request Aug 9, 2026
* Quote hardening B1-B5: explicit validation, full compensation, unbricked 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>
* One document-endpoint adapter: auth refusal, failure map, success build
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>
* Grid hardening F1-F6 + assertive spec: drafts persist, retries retry
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>
* Record the deferred structure work; regenerate metrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Persist the collision at its catch site; refresh derived rows
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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40) - #48

Merged
corrin merged 12 commits into
mainfrom
xero/quote-path
Aug 9, 2026
Merged

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40)#48
corrin merged 12 commits into
mainfrom
xero/quote-path

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Slice 2c — the last of the three slice-2 Xero PRs. Done means the spec is green: job-xero-quote.spec.ts passed writes-live against the demo tenant (real DRAFT quote created, native Xero PDF downloaded server-side and the configured terms text found in it), and the full run_e2e.sh gate passed all 39 tests across the 14 ported spec files on the final code.

Backend

  • apps/xero/documents/quote.py — expected refusals (already quoted, T&M pricing, empty quote cost set, blank breakdown descriptions, missing theme/terms config) return typed 400 values with the provider never called; unexpected failures persist once and re-raise. Total-only mode sends the cost-set summary revenue as one line; breakdown mode one sanitised line per cost line. The concurrent-push loser voids its orphan Xero quote (savepoint-guarded) before refusing; a quote deleted Xero-side comes back as a typed 404 from the provider pre-read and the manager cleans up the local row instead of bricking the job.
  • Provider: create_quote, delete_quote (soft delete via pre-read + DELETED upsert), download_quote_pdf. Readonly fabricates QU-E2E-* results and refuses the PDF download — a fabricated file would satisfy the text assertion against nothing.
  • Endpoints: POST /api/xero/create_quote/{job_id} (body {breakdown}), DELETE /api/xero/delete_quote/{job_id} (no id parameter — one quote per job) at v1-parity URL fragments; GET /api/job/jobs/{id}/quote/ serving {quote: QuoteOut | null} — enveloped because the generated axios client coerces a bare JSON null body to {} (ledgered, with the conditional-GET drop).
  • PDF inspection: apps/accounting/services/quote_pdf.py + the inspect_xero_quote_pdf command emitting the single JSON line the spec parses; the file survives every diagnostic path.

Frontend

  • features/job/costing/CostLineGrid.tsx — the one cost-line grid (estimate/actual arrive later as prop configs), on TanStack Table v8 with module-constant column defs (per-render defs would remount and blur every input). Full day-one selector contract: .smart-costlines-table, exactly one trailing phantom tbody row, SmartCostLinesTable-*/DataTable-row-*/data-grid-* from the visual index, ItemSelect-option-*, trigger named Select Item only when unbound.
  • useAutosaveField derives its display value (local buffer only while editing) — an effect-synced copy provably kept rejected input on screen when an optimistic write and its rollback coalesced into one render. 600ms debounce, blur flushes and cancels, deliberately no If-Match on cost-line CRUD (v1 parity).
  • JobQuoteTab + XeroQuoteCard: server-owned summary (ADR 0046), ping-gated create, "Export Quote to Xero" dialog (Send Total Only / Send Breakdown), open-in-Xero with noopener, delete. Lazy-loaded.
  • Deferred with attributes already in place: keyboard-nav behaviour, duplicate-line, unit-rev override bookkeeping, data-freshness polling.

Spec port

Recorded deviations from v1's spec: own job instead of the read-only sharedEditJobUrl fixture; no in-spec ping (global setup fails the run closed); waitForAutosave instead of 800ms sleeps; PDF inspector spawned via uv run python. The repair machinery ports near-verbatim — it is what exercises the grid every run (the fresh job's material line lacks a stock binding).

Review

Adversarial 2-subagent review pre-PR; all four backend should-fixes and the frontend blocker + should-fixes applied with regression tests (draft-POST failure recovery, same-value retry after rollback). Declined with reasons in the commit: the compact-match "tightening" (equivalent in power to the existing check) and the readonly totals type change (2b precedent).

Verification: 1708 unit tests, coverage 88.46%, job-xero-quote green live, full E2E gate green twice (before and after review fixes).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Quote workspace to job details for managing cost lines, labour, stock, pricing, and autosaving changes.
    • Added Xero quote creation with total-only or detailed line breakdown options.
    • Added options to open quotes in Xero and delete existing quotes.
    • Added job quote retrieval through the API, including quote numbers and summary details.
    • Added quote PDF inspection for validating terms, branding, page count, and extracted text.
  • Bug Fixes
    • Improved handling of missing invoice totals and clearer document deletion errors.
  • Documentation
    • Updated API and rewrite progress documentation for quote functionality.

corrinand others added 10 commits August 9, 2026 15:30
…otocol
QuotePayload carries required terms (Xero applies no default to API-created
quotes) and an expiry date; QuotePdfDocument hands the caller a temp file it
owns. Live provider mirrors the invoice shapes: constructed edit-URL, DELETED
upsert after a contact/date pre-read, PDF download that raises rather than
returning a partial result. Readonly fabricates QU-E2E-* results and refuses
the PDF download outright — a fabricated file would satisfy the text
assertion against nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… mirror
Expected refusals (already quoted, T&M pricing, empty quote cost set, blank
breakdown descriptions, missing theme/terms config) return typed 400 values
with the provider never called; unexpected failures persist once and re-raise
per the base contract. Total-only mode sends the cost-set summary revenue as
a single line; breakdown mode one sanitised line per cost line. The job's
updated_at bumps in-request so the tab refetch sees quoted=true. The
duplicated _create_job_event hoisted from invoice.py into the base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /xero/create_quote/{job_id} (body: breakdown) and DELETE
/xero/delete_quote/{job_id} mirror the invoice handlers; delete takes no id
parameter because a job holds at most one quote. GET /job/jobs/{id}/quote/
serves the Xero quote header or null — a plain GET, not v1's conditional-GET:
nothing external holds the URL and 304-with-empty-body reads as no-quote to
an axios consumer (ledger entry to follow with the slice docs).
XeroDocumentSuccessResponse gains nullable quote_id beside invoice_id; the
two xero operation renames are recorded in the work-list ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the v1 inspection: pypdf text extraction over non-blank pages,
space-normalised AND compact matching (Xero's text layer wraps mid-phrase
and sometimes drops word spaces), blank render raises rather than reporting
the marker absent, and the temp file survives every failure path for
diagnosis. The command emits exactly one sorted-keys JSON line — the
subprocess contract the E2E quote spec parses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-exports the cost-line/labour-rates/stock-search factories and the three
new quote operations through the api boundary. Adds @tanstack/react-table
and the shadcn popover + command primitives (installed, not hand-written).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TanStack Table v8 with module-constant column defs — rebuilding them per
render changes every cell component's identity and remounts (blurring) all
inputs, so cells reach live state through table meta instead. Day-one
contract: .smart-costlines-table, exactly one trailing phantom tbody row,
SmartCostLinesTable-*/DataTable-row-*/data-grid-* attributes derived from
the visual index, ItemSelect with labour-first options.
useAutosaveField DERIVES its display value (local buffer only while
editing): an optimistic write and its failure rollback can land between two
renders, so an effect keyed on the server value never fires — the unit net
caught a synced copy keeping rejected input on screen. 600ms debounce,
blur flushes and cancels, no If-Match on cost-line CRUD (deliberate, v1
parity). All failures toast; the tests fail on any console.error, matching
the E2E guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quote workspace: editable quote cost set, server-owned summary card
(ADR 0046), and the Xero card (ping-gated create, Export Quote to Xero
dialog with Send Total Only / Send Breakdown, open-in-Xero with noopener,
delete). Lazy-loaded from the job detail page.
The retrieve contract changed to {quote: QuoteOut | null}: the unit net
caught the generated axios client coercing a bare JSON null body to {},
which read as an existing quote and crashed the card — an envelope is the
only shape that round-trips absence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…check
Deliberate deviations from the v1 spec, each with its reason in place: a
dedicated job instead of the read-only sharedEditJobUrl fixture; no in-spec
Xero ping (global setup fails the run closed); waitForAutosave instead of
800ms sleeps around the 600ms debounce; and the PDF inspector spawns via
uv run python since nothing guarantees an activated interpreter under npm.
The repair machinery (Select Item pick, desc/unit-rev fills) ports
near-verbatim — it is the part of the spec that exercises the grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st states
Backend: the concurrent-push loser now voids its orphan Xero quote (under a
savepoint, so the compensation can persist its AppError) and refuses with
the readable 400; a quote deleted Xero-side returns a typed 404 from the
provider pre-read and the manager treats it as cleanup-allowed instead of
bricking the job; the local mirror stores the payload's date (midnight
span); present-but-null totals get the crafted message in both quote and
invoice managers; the not-found PDF keeps its file for diagnosis;
delete_invoice passes error_type through like its quote sibling.
Frontend: a failed draft POST clears the persisting guard so the row stays
retryable (was permanently bricked); draft inputs disable while the create
is in flight; the send-dedupe only skips a KNOWN-applied value so a rejected
edit can be retried; the quote card renders pending as pending, not as
create-state; error states stop masquerading as data (picker, ping,
summary); background-refetch errors keep the working grid; null profit
margin renders as a dash, not 0.0%; PATCH rollback reverts only the patched
fields against the current cache, with in-flight refetches cancelled first.
Spec: isEnabled guard before repairing a rev input; response schema
validates quote_id/online_url. Declined: the compact-match tightening (the
proposed regex is equivalent in power — its own counterexample defeats
both) and the readonly totals type change (2b precedent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:23 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f0ec6174-067a-4295-8ee5-20d5269c5cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 9ae11da and e19f4e6.

📒 Files selected for processing (11)
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/services/quote_pdf.py
  • docs/code-quality.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
📝 Walkthrough

Walkthrough

Added end-to-end Xero quote support across accounting providers, quote APIs, PDF inspection, job retrieval, frontend costing, and quote management UI. Added tests, OpenAPI updates, compatibility mappings, and E2E validation.

Changes

Xero quote backend

Layer / File(s)Summary
Provider contracts and integrations
apps/accounting/types.py, apps/accounting/provider.py, apps/xero/provider.py, apps/xero/readonly_provider.py, stubs/xero_python/..., apps/xero/tests/test_provider_documents.py
Added quote payload and PDF types, provider operations, Xero quote create/delete/PDF support, readonly behavior, and SDK stubs.
Quote manager and API lifecycle
apps/xero/documents/quote.py, apps/xero/api.py, apps/xero/documents/base.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py
Added quote validation, payload construction, persistence, compensation, deletion, audit events, authenticated endpoints, and lifecycle tests.
Quote PDF inspection tooling
apps/accounting/services/quote_pdf.py, apps/accounting/management/commands/..., apps/accounting/tests/test_quote_pdf.py
Added PDF text inspection, branding and page metadata, diagnostic-file handling, and JSON command output.
Job quote retrieval contract
apps/job/api.py, apps/job/schemas.py, apps/job/services/job_service.py, frontend/schema.v2.yml, frontend/src/api/index.ts
Added nullable enveloped quote retrieval with quote-number serialization and matching API schemas.
Cost-line editing workspace
frontend/src/features/job/costing/*, frontend/src/components/ui/*, frontend/package.json
Added cost-line types, calculations, autosave, optimistic CRUD, item selection, editable grid behavior, and supporting UI primitives.
Quote tab and Xero card
frontend/src/features/job/JobDetailPage.tsx, frontend/src/features/job/costing/JobQuoteTab.tsx, frontend/src/features/job/costing/XeroQuoteCard.tsx
Added the lazy-loaded quote tab, quote summary, cost grid, Xero quote creation modes, deletion, deep links, and mutation states.
End-to-end validation and status support
frontend/tests/e2e/job/job-xero-quote.spec.ts, docs/rewrite-status.md, docs/accepted-api-differences.yml, scripts/v1-frontend-operations.yml
Added E2E quote creation and PDF checks, diagnostic repair logic, status updates, API difference documentation, and operation mappings.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant User
participant JobQuoteTab
participant XeroQuoteCard
participant QuoteAPI
participant Xero
User->>JobQuoteTab: Open the quote tab
JobQuoteTab->>XeroQuoteCard: Load quote state
User->>XeroQuoteCard: Select quote mode and submit
XeroQuoteCard->>QuoteAPI: Create quote request
QuoteAPI->>Xero: Create quote
Xero-->>QuoteAPI: Return quote identifiers and totals
QuoteAPI-->>XeroQuoteCard: Return quote response
XeroQuoteCard-->>User: Display quote link and status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 32.57% 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 Xero quote path, cost-line grid, and related end-to-end test result.
Description check✅ PassedThe description thoroughly covers implementation details, scope, deviations, review outcomes, and verification results, despite omitting 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-path

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: 8

🧹 Nitpick comments (10)
frontend/src/features/job/costing/useCostLines.ts (1)

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

Document why deleteLine restores the whole snapshot.

patchLine carries a comment that rejects the wholesale snapshot restore and explains the interleaved-write constraint. deleteLine then uses that exact rejected strategy at Line 134 with no comment. A failed delete restores the full pre-delete cost set, so it also reverts any successful interleaved patch on another line until the onSettled refetch lands. Record the constraint that makes the wholesale restore correct here, for example that a removed row cannot be reconstructed field-by-field.

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 `@frontend/src/features/job/costing/useCostLines.ts` around lines 121 - 140,
Update deleteLine’s onError rollback comment to document why restoring the
entire snapshot is intentional: unlike patchLine, a deleted row cannot be
reconstructed field-by-field, so a wholesale restore is required despite
potentially reverting interleaved writes until invalidate refetches.

Source: Coding guidelines

frontend/src/features/job/costing/calc.test.ts (1)

123-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the two unresolved-reference fallbacks in itemLabel.

The suite does not exercise the fallback at calc.ts Line 79 (labour rate not in the list) or Line 86 (stock id not in the loaded page). The comment on calc.ts Line 85 states that the 'Stock item' fallback must not read as 'Select Item', because the E2E repair loop counts buttons by that exact name. A regression that returns 'Select Item' for an unresolved stock id would pass this suite and fail only in Playwright. Add the two cases.

🧪 Proposed additional cases
 it('names the labour subtype for a time line', () => {
expect(itemLabel(line({ kind: 'time', labour_subtype: 'workshop' }), stockById, rates)).toBe(
'Workshop',
)
})
++ it('falls back to the raw subtype when the rate is not loaded', () => {+ expect(itemLabel(line({ kind: 'time', labour_subtype: 'nightshift' }), stockById, rates)).toBe(+ 'nightshift',+ )+ })++ it('never reads as "Select Item" when bound to unloaded stock', () => {+ expect(itemLabel(line({ ext_refs: { stock_id: 'stock-unloaded' } }), stockById, rates)).toBe(+ 'Stock item',+ )+ })
})
🤖 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/calc.test.ts` around lines 123 - 140, Add
two `itemLabel` tests covering unresolved references: verify a time line with a
labour subtype absent from `rates` uses the labour-rate fallback, and verify a
bound line whose stock ID is absent from `stockById` returns the distinct “Stock
item” fallback rather than “Select Item”.
frontend/src/features/job/costing/CostLineGrid.test.tsx (1)

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

Annotate the labourRates and stockPage fixtures with the generated types.

materialLine and costSet are annotated, so a wire-type change breaks this file at compile time. labourRates and stockPage are bare literals, so the same change passes type-checking and fails only at runtime. calc.test.ts annotates the equivalent fixtures as JobLabourRateOut and StockItem. Add const labourRates: JobLabourRateOut[] and the generated page type for stockPage.

🤖 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.test.tsx` around lines 67 -
108, Annotate the labourRates fixture as JobLabourRateOut[] and annotate
stockPage with the generated page type used for StockItem results, matching the
equivalent fixtures in calc.test.ts. Preserve the existing fixture values while
ensuring wire-type changes are caught during compilation.
frontend/src/features/job/costing/ItemSelect.tsx (1)

88-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider debouncing the stock search input.

CommandInput calls onValueChange={setSearch} directly, and search is part of the purchasingStockSearchRetrieveOptions query key. Every keystroke while the popover is open fires a new server-side stock search request. The component's own comment notes that queries under 3 characters list everything, so short inputs during typing can each trigger a full unfiltered fetch.

Debounce the value passed to the query (for example with a small useDeferredValue or timer-based hook) so the request fires once typing pauses.

🤖 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/ItemSelect.tsx` around lines 88 - 92,
Debounce the search value used by the stock query in ItemSelect rather than
passing the raw search state into purchasingStockSearchRetrieveOptions on every
keystroke. Keep CommandInput responsive with the immediate value, and use a
small deferred or timer-based value so requests occur after typing pauses while
preserving the existing short-query behavior.
frontend/src/features/job/costing/XeroQuoteCard.test.tsx (1)

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

Use the user returned by renderWithProviders.

renderWithProviders already calls userEvent.setup() and returns the instance. Each test creates a second instance. Two sessions can hold separate pointer and keyboard state. Reuse the returned user in all five tests.

- const user = userEvent.setup()- renderWithProviders(<XeroQuoteCard jobId="job-1" />)+ const { user } = renderWithProviders(<XeroQuoteCard jobId="job-1" />)
🤖 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/XeroQuoteCard.test.tsx` around lines 72 -
73, Update all five tests in XeroQuoteCard.test.tsx to destructure and reuse the
user instance returned by renderWithProviders, removing each redundant
userEvent.setup() call while preserving the existing test interactions.
frontend/src/features/job/costing/CostLineGrid.tsx (1)

192-199: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Move the create call out of the setDrafts updater.

commitDraftField runs persistDraftIfReady inside a state updater. That updater executes during render. It starts a network mutation and calls syncPersisting (another setState) from render. React requires updaters to be pure. The persistingRef guard hides the StrictMode double invocation today, but any future replay of the updater still re-enters impure code.

Keep the latest drafts in a ref and run the persistence after the state update.

♻️ Proposed refactor: read drafts from a ref instead of a state updater
 const [drafts, setDrafts] = useState<DraftRow[]>([freshPhantom()])
+ // persistDraftIfReady must see the same render's updateDraft result, but a+ // network call inside a state updater is not a pure update; the ref carries+ // that latest value out of the updater instead.+ const draftsRef = useRef<DraftRow[]>(drafts)+ draftsRef.current = drafts
@@
updateDraft: (localId, patch) => {
setDrafts((current) => {
...
- return next+ draftsRef.current = next+ return next
})
},
commitDraftField: (localId) => {
- setDrafts((current) => {- persistDraftIfReady(current, localId)- return current- })+ persistDraftIfReady(draftsRef.current, localId)
},
🤖 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 192 - 199,
Refactor commitDraftField so persistDraftIfReady is no longer called inside the
setDrafts updater; keep the latest drafts synchronized in a ref and invoke
persistence after the state update using that ref. Ensure the setDrafts updater
remains pure while preserving the existing localId persistence behavior and
syncPersisting flow.
apps/xero/tests/test_provider_documents.py (2)

431-438: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the 404 status code, not only the error text.

XeroQuoteManager.delete_document (apps/xero/documents/quote.py, line 311) branches on result.status_code == 404 to clean up the local row when the quote is already gone from Xero. The error string is not part of that contract; the status code is. This test passes today even if delete_quote returns status 500 for an absent quote, which would brick the recovery path.

♻️ Proposed assertion
 result = provider.delete_quote(str(uuid.uuid4()))
assert not result.success
assert result.error is not None and "no quote" in result.error
+ # The manager keys its local-cleanup branch on this code, not the text.+ assert result.status_code == 404
🤖 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_provider_documents.py` around lines 431 - 438, Update
test_missing_quote_is_an_error_result to assert that the failed delete_quote
result has status_code 404, preserving the existing unsuccessful-result check
while treating the status code—not the error text—as the contract for a missing
quote.

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

Tighten the match, or drop "persists" from the test name.

match="quote" matches every ValueError this method raises, including the "returned N quotes" guard. The name promises a persistence assertion that the body does not make.

♻️ Proposed change
- with pytest.raises(ValueError, match="quote"):+ with pytest.raises(ValueError, match="for a request naming"):
provider.download_quote_pdf(str(uuid.uuid4()))
🤖 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_provider_documents.py` around lines 462 - 467, Align
test_quote_id_mismatch_raises_and_persists with its actual coverage: either
assert the specific quote-ID mismatch error message rather than the broad
“quote” match, and add the intended persistence assertion, or rename the test to
remove “persists” if persistence is not being verified.
apps/xero/tests/test_quote_manager.py (1)

170-197: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the unvoidable-orphan branch.

This test covers the race loser whose compensating void succeeds. The branch at apps/xero/documents/quote.py lines 216-224 is untested: when delete_quote also fails, the manager raises a ValueError carrying the orphan quote id. That id is the only record an operator has, because the AppError row cannot carry it. A regression that swallowed this raise would leave a real Xero quote orphaned with no trace.

♻️ Proposed companion test
+ def test_race_loser_raises_when_the_orphan_cannot_be_voided(+ self, company: Company, job: Job, office_staff: Staff+ ) -> None:+ """An unvoidable orphan must reach an operator with its Xero id."""+ provider = Mock()+ provider.get_account_code.return_value = "200"+ orphan = _success_result()++ def concurrent_winner_lands_first(payload: object) -> DocumentResult: # noqa: ARG001+ _existing_quote(job, company)+ return orphan++ provider.create_quote.side_effect = concurrent_winner_lands_first+ provider.delete_quote.return_value = DocumentResult(+ success=False, error="Quote is ACCEPTED", status_code=400+ )+ manager = _manager(company, job, office_staff, provider)++ with pytest.raises(ValueError, match=str(orphan.external_id)):+ manager.create_document(breakdown=False)++ assert Quote.objects.count() == 1 # only the winner's row
🤖 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 170 - 197, Add a
companion test for the race-loser path in create_document where
provider.delete_quote fails after Quote.objects.create is rejected; assert the
manager raises ValueError containing the orphan Xero quote id, while preserving
the existing assertions for the successful compensation case.
stubs/xero_python/accounting/__init__.pyi (1)

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

Add the Quote fields the provider sets.

apps/xero/provider.py (lines 271-272) passes line_amount_types and currency_code into Quote(...). The stub does not declare either attribute. The call type-checks today only because __init__ accepts **kwargs: Any. Any later attribute read of those fields would fail under strict mypy.

♻️ Proposed stub additions
 class Quote:
quote_id: str | None
quote_number: str | None
contact: Contact | None
date: Any
expiry_date: Any
status: str | None
line_items: list[LineItem] | None
+ line_amount_types: str | None+ currency_code: str | None
branding_theme_id: str | None
terms: str | None
reference: str | None
updated_date_utc: Any
🤖 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 `@stubs/xero_python/accounting/__init__.pyi` around lines 80 - 93, Update the
Quote stub by declaring the line_amount_types and currency_code attributes
alongside the existing quote fields, using types consistent with the values
passed by apps/xero/provider.py. Keep the existing constructor and serialization
declarations unchanged.
🤖 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 `@apps/accounting/management/commands/inspect_xero_quote_pdf.py`:
- Line 28: Replace the narration docstring near the command’s
validation/inspection flow in
apps/accounting/management/commands/inspect_xero_quote_pdf.py:28-28 with either
no comment or rationale explaining the rejected behavior and the subprocess
contract requiring the selected behavior. Update the comment in
frontend/tests/e2e/job/job-xero-quote.spec.ts:139-140 to explain that treating
the phantom row as a persisted cost line would create invalid repair work
because it has no persisted cost-line data.
In `@apps/accounting/services/quote_pdf.py`:
- Around line 32-69: Wrap the PdfReader usage in the inspection flow with a
context manager so its streams close before any cleanup. Ensure the reader is
closed on both successful validation and failure paths, while preserving the
diagnostic PDF when the expected text is absent and allowing unlink after the
reader scope ends.
In `@frontend/src/features/job/costing/calc.ts`:
- Around line 32-44: Update stockPickPatch to explicitly set labour_subtype to
null when constructing the material CostLineUpdateRequest, while preserving the
existing stock and cost field mappings.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 410-419: Update the onPickStock handler to preserve null for
missing patch.unit_cost and patch.unit_rev instead of converting absent values
to empty strings. Ensure the resulting DraftLine remains incomplete when either
price is unavailable, and omit null price fields when constructing the create
PATCH request.
In `@frontend/src/features/job/costing/useAutosaveField.ts`:
- Around line 52-64: Update dispatch to compare against a live serverValue ref
rather than the render-captured serverValue when the debounce callback runs.
Keep the existing knownApplied and untouched deduplication behavior, and ensure
the ref is synchronized with the latest server value before dispatch evaluates
parsed.
In `@frontend/src/features/job/costing/XeroQuoteCard.tsx`:
- Around line 170-190: Update the button label logic in the XeroQuoteCard
component to distinguish ping.isPending from a genuinely disconnected
xeroConnected state. While the Xero connection check is pending, show a
checking/loading label and prevent the logged-out “Login to Xero first” message;
retain the existing labels once the check resolves.
- Around line 81-95: The executeDelete handler currently deletes the quote
without confirmation. Update executeDelete to show a visible confirmation
dialog, such as window.confirm with a clear deletion message, and only call
deleteQuote.mutate when the user confirms; preserve the existing pending guard
and success/error callbacks.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Line 173: Update the AccountingApi quote PDF call to use the method name
provided by xero-python==15.0.0 instead of get_quote_as_pdf, avoiding the
AttributeError. If a local get_quote_as_pdf implementation is retained, pass
headers={"Accept": "application/pdf"} and write its response to a temporary file
before applying the return-type check, rather than treating the raw response as
a path.
---
Nitpick comments:
In `@apps/xero/tests/test_provider_documents.py`:
- Around line 431-438: Update test_missing_quote_is_an_error_result to assert
that the failed delete_quote result has status_code 404, preserving the existing
unsuccessful-result check while treating the status code—not the error text—as
the contract for a missing quote.
- Around line 462-467: Align test_quote_id_mismatch_raises_and_persists with its
actual coverage: either assert the specific quote-ID mismatch error message
rather than the broad “quote” match, and add the intended persistence assertion,
or rename the test to remove “persists” if persistence is not being verified.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 170-197: Add a companion test for the race-loser path in
create_document where provider.delete_quote fails after Quote.objects.create is
rejected; assert the manager raises ValueError containing the orphan Xero quote
id, while preserving the existing assertions for the successful compensation
case.
In `@frontend/src/features/job/costing/calc.test.ts`:
- Around line 123-140: Add two `itemLabel` tests covering unresolved references:
verify a time line with a labour subtype absent from `rates` uses the
labour-rate fallback, and verify a bound line whose stock ID is absent from
`stockById` returns the distinct “Stock item” fallback rather than “Select
Item”.
In `@frontend/src/features/job/costing/CostLineGrid.test.tsx`:
- Around line 67-108: Annotate the labourRates fixture as JobLabourRateOut[] and
annotate stockPage with the generated page type used for StockItem results,
matching the equivalent fixtures in calc.test.ts. Preserve the existing fixture
values while ensuring wire-type changes are caught during compilation.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 192-199: Refactor commitDraftField so persistDraftIfReady is no
longer called inside the setDrafts updater; keep the latest drafts synchronized
in a ref and invoke persistence after the state update using that ref. Ensure
the setDrafts updater remains pure while preserving the existing localId
persistence behavior and syncPersisting flow.
In `@frontend/src/features/job/costing/ItemSelect.tsx`:
- Around line 88-92: Debounce the search value used by the stock query in
ItemSelect rather than passing the raw search state into
purchasingStockSearchRetrieveOptions on every keystroke. Keep CommandInput
responsive with the immediate value, and use a small deferred or timer-based
value so requests occur after typing pauses while preserving the existing
short-query behavior.
In `@frontend/src/features/job/costing/useCostLines.ts`:
- Around line 121-140: Update deleteLine’s onError rollback comment to document
why restoring the entire snapshot is intentional: unlike patchLine, a deleted
row cannot be reconstructed field-by-field, so a wholesale restore is required
despite potentially reverting interleaved writes until invalidate refetches.
In `@frontend/src/features/job/costing/XeroQuoteCard.test.tsx`:
- Around line 72-73: Update all five tests in XeroQuoteCard.test.tsx to
destructure and reuse the user instance returned by renderWithProviders,
removing each redundant userEvent.setup() call while preserving the existing
test interactions.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Around line 80-93: Update the Quote stub by declaring the line_amount_types
and currency_code attributes alongside the existing quote fields, using types
consistent with the values passed by apps/xero/provider.py. Keep the existing
constructor and serialization declarations unchanged.
🪄 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: 6120c3fb-75ad-489f-9410-9c2daf5d7eaa

📥 Commits

Reviewing files that changed from the base of the PR and between cd24a0f and 9ae11da.

⛔ Files ignored due to path filters (6)
  • frontend/package-lock.json is excluded by !**/package-lock.json
  • frontend/src/api/generated/@tanstack/react-query.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/index.ts is excluded by !**/generated/**
  • frontend/src/api/generated/sdk.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/types.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/zod.gen.ts is excluded by !**/generated/**
📒 Files selected for processing (45)
  • apps/accounting/management/__init__.py
  • apps/accounting/management/commands/__init__.py
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/provider.py
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/accounting/types.py
  • apps/job/api.py
  • apps/job/schemas.py
  • apps/job/services/job_service.py
  • apps/job/tests/test_job_api.py
  • apps/xero/api.py
  • apps/xero/documents/base.py
  • apps/xero/documents/invoice.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/readonly_provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/accepted-api-differences.yml
  • docs/rewrite-status.md
  • frontend/package.json
  • frontend/schema.v2.yml
  • frontend/src/api/index.ts
  • frontend/src/components/ui/command.tsx
  • frontend/src/components/ui/popover.tsx
  • frontend/src/features/job/JobDetailPage.tsx
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/ItemSelect.tsx
  • frontend/src/features/job/costing/JobQuoteTab.test.tsx
  • frontend/src/features/job/costing/JobQuoteTab.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/types.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • frontend/src/lib/format.ts
  • frontend/src/test/setup.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
  • scripts/v1-frontend-operations.yml
  • stubs/xero_python/accounting/__init__.pyi

Comment threadapps/accounting/services/quote_pdf.py Outdated
Comment threadfrontend/src/features/job/costing/calc.ts
Comment threadfrontend/src/features/job/costing/CostLineGrid.tsx
Comment threadfrontend/src/features/job/costing/useAutosaveField.ts
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadstubs/xero_python/accounting/__init__.pyi
corrinand others added 2 commits August 9, 2026 18:13
Movements all belong to this slice: two call-time manager imports and test
fixtures (PLC0415), the provider's deliberate exception-to-result
conversions (BLE001, returns-instead), the TableMeta module augmentation
(eslint-disable), and the new manager/provider try shapes. Passthrough
stays pinned at zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est states
Close the PdfReader before any unlink (a held stream fails deletion on
Windows); a stock pick clears labour_subtype so a converted time line
carries no stale subtype; the draft item-pick preserves absence as null
instead of '' (an empty string would satisfy the persist-ready check);
the debounce dispatch compares against the live server value via a ref;
quote deletion asks for confirmation like a cost-line delete; a pending
Xero ping reads 'Checking Xero…' instead of the logged-out label; two
comments now state their rejected alternatives. Declined with evidence in
the thread: the claim that xero-python 15.0.0 lacks get_quote_as_pdf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@corrin
corrin merged commit cd5943d into mainAug 9, 2026
3 checks passed
corrin added a commit that referenced this pull request Aug 9, 2026
* Quote hardening B1-B5: explicit validation, full compensation, unbricked 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>
* One document-endpoint adapter: auth refusal, failure map, success build
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>
* Grid hardening F1-F6 + assertive spec: drafts persist, retries retry
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>
* Record the deferred structure work; regenerate metrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Persist the collision at its catch site; refresh derived rows
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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40) - #48

Merged
corrin merged 12 commits into
mainfrom
xero/quote-path
Aug 9, 2026
Merged

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40)#48
corrin merged 12 commits into
mainfrom
xero/quote-path

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Slice 2c — the last of the three slice-2 Xero PRs. Done means the spec is green: job-xero-quote.spec.ts passed writes-live against the demo tenant (real DRAFT quote created, native Xero PDF downloaded server-side and the configured terms text found in it), and the full run_e2e.sh gate passed all 39 tests across the 14 ported spec files on the final code.

Backend

  • apps/xero/documents/quote.py — expected refusals (already quoted, T&M pricing, empty quote cost set, blank breakdown descriptions, missing theme/terms config) return typed 400 values with the provider never called; unexpected failures persist once and re-raise. Total-only mode sends the cost-set summary revenue as one line; breakdown mode one sanitised line per cost line. The concurrent-push loser voids its orphan Xero quote (savepoint-guarded) before refusing; a quote deleted Xero-side comes back as a typed 404 from the provider pre-read and the manager cleans up the local row instead of bricking the job.
  • Provider: create_quote, delete_quote (soft delete via pre-read + DELETED upsert), download_quote_pdf. Readonly fabricates QU-E2E-* results and refuses the PDF download — a fabricated file would satisfy the text assertion against nothing.
  • Endpoints: POST /api/xero/create_quote/{job_id} (body {breakdown}), DELETE /api/xero/delete_quote/{job_id} (no id parameter — one quote per job) at v1-parity URL fragments; GET /api/job/jobs/{id}/quote/ serving {quote: QuoteOut | null} — enveloped because the generated axios client coerces a bare JSON null body to {} (ledgered, with the conditional-GET drop).
  • PDF inspection: apps/accounting/services/quote_pdf.py + the inspect_xero_quote_pdf command emitting the single JSON line the spec parses; the file survives every diagnostic path.

Frontend

  • features/job/costing/CostLineGrid.tsx — the one cost-line grid (estimate/actual arrive later as prop configs), on TanStack Table v8 with module-constant column defs (per-render defs would remount and blur every input). Full day-one selector contract: .smart-costlines-table, exactly one trailing phantom tbody row, SmartCostLinesTable-*/DataTable-row-*/data-grid-* from the visual index, ItemSelect-option-*, trigger named Select Item only when unbound.
  • useAutosaveField derives its display value (local buffer only while editing) — an effect-synced copy provably kept rejected input on screen when an optimistic write and its rollback coalesced into one render. 600ms debounce, blur flushes and cancels, deliberately no If-Match on cost-line CRUD (v1 parity).
  • JobQuoteTab + XeroQuoteCard: server-owned summary (ADR 0046), ping-gated create, "Export Quote to Xero" dialog (Send Total Only / Send Breakdown), open-in-Xero with noopener, delete. Lazy-loaded.
  • Deferred with attributes already in place: keyboard-nav behaviour, duplicate-line, unit-rev override bookkeeping, data-freshness polling.

Spec port

Recorded deviations from v1's spec: own job instead of the read-only sharedEditJobUrl fixture; no in-spec ping (global setup fails the run closed); waitForAutosave instead of 800ms sleeps; PDF inspector spawned via uv run python. The repair machinery ports near-verbatim — it is what exercises the grid every run (the fresh job's material line lacks a stock binding).

Review

Adversarial 2-subagent review pre-PR; all four backend should-fixes and the frontend blocker + should-fixes applied with regression tests (draft-POST failure recovery, same-value retry after rollback). Declined with reasons in the commit: the compact-match "tightening" (equivalent in power to the existing check) and the readonly totals type change (2b precedent).

Verification: 1708 unit tests, coverage 88.46%, job-xero-quote green live, full E2E gate green twice (before and after review fixes).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Quote workspace to job details for managing cost lines, labour, stock, pricing, and autosaving changes.
    • Added Xero quote creation with total-only or detailed line breakdown options.
    • Added options to open quotes in Xero and delete existing quotes.
    • Added job quote retrieval through the API, including quote numbers and summary details.
    • Added quote PDF inspection for validating terms, branding, page count, and extracted text.
  • Bug Fixes
    • Improved handling of missing invoice totals and clearer document deletion errors.
  • Documentation
    • Updated API and rewrite progress documentation for quote functionality.

corrinand others added 10 commits August 9, 2026 15:30
…otocol
QuotePayload carries required terms (Xero applies no default to API-created
quotes) and an expiry date; QuotePdfDocument hands the caller a temp file it
owns. Live provider mirrors the invoice shapes: constructed edit-URL, DELETED
upsert after a contact/date pre-read, PDF download that raises rather than
returning a partial result. Readonly fabricates QU-E2E-* results and refuses
the PDF download outright — a fabricated file would satisfy the text
assertion against nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… mirror
Expected refusals (already quoted, T&M pricing, empty quote cost set, blank
breakdown descriptions, missing theme/terms config) return typed 400 values
with the provider never called; unexpected failures persist once and re-raise
per the base contract. Total-only mode sends the cost-set summary revenue as
a single line; breakdown mode one sanitised line per cost line. The job's
updated_at bumps in-request so the tab refetch sees quoted=true. The
duplicated _create_job_event hoisted from invoice.py into the base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /xero/create_quote/{job_id} (body: breakdown) and DELETE
/xero/delete_quote/{job_id} mirror the invoice handlers; delete takes no id
parameter because a job holds at most one quote. GET /job/jobs/{id}/quote/
serves the Xero quote header or null — a plain GET, not v1's conditional-GET:
nothing external holds the URL and 304-with-empty-body reads as no-quote to
an axios consumer (ledger entry to follow with the slice docs).
XeroDocumentSuccessResponse gains nullable quote_id beside invoice_id; the
two xero operation renames are recorded in the work-list ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the v1 inspection: pypdf text extraction over non-blank pages,
space-normalised AND compact matching (Xero's text layer wraps mid-phrase
and sometimes drops word spaces), blank render raises rather than reporting
the marker absent, and the temp file survives every failure path for
diagnosis. The command emits exactly one sorted-keys JSON line — the
subprocess contract the E2E quote spec parses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-exports the cost-line/labour-rates/stock-search factories and the three
new quote operations through the api boundary. Adds @tanstack/react-table
and the shadcn popover + command primitives (installed, not hand-written).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TanStack Table v8 with module-constant column defs — rebuilding them per
render changes every cell component's identity and remounts (blurring) all
inputs, so cells reach live state through table meta instead. Day-one
contract: .smart-costlines-table, exactly one trailing phantom tbody row,
SmartCostLinesTable-*/DataTable-row-*/data-grid-* attributes derived from
the visual index, ItemSelect with labour-first options.
useAutosaveField DERIVES its display value (local buffer only while
editing): an optimistic write and its failure rollback can land between two
renders, so an effect keyed on the server value never fires — the unit net
caught a synced copy keeping rejected input on screen. 600ms debounce,
blur flushes and cancels, no If-Match on cost-line CRUD (deliberate, v1
parity). All failures toast; the tests fail on any console.error, matching
the E2E guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quote workspace: editable quote cost set, server-owned summary card
(ADR 0046), and the Xero card (ping-gated create, Export Quote to Xero
dialog with Send Total Only / Send Breakdown, open-in-Xero with noopener,
delete). Lazy-loaded from the job detail page.
The retrieve contract changed to {quote: QuoteOut | null}: the unit net
caught the generated axios client coercing a bare JSON null body to {},
which read as an existing quote and crashed the card — an envelope is the
only shape that round-trips absence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…check
Deliberate deviations from the v1 spec, each with its reason in place: a
dedicated job instead of the read-only sharedEditJobUrl fixture; no in-spec
Xero ping (global setup fails the run closed); waitForAutosave instead of
800ms sleeps around the 600ms debounce; and the PDF inspector spawns via
uv run python since nothing guarantees an activated interpreter under npm.
The repair machinery (Select Item pick, desc/unit-rev fills) ports
near-verbatim — it is the part of the spec that exercises the grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st states
Backend: the concurrent-push loser now voids its orphan Xero quote (under a
savepoint, so the compensation can persist its AppError) and refuses with
the readable 400; a quote deleted Xero-side returns a typed 404 from the
provider pre-read and the manager treats it as cleanup-allowed instead of
bricking the job; the local mirror stores the payload's date (midnight
span); present-but-null totals get the crafted message in both quote and
invoice managers; the not-found PDF keeps its file for diagnosis;
delete_invoice passes error_type through like its quote sibling.
Frontend: a failed draft POST clears the persisting guard so the row stays
retryable (was permanently bricked); draft inputs disable while the create
is in flight; the send-dedupe only skips a KNOWN-applied value so a rejected
edit can be retried; the quote card renders pending as pending, not as
create-state; error states stop masquerading as data (picker, ping,
summary); background-refetch errors keep the working grid; null profit
margin renders as a dash, not 0.0%; PATCH rollback reverts only the patched
fields against the current cache, with in-flight refetches cancelled first.
Spec: isEnabled guard before repairing a rev input; response schema
validates quote_id/online_url. Declined: the compact-match tightening (the
proposed regex is equivalent in power — its own counterexample defeats
both) and the readonly totals type change (2b precedent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:23 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f0ec6174-067a-4295-8ee5-20d5269c5cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 9ae11da and e19f4e6.

📒 Files selected for processing (11)
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/services/quote_pdf.py
  • docs/code-quality.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
📝 Walkthrough

Walkthrough

Added end-to-end Xero quote support across accounting providers, quote APIs, PDF inspection, job retrieval, frontend costing, and quote management UI. Added tests, OpenAPI updates, compatibility mappings, and E2E validation.

Changes

Xero quote backend

Layer / File(s)Summary
Provider contracts and integrations
apps/accounting/types.py, apps/accounting/provider.py, apps/xero/provider.py, apps/xero/readonly_provider.py, stubs/xero_python/..., apps/xero/tests/test_provider_documents.py
Added quote payload and PDF types, provider operations, Xero quote create/delete/PDF support, readonly behavior, and SDK stubs.
Quote manager and API lifecycle
apps/xero/documents/quote.py, apps/xero/api.py, apps/xero/documents/base.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py
Added quote validation, payload construction, persistence, compensation, deletion, audit events, authenticated endpoints, and lifecycle tests.
Quote PDF inspection tooling
apps/accounting/services/quote_pdf.py, apps/accounting/management/commands/..., apps/accounting/tests/test_quote_pdf.py
Added PDF text inspection, branding and page metadata, diagnostic-file handling, and JSON command output.
Job quote retrieval contract
apps/job/api.py, apps/job/schemas.py, apps/job/services/job_service.py, frontend/schema.v2.yml, frontend/src/api/index.ts
Added nullable enveloped quote retrieval with quote-number serialization and matching API schemas.
Cost-line editing workspace
frontend/src/features/job/costing/*, frontend/src/components/ui/*, frontend/package.json
Added cost-line types, calculations, autosave, optimistic CRUD, item selection, editable grid behavior, and supporting UI primitives.
Quote tab and Xero card
frontend/src/features/job/JobDetailPage.tsx, frontend/src/features/job/costing/JobQuoteTab.tsx, frontend/src/features/job/costing/XeroQuoteCard.tsx
Added the lazy-loaded quote tab, quote summary, cost grid, Xero quote creation modes, deletion, deep links, and mutation states.
End-to-end validation and status support
frontend/tests/e2e/job/job-xero-quote.spec.ts, docs/rewrite-status.md, docs/accepted-api-differences.yml, scripts/v1-frontend-operations.yml
Added E2E quote creation and PDF checks, diagnostic repair logic, status updates, API difference documentation, and operation mappings.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant User
participant JobQuoteTab
participant XeroQuoteCard
participant QuoteAPI
participant Xero
User->>JobQuoteTab: Open the quote tab
JobQuoteTab->>XeroQuoteCard: Load quote state
User->>XeroQuoteCard: Select quote mode and submit
XeroQuoteCard->>QuoteAPI: Create quote request
QuoteAPI->>Xero: Create quote
Xero-->>QuoteAPI: Return quote identifiers and totals
QuoteAPI-->>XeroQuoteCard: Return quote response
XeroQuoteCard-->>User: Display quote link and status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 32.57% 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 Xero quote path, cost-line grid, and related end-to-end test result.
Description check✅ PassedThe description thoroughly covers implementation details, scope, deviations, review outcomes, and verification results, despite omitting 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-path

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: 8

🧹 Nitpick comments (10)
frontend/src/features/job/costing/useCostLines.ts (1)

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

Document why deleteLine restores the whole snapshot.

patchLine carries a comment that rejects the wholesale snapshot restore and explains the interleaved-write constraint. deleteLine then uses that exact rejected strategy at Line 134 with no comment. A failed delete restores the full pre-delete cost set, so it also reverts any successful interleaved patch on another line until the onSettled refetch lands. Record the constraint that makes the wholesale restore correct here, for example that a removed row cannot be reconstructed field-by-field.

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 `@frontend/src/features/job/costing/useCostLines.ts` around lines 121 - 140,
Update deleteLine’s onError rollback comment to document why restoring the
entire snapshot is intentional: unlike patchLine, a deleted row cannot be
reconstructed field-by-field, so a wholesale restore is required despite
potentially reverting interleaved writes until invalidate refetches.

Source: Coding guidelines

frontend/src/features/job/costing/calc.test.ts (1)

123-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the two unresolved-reference fallbacks in itemLabel.

The suite does not exercise the fallback at calc.ts Line 79 (labour rate not in the list) or Line 86 (stock id not in the loaded page). The comment on calc.ts Line 85 states that the 'Stock item' fallback must not read as 'Select Item', because the E2E repair loop counts buttons by that exact name. A regression that returns 'Select Item' for an unresolved stock id would pass this suite and fail only in Playwright. Add the two cases.

🧪 Proposed additional cases
 it('names the labour subtype for a time line', () => {
expect(itemLabel(line({ kind: 'time', labour_subtype: 'workshop' }), stockById, rates)).toBe(
'Workshop',
)
})
++ it('falls back to the raw subtype when the rate is not loaded', () => {+ expect(itemLabel(line({ kind: 'time', labour_subtype: 'nightshift' }), stockById, rates)).toBe(+ 'nightshift',+ )+ })++ it('never reads as "Select Item" when bound to unloaded stock', () => {+ expect(itemLabel(line({ ext_refs: { stock_id: 'stock-unloaded' } }), stockById, rates)).toBe(+ 'Stock item',+ )+ })
})
🤖 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/calc.test.ts` around lines 123 - 140, Add
two `itemLabel` tests covering unresolved references: verify a time line with a
labour subtype absent from `rates` uses the labour-rate fallback, and verify a
bound line whose stock ID is absent from `stockById` returns the distinct “Stock
item” fallback rather than “Select Item”.
frontend/src/features/job/costing/CostLineGrid.test.tsx (1)

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

Annotate the labourRates and stockPage fixtures with the generated types.

materialLine and costSet are annotated, so a wire-type change breaks this file at compile time. labourRates and stockPage are bare literals, so the same change passes type-checking and fails only at runtime. calc.test.ts annotates the equivalent fixtures as JobLabourRateOut and StockItem. Add const labourRates: JobLabourRateOut[] and the generated page type for stockPage.

🤖 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.test.tsx` around lines 67 -
108, Annotate the labourRates fixture as JobLabourRateOut[] and annotate
stockPage with the generated page type used for StockItem results, matching the
equivalent fixtures in calc.test.ts. Preserve the existing fixture values while
ensuring wire-type changes are caught during compilation.
frontend/src/features/job/costing/ItemSelect.tsx (1)

88-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider debouncing the stock search input.

CommandInput calls onValueChange={setSearch} directly, and search is part of the purchasingStockSearchRetrieveOptions query key. Every keystroke while the popover is open fires a new server-side stock search request. The component's own comment notes that queries under 3 characters list everything, so short inputs during typing can each trigger a full unfiltered fetch.

Debounce the value passed to the query (for example with a small useDeferredValue or timer-based hook) so the request fires once typing pauses.

🤖 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/ItemSelect.tsx` around lines 88 - 92,
Debounce the search value used by the stock query in ItemSelect rather than
passing the raw search state into purchasingStockSearchRetrieveOptions on every
keystroke. Keep CommandInput responsive with the immediate value, and use a
small deferred or timer-based value so requests occur after typing pauses while
preserving the existing short-query behavior.
frontend/src/features/job/costing/XeroQuoteCard.test.tsx (1)

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

Use the user returned by renderWithProviders.

renderWithProviders already calls userEvent.setup() and returns the instance. Each test creates a second instance. Two sessions can hold separate pointer and keyboard state. Reuse the returned user in all five tests.

- const user = userEvent.setup()- renderWithProviders(<XeroQuoteCard jobId="job-1" />)+ const { user } = renderWithProviders(<XeroQuoteCard jobId="job-1" />)
🤖 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/XeroQuoteCard.test.tsx` around lines 72 -
73, Update all five tests in XeroQuoteCard.test.tsx to destructure and reuse the
user instance returned by renderWithProviders, removing each redundant
userEvent.setup() call while preserving the existing test interactions.
frontend/src/features/job/costing/CostLineGrid.tsx (1)

192-199: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Move the create call out of the setDrafts updater.

commitDraftField runs persistDraftIfReady inside a state updater. That updater executes during render. It starts a network mutation and calls syncPersisting (another setState) from render. React requires updaters to be pure. The persistingRef guard hides the StrictMode double invocation today, but any future replay of the updater still re-enters impure code.

Keep the latest drafts in a ref and run the persistence after the state update.

♻️ Proposed refactor: read drafts from a ref instead of a state updater
 const [drafts, setDrafts] = useState<DraftRow[]>([freshPhantom()])
+ // persistDraftIfReady must see the same render's updateDraft result, but a+ // network call inside a state updater is not a pure update; the ref carries+ // that latest value out of the updater instead.+ const draftsRef = useRef<DraftRow[]>(drafts)+ draftsRef.current = drafts
@@
updateDraft: (localId, patch) => {
setDrafts((current) => {
...
- return next+ draftsRef.current = next+ return next
})
},
commitDraftField: (localId) => {
- setDrafts((current) => {- persistDraftIfReady(current, localId)- return current- })+ persistDraftIfReady(draftsRef.current, localId)
},
🤖 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 192 - 199,
Refactor commitDraftField so persistDraftIfReady is no longer called inside the
setDrafts updater; keep the latest drafts synchronized in a ref and invoke
persistence after the state update using that ref. Ensure the setDrafts updater
remains pure while preserving the existing localId persistence behavior and
syncPersisting flow.
apps/xero/tests/test_provider_documents.py (2)

431-438: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the 404 status code, not only the error text.

XeroQuoteManager.delete_document (apps/xero/documents/quote.py, line 311) branches on result.status_code == 404 to clean up the local row when the quote is already gone from Xero. The error string is not part of that contract; the status code is. This test passes today even if delete_quote returns status 500 for an absent quote, which would brick the recovery path.

♻️ Proposed assertion
 result = provider.delete_quote(str(uuid.uuid4()))
assert not result.success
assert result.error is not None and "no quote" in result.error
+ # The manager keys its local-cleanup branch on this code, not the text.+ assert result.status_code == 404
🤖 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_provider_documents.py` around lines 431 - 438, Update
test_missing_quote_is_an_error_result to assert that the failed delete_quote
result has status_code 404, preserving the existing unsuccessful-result check
while treating the status code—not the error text—as the contract for a missing
quote.

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

Tighten the match, or drop "persists" from the test name.

match="quote" matches every ValueError this method raises, including the "returned N quotes" guard. The name promises a persistence assertion that the body does not make.

♻️ Proposed change
- with pytest.raises(ValueError, match="quote"):+ with pytest.raises(ValueError, match="for a request naming"):
provider.download_quote_pdf(str(uuid.uuid4()))
🤖 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_provider_documents.py` around lines 462 - 467, Align
test_quote_id_mismatch_raises_and_persists with its actual coverage: either
assert the specific quote-ID mismatch error message rather than the broad
“quote” match, and add the intended persistence assertion, or rename the test to
remove “persists” if persistence is not being verified.
apps/xero/tests/test_quote_manager.py (1)

170-197: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the unvoidable-orphan branch.

This test covers the race loser whose compensating void succeeds. The branch at apps/xero/documents/quote.py lines 216-224 is untested: when delete_quote also fails, the manager raises a ValueError carrying the orphan quote id. That id is the only record an operator has, because the AppError row cannot carry it. A regression that swallowed this raise would leave a real Xero quote orphaned with no trace.

♻️ Proposed companion test
+ def test_race_loser_raises_when_the_orphan_cannot_be_voided(+ self, company: Company, job: Job, office_staff: Staff+ ) -> None:+ """An unvoidable orphan must reach an operator with its Xero id."""+ provider = Mock()+ provider.get_account_code.return_value = "200"+ orphan = _success_result()++ def concurrent_winner_lands_first(payload: object) -> DocumentResult: # noqa: ARG001+ _existing_quote(job, company)+ return orphan++ provider.create_quote.side_effect = concurrent_winner_lands_first+ provider.delete_quote.return_value = DocumentResult(+ success=False, error="Quote is ACCEPTED", status_code=400+ )+ manager = _manager(company, job, office_staff, provider)++ with pytest.raises(ValueError, match=str(orphan.external_id)):+ manager.create_document(breakdown=False)++ assert Quote.objects.count() == 1 # only the winner's row
🤖 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 170 - 197, Add a
companion test for the race-loser path in create_document where
provider.delete_quote fails after Quote.objects.create is rejected; assert the
manager raises ValueError containing the orphan Xero quote id, while preserving
the existing assertions for the successful compensation case.
stubs/xero_python/accounting/__init__.pyi (1)

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

Add the Quote fields the provider sets.

apps/xero/provider.py (lines 271-272) passes line_amount_types and currency_code into Quote(...). The stub does not declare either attribute. The call type-checks today only because __init__ accepts **kwargs: Any. Any later attribute read of those fields would fail under strict mypy.

♻️ Proposed stub additions
 class Quote:
quote_id: str | None
quote_number: str | None
contact: Contact | None
date: Any
expiry_date: Any
status: str | None
line_items: list[LineItem] | None
+ line_amount_types: str | None+ currency_code: str | None
branding_theme_id: str | None
terms: str | None
reference: str | None
updated_date_utc: Any
🤖 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 `@stubs/xero_python/accounting/__init__.pyi` around lines 80 - 93, Update the
Quote stub by declaring the line_amount_types and currency_code attributes
alongside the existing quote fields, using types consistent with the values
passed by apps/xero/provider.py. Keep the existing constructor and serialization
declarations unchanged.
🤖 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 `@apps/accounting/management/commands/inspect_xero_quote_pdf.py`:
- Line 28: Replace the narration docstring near the command’s
validation/inspection flow in
apps/accounting/management/commands/inspect_xero_quote_pdf.py:28-28 with either
no comment or rationale explaining the rejected behavior and the subprocess
contract requiring the selected behavior. Update the comment in
frontend/tests/e2e/job/job-xero-quote.spec.ts:139-140 to explain that treating
the phantom row as a persisted cost line would create invalid repair work
because it has no persisted cost-line data.
In `@apps/accounting/services/quote_pdf.py`:
- Around line 32-69: Wrap the PdfReader usage in the inspection flow with a
context manager so its streams close before any cleanup. Ensure the reader is
closed on both successful validation and failure paths, while preserving the
diagnostic PDF when the expected text is absent and allowing unlink after the
reader scope ends.
In `@frontend/src/features/job/costing/calc.ts`:
- Around line 32-44: Update stockPickPatch to explicitly set labour_subtype to
null when constructing the material CostLineUpdateRequest, while preserving the
existing stock and cost field mappings.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 410-419: Update the onPickStock handler to preserve null for
missing patch.unit_cost and patch.unit_rev instead of converting absent values
to empty strings. Ensure the resulting DraftLine remains incomplete when either
price is unavailable, and omit null price fields when constructing the create
PATCH request.
In `@frontend/src/features/job/costing/useAutosaveField.ts`:
- Around line 52-64: Update dispatch to compare against a live serverValue ref
rather than the render-captured serverValue when the debounce callback runs.
Keep the existing knownApplied and untouched deduplication behavior, and ensure
the ref is synchronized with the latest server value before dispatch evaluates
parsed.
In `@frontend/src/features/job/costing/XeroQuoteCard.tsx`:
- Around line 170-190: Update the button label logic in the XeroQuoteCard
component to distinguish ping.isPending from a genuinely disconnected
xeroConnected state. While the Xero connection check is pending, show a
checking/loading label and prevent the logged-out “Login to Xero first” message;
retain the existing labels once the check resolves.
- Around line 81-95: The executeDelete handler currently deletes the quote
without confirmation. Update executeDelete to show a visible confirmation
dialog, such as window.confirm with a clear deletion message, and only call
deleteQuote.mutate when the user confirms; preserve the existing pending guard
and success/error callbacks.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Line 173: Update the AccountingApi quote PDF call to use the method name
provided by xero-python==15.0.0 instead of get_quote_as_pdf, avoiding the
AttributeError. If a local get_quote_as_pdf implementation is retained, pass
headers={"Accept": "application/pdf"} and write its response to a temporary file
before applying the return-type check, rather than treating the raw response as
a path.
---
Nitpick comments:
In `@apps/xero/tests/test_provider_documents.py`:
- Around line 431-438: Update test_missing_quote_is_an_error_result to assert
that the failed delete_quote result has status_code 404, preserving the existing
unsuccessful-result check while treating the status code—not the error text—as
the contract for a missing quote.
- Around line 462-467: Align test_quote_id_mismatch_raises_and_persists with its
actual coverage: either assert the specific quote-ID mismatch error message
rather than the broad “quote” match, and add the intended persistence assertion,
or rename the test to remove “persists” if persistence is not being verified.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 170-197: Add a companion test for the race-loser path in
create_document where provider.delete_quote fails after Quote.objects.create is
rejected; assert the manager raises ValueError containing the orphan Xero quote
id, while preserving the existing assertions for the successful compensation
case.
In `@frontend/src/features/job/costing/calc.test.ts`:
- Around line 123-140: Add two `itemLabel` tests covering unresolved references:
verify a time line with a labour subtype absent from `rates` uses the
labour-rate fallback, and verify a bound line whose stock ID is absent from
`stockById` returns the distinct “Stock item” fallback rather than “Select
Item”.
In `@frontend/src/features/job/costing/CostLineGrid.test.tsx`:
- Around line 67-108: Annotate the labourRates fixture as JobLabourRateOut[] and
annotate stockPage with the generated page type used for StockItem results,
matching the equivalent fixtures in calc.test.ts. Preserve the existing fixture
values while ensuring wire-type changes are caught during compilation.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 192-199: Refactor commitDraftField so persistDraftIfReady is no
longer called inside the setDrafts updater; keep the latest drafts synchronized
in a ref and invoke persistence after the state update using that ref. Ensure
the setDrafts updater remains pure while preserving the existing localId
persistence behavior and syncPersisting flow.
In `@frontend/src/features/job/costing/ItemSelect.tsx`:
- Around line 88-92: Debounce the search value used by the stock query in
ItemSelect rather than passing the raw search state into
purchasingStockSearchRetrieveOptions on every keystroke. Keep CommandInput
responsive with the immediate value, and use a small deferred or timer-based
value so requests occur after typing pauses while preserving the existing
short-query behavior.
In `@frontend/src/features/job/costing/useCostLines.ts`:
- Around line 121-140: Update deleteLine’s onError rollback comment to document
why restoring the entire snapshot is intentional: unlike patchLine, a deleted
row cannot be reconstructed field-by-field, so a wholesale restore is required
despite potentially reverting interleaved writes until invalidate refetches.
In `@frontend/src/features/job/costing/XeroQuoteCard.test.tsx`:
- Around line 72-73: Update all five tests in XeroQuoteCard.test.tsx to
destructure and reuse the user instance returned by renderWithProviders,
removing each redundant userEvent.setup() call while preserving the existing
test interactions.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Around line 80-93: Update the Quote stub by declaring the line_amount_types
and currency_code attributes alongside the existing quote fields, using types
consistent with the values passed by apps/xero/provider.py. Keep the existing
constructor and serialization declarations unchanged.
🪄 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: 6120c3fb-75ad-489f-9410-9c2daf5d7eaa

📥 Commits

Reviewing files that changed from the base of the PR and between cd24a0f and 9ae11da.

⛔ Files ignored due to path filters (6)
  • frontend/package-lock.json is excluded by !**/package-lock.json
  • frontend/src/api/generated/@tanstack/react-query.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/index.ts is excluded by !**/generated/**
  • frontend/src/api/generated/sdk.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/types.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/zod.gen.ts is excluded by !**/generated/**
📒 Files selected for processing (45)
  • apps/accounting/management/__init__.py
  • apps/accounting/management/commands/__init__.py
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/provider.py
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/accounting/types.py
  • apps/job/api.py
  • apps/job/schemas.py
  • apps/job/services/job_service.py
  • apps/job/tests/test_job_api.py
  • apps/xero/api.py
  • apps/xero/documents/base.py
  • apps/xero/documents/invoice.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/readonly_provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/accepted-api-differences.yml
  • docs/rewrite-status.md
  • frontend/package.json
  • frontend/schema.v2.yml
  • frontend/src/api/index.ts
  • frontend/src/components/ui/command.tsx
  • frontend/src/components/ui/popover.tsx
  • frontend/src/features/job/JobDetailPage.tsx
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/ItemSelect.tsx
  • frontend/src/features/job/costing/JobQuoteTab.test.tsx
  • frontend/src/features/job/costing/JobQuoteTab.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/types.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • frontend/src/lib/format.ts
  • frontend/src/test/setup.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
  • scripts/v1-frontend-operations.yml
  • stubs/xero_python/accounting/__init__.pyi

Comment threadapps/accounting/services/quote_pdf.py Outdated
Comment threadfrontend/src/features/job/costing/calc.ts
Comment threadfrontend/src/features/job/costing/CostLineGrid.tsx
Comment threadfrontend/src/features/job/costing/useAutosaveField.ts
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadstubs/xero_python/accounting/__init__.pyi
corrinand others added 2 commits August 9, 2026 18:13
Movements all belong to this slice: two call-time manager imports and test
fixtures (PLC0415), the provider's deliberate exception-to-result
conversions (BLE001, returns-instead), the TableMeta module augmentation
(eslint-disable), and the new manager/provider try shapes. Passthrough
stays pinned at zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est states
Close the PdfReader before any unlink (a held stream fails deletion on
Windows); a stock pick clears labour_subtype so a converted time line
carries no stale subtype; the draft item-pick preserves absence as null
instead of '' (an empty string would satisfy the persist-ready check);
the debounce dispatch compares against the live server value via a ref;
quote deletion asks for confirmation like a cost-line delete; a pending
Xero ping reads 'Checking Xero…' instead of the logged-out label; two
comments now state their rejected alternatives. Declined with evidence in
the thread: the claim that xero-python 15.0.0 lacks get_quote_as_pdf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@corrin
corrin merged commit cd5943d into mainAug 9, 2026
3 checks passed
corrin added a commit that referenced this pull request Aug 9, 2026
* Quote hardening B1-B5: explicit validation, full compensation, unbricked 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>
* One document-endpoint adapter: auth refusal, failure map, success build
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>
* Grid hardening F1-F6 + assertive spec: drafts persist, retries retry
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>
* Record the deferred structure work; regenerate metrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Persist the collision at its catch site; refresh derived rows
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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40) - #48

Merged
corrin merged 12 commits into
mainfrom
xero/quote-path
Aug 9, 2026
Merged

Xero quote path: quote push + cost-line grid — job-xero-quote green (14 of 40)#48
corrin merged 12 commits into
mainfrom
xero/quote-path

Conversation

@corrin

@corrincorrin commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Slice 2c — the last of the three slice-2 Xero PRs. Done means the spec is green: job-xero-quote.spec.ts passed writes-live against the demo tenant (real DRAFT quote created, native Xero PDF downloaded server-side and the configured terms text found in it), and the full run_e2e.sh gate passed all 39 tests across the 14 ported spec files on the final code.

Backend

  • apps/xero/documents/quote.py — expected refusals (already quoted, T&M pricing, empty quote cost set, blank breakdown descriptions, missing theme/terms config) return typed 400 values with the provider never called; unexpected failures persist once and re-raise. Total-only mode sends the cost-set summary revenue as one line; breakdown mode one sanitised line per cost line. The concurrent-push loser voids its orphan Xero quote (savepoint-guarded) before refusing; a quote deleted Xero-side comes back as a typed 404 from the provider pre-read and the manager cleans up the local row instead of bricking the job.
  • Provider: create_quote, delete_quote (soft delete via pre-read + DELETED upsert), download_quote_pdf. Readonly fabricates QU-E2E-* results and refuses the PDF download — a fabricated file would satisfy the text assertion against nothing.
  • Endpoints: POST /api/xero/create_quote/{job_id} (body {breakdown}), DELETE /api/xero/delete_quote/{job_id} (no id parameter — one quote per job) at v1-parity URL fragments; GET /api/job/jobs/{id}/quote/ serving {quote: QuoteOut | null} — enveloped because the generated axios client coerces a bare JSON null body to {} (ledgered, with the conditional-GET drop).
  • PDF inspection: apps/accounting/services/quote_pdf.py + the inspect_xero_quote_pdf command emitting the single JSON line the spec parses; the file survives every diagnostic path.

Frontend

  • features/job/costing/CostLineGrid.tsx — the one cost-line grid (estimate/actual arrive later as prop configs), on TanStack Table v8 with module-constant column defs (per-render defs would remount and blur every input). Full day-one selector contract: .smart-costlines-table, exactly one trailing phantom tbody row, SmartCostLinesTable-*/DataTable-row-*/data-grid-* from the visual index, ItemSelect-option-*, trigger named Select Item only when unbound.
  • useAutosaveField derives its display value (local buffer only while editing) — an effect-synced copy provably kept rejected input on screen when an optimistic write and its rollback coalesced into one render. 600ms debounce, blur flushes and cancels, deliberately no If-Match on cost-line CRUD (v1 parity).
  • JobQuoteTab + XeroQuoteCard: server-owned summary (ADR 0046), ping-gated create, "Export Quote to Xero" dialog (Send Total Only / Send Breakdown), open-in-Xero with noopener, delete. Lazy-loaded.
  • Deferred with attributes already in place: keyboard-nav behaviour, duplicate-line, unit-rev override bookkeeping, data-freshness polling.

Spec port

Recorded deviations from v1's spec: own job instead of the read-only sharedEditJobUrl fixture; no in-spec ping (global setup fails the run closed); waitForAutosave instead of 800ms sleeps; PDF inspector spawned via uv run python. The repair machinery ports near-verbatim — it is what exercises the grid every run (the fresh job's material line lacks a stock binding).

Review

Adversarial 2-subagent review pre-PR; all four backend should-fixes and the frontend blocker + should-fixes applied with regression tests (draft-POST failure recovery, same-value retry after rollback). Declined with reasons in the commit: the compact-match "tightening" (equivalent in power to the existing check) and the readonly totals type change (2b precedent).

Verification: 1708 unit tests, coverage 88.46%, job-xero-quote green live, full E2E gate green twice (before and after review fixes).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Quote workspace to job details for managing cost lines, labour, stock, pricing, and autosaving changes.
    • Added Xero quote creation with total-only or detailed line breakdown options.
    • Added options to open quotes in Xero and delete existing quotes.
    • Added job quote retrieval through the API, including quote numbers and summary details.
    • Added quote PDF inspection for validating terms, branding, page count, and extracted text.
  • Bug Fixes
    • Improved handling of missing invoice totals and clearer document deletion errors.
  • Documentation
    • Updated API and rewrite progress documentation for quote functionality.

corrinand others added 10 commits August 9, 2026 15:30
…otocol
QuotePayload carries required terms (Xero applies no default to API-created
quotes) and an expiry date; QuotePdfDocument hands the caller a temp file it
owns. Live provider mirrors the invoice shapes: constructed edit-URL, DELETED
upsert after a contact/date pre-read, PDF download that raises rather than
returning a partial result. Readonly fabricates QU-E2E-* results and refuses
the PDF download outright — a fabricated file would satisfy the text
assertion against nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… mirror
Expected refusals (already quoted, T&M pricing, empty quote cost set, blank
breakdown descriptions, missing theme/terms config) return typed 400 values
with the provider never called; unexpected failures persist once and re-raise
per the base contract. Total-only mode sends the cost-set summary revenue as
a single line; breakdown mode one sanitised line per cost line. The job's
updated_at bumps in-request so the tab refetch sees quoted=true. The
duplicated _create_job_event hoisted from invoice.py into the base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /xero/create_quote/{job_id} (body: breakdown) and DELETE
/xero/delete_quote/{job_id} mirror the invoice handlers; delete takes no id
parameter because a job holds at most one quote. GET /job/jobs/{id}/quote/
serves the Xero quote header or null — a plain GET, not v1's conditional-GET:
nothing external holds the URL and 304-with-empty-body reads as no-quote to
an axios consumer (ledger entry to follow with the slice docs).
XeroDocumentSuccessResponse gains nullable quote_id beside invoice_id; the
two xero operation renames are recorded in the work-list ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the v1 inspection: pypdf text extraction over non-blank pages,
space-normalised AND compact matching (Xero's text layer wraps mid-phrase
and sometimes drops word spaces), blank render raises rather than reporting
the marker absent, and the temp file survives every failure path for
diagnosis. The command emits exactly one sorted-keys JSON line — the
subprocess contract the E2E quote spec parses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-exports the cost-line/labour-rates/stock-search factories and the three
new quote operations through the api boundary. Adds @tanstack/react-table
and the shadcn popover + command primitives (installed, not hand-written).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TanStack Table v8 with module-constant column defs — rebuilding them per
render changes every cell component's identity and remounts (blurring) all
inputs, so cells reach live state through table meta instead. Day-one
contract: .smart-costlines-table, exactly one trailing phantom tbody row,
SmartCostLinesTable-*/DataTable-row-*/data-grid-* attributes derived from
the visual index, ItemSelect with labour-first options.
useAutosaveField DERIVES its display value (local buffer only while
editing): an optimistic write and its failure rollback can land between two
renders, so an effect keyed on the server value never fires — the unit net
caught a synced copy keeping rejected input on screen. 600ms debounce,
blur flushes and cancels, no If-Match on cost-line CRUD (deliberate, v1
parity). All failures toast; the tests fail on any console.error, matching
the E2E guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quote workspace: editable quote cost set, server-owned summary card
(ADR 0046), and the Xero card (ping-gated create, Export Quote to Xero
dialog with Send Total Only / Send Breakdown, open-in-Xero with noopener,
delete). Lazy-loaded from the job detail page.
The retrieve contract changed to {quote: QuoteOut | null}: the unit net
caught the generated axios client coercing a bare JSON null body to {},
which read as an existing quote and crashed the card — an envelope is the
only shape that round-trips absence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…check
Deliberate deviations from the v1 spec, each with its reason in place: a
dedicated job instead of the read-only sharedEditJobUrl fixture; no in-spec
Xero ping (global setup fails the run closed); waitForAutosave instead of
800ms sleeps around the 600ms debounce; and the PDF inspector spawns via
uv run python since nothing guarantees an activated interpreter under npm.
The repair machinery (Select Item pick, desc/unit-rev fills) ports
near-verbatim — it is the part of the spec that exercises the grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st states
Backend: the concurrent-push loser now voids its orphan Xero quote (under a
savepoint, so the compensation can persist its AppError) and refuses with
the readable 400; a quote deleted Xero-side returns a typed 404 from the
provider pre-read and the manager treats it as cleanup-allowed instead of
bricking the job; the local mirror stores the payload's date (midnight
span); present-but-null totals get the crafted message in both quote and
invoice managers; the not-found PDF keeps its file for diagnosis;
delete_invoice passes error_type through like its quote sibling.
Frontend: a failed draft POST clears the persisting guard so the row stays
retryable (was permanently bricked); draft inputs disable while the create
is in flight; the send-dedupe only skips a KNOWN-applied value so a rejected
edit can be retried; the quote card renders pending as pending, not as
create-state; error states stop masquerading as data (picker, ping,
summary); background-refetch errors keep the working grid; null profit
margin renders as a dash, not 0.0%; PATCH rollback reverts only the patched
fields against the current cache, with in-flight refetches cancelled first.
Spec: isEnabled guard before repairing a rev input; response schema
validates quote_id/online_url. Declined: the compact-match tightening (the
proposed regex is equivalent in power — its own counterexample defeats
both) and the readonly totals type change (2b precedent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@corrin, you've reached your PR review limit, so we couldn't start this review.

Next review available in:23 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f0ec6174-067a-4295-8ee5-20d5269c5cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 9ae11da and e19f4e6.

📒 Files selected for processing (11)
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/services/quote_pdf.py
  • docs/code-quality.md
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
📝 Walkthrough

Walkthrough

Added end-to-end Xero quote support across accounting providers, quote APIs, PDF inspection, job retrieval, frontend costing, and quote management UI. Added tests, OpenAPI updates, compatibility mappings, and E2E validation.

Changes

Xero quote backend

Layer / File(s)Summary
Provider contracts and integrations
apps/accounting/types.py, apps/accounting/provider.py, apps/xero/provider.py, apps/xero/readonly_provider.py, stubs/xero_python/..., apps/xero/tests/test_provider_documents.py
Added quote payload and PDF types, provider operations, Xero quote create/delete/PDF support, readonly behavior, and SDK stubs.
Quote manager and API lifecycle
apps/xero/documents/quote.py, apps/xero/api.py, apps/xero/documents/base.py, apps/xero/tests/test_quote_manager.py, apps/xero/tests/test_document_api.py
Added quote validation, payload construction, persistence, compensation, deletion, audit events, authenticated endpoints, and lifecycle tests.
Quote PDF inspection tooling
apps/accounting/services/quote_pdf.py, apps/accounting/management/commands/..., apps/accounting/tests/test_quote_pdf.py
Added PDF text inspection, branding and page metadata, diagnostic-file handling, and JSON command output.
Job quote retrieval contract
apps/job/api.py, apps/job/schemas.py, apps/job/services/job_service.py, frontend/schema.v2.yml, frontend/src/api/index.ts
Added nullable enveloped quote retrieval with quote-number serialization and matching API schemas.
Cost-line editing workspace
frontend/src/features/job/costing/*, frontend/src/components/ui/*, frontend/package.json
Added cost-line types, calculations, autosave, optimistic CRUD, item selection, editable grid behavior, and supporting UI primitives.
Quote tab and Xero card
frontend/src/features/job/JobDetailPage.tsx, frontend/src/features/job/costing/JobQuoteTab.tsx, frontend/src/features/job/costing/XeroQuoteCard.tsx
Added the lazy-loaded quote tab, quote summary, cost grid, Xero quote creation modes, deletion, deep links, and mutation states.
End-to-end validation and status support
frontend/tests/e2e/job/job-xero-quote.spec.ts, docs/rewrite-status.md, docs/accepted-api-differences.yml, scripts/v1-frontend-operations.yml
Added E2E quote creation and PDF checks, diagnostic repair logic, status updates, API difference documentation, and operation mappings.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant User
participant JobQuoteTab
participant XeroQuoteCard
participant QuoteAPI
participant Xero
User->>JobQuoteTab: Open the quote tab
JobQuoteTab->>XeroQuoteCard: Load quote state
User->>XeroQuoteCard: Select quote mode and submit
XeroQuoteCard->>QuoteAPI: Create quote request
QuoteAPI->>Xero: Create quote
Xero-->>QuoteAPI: Return quote identifiers and totals
QuoteAPI-->>XeroQuoteCard: Return quote response
XeroQuoteCard-->>User: Display quote link and status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 32.57% 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 Xero quote path, cost-line grid, and related end-to-end test result.
Description check✅ PassedThe description thoroughly covers implementation details, scope, deviations, review outcomes, and verification results, despite omitting 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-path

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: 8

🧹 Nitpick comments (10)
frontend/src/features/job/costing/useCostLines.ts (1)

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

Document why deleteLine restores the whole snapshot.

patchLine carries a comment that rejects the wholesale snapshot restore and explains the interleaved-write constraint. deleteLine then uses that exact rejected strategy at Line 134 with no comment. A failed delete restores the full pre-delete cost set, so it also reverts any successful interleaved patch on another line until the onSettled refetch lands. Record the constraint that makes the wholesale restore correct here, for example that a removed row cannot be reconstructed field-by-field.

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 `@frontend/src/features/job/costing/useCostLines.ts` around lines 121 - 140,
Update deleteLine’s onError rollback comment to document why restoring the
entire snapshot is intentional: unlike patchLine, a deleted row cannot be
reconstructed field-by-field, so a wholesale restore is required despite
potentially reverting interleaved writes until invalidate refetches.

Source: Coding guidelines

frontend/src/features/job/costing/calc.test.ts (1)

123-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the two unresolved-reference fallbacks in itemLabel.

The suite does not exercise the fallback at calc.ts Line 79 (labour rate not in the list) or Line 86 (stock id not in the loaded page). The comment on calc.ts Line 85 states that the 'Stock item' fallback must not read as 'Select Item', because the E2E repair loop counts buttons by that exact name. A regression that returns 'Select Item' for an unresolved stock id would pass this suite and fail only in Playwright. Add the two cases.

🧪 Proposed additional cases
 it('names the labour subtype for a time line', () => {
expect(itemLabel(line({ kind: 'time', labour_subtype: 'workshop' }), stockById, rates)).toBe(
'Workshop',
)
})
++ it('falls back to the raw subtype when the rate is not loaded', () => {+ expect(itemLabel(line({ kind: 'time', labour_subtype: 'nightshift' }), stockById, rates)).toBe(+ 'nightshift',+ )+ })++ it('never reads as "Select Item" when bound to unloaded stock', () => {+ expect(itemLabel(line({ ext_refs: { stock_id: 'stock-unloaded' } }), stockById, rates)).toBe(+ 'Stock item',+ )+ })
})
🤖 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/calc.test.ts` around lines 123 - 140, Add
two `itemLabel` tests covering unresolved references: verify a time line with a
labour subtype absent from `rates` uses the labour-rate fallback, and verify a
bound line whose stock ID is absent from `stockById` returns the distinct “Stock
item” fallback rather than “Select Item”.
frontend/src/features/job/costing/CostLineGrid.test.tsx (1)

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

Annotate the labourRates and stockPage fixtures with the generated types.

materialLine and costSet are annotated, so a wire-type change breaks this file at compile time. labourRates and stockPage are bare literals, so the same change passes type-checking and fails only at runtime. calc.test.ts annotates the equivalent fixtures as JobLabourRateOut and StockItem. Add const labourRates: JobLabourRateOut[] and the generated page type for stockPage.

🤖 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.test.tsx` around lines 67 -
108, Annotate the labourRates fixture as JobLabourRateOut[] and annotate
stockPage with the generated page type used for StockItem results, matching the
equivalent fixtures in calc.test.ts. Preserve the existing fixture values while
ensuring wire-type changes are caught during compilation.
frontend/src/features/job/costing/ItemSelect.tsx (1)

88-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider debouncing the stock search input.

CommandInput calls onValueChange={setSearch} directly, and search is part of the purchasingStockSearchRetrieveOptions query key. Every keystroke while the popover is open fires a new server-side stock search request. The component's own comment notes that queries under 3 characters list everything, so short inputs during typing can each trigger a full unfiltered fetch.

Debounce the value passed to the query (for example with a small useDeferredValue or timer-based hook) so the request fires once typing pauses.

🤖 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/ItemSelect.tsx` around lines 88 - 92,
Debounce the search value used by the stock query in ItemSelect rather than
passing the raw search state into purchasingStockSearchRetrieveOptions on every
keystroke. Keep CommandInput responsive with the immediate value, and use a
small deferred or timer-based value so requests occur after typing pauses while
preserving the existing short-query behavior.
frontend/src/features/job/costing/XeroQuoteCard.test.tsx (1)

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

Use the user returned by renderWithProviders.

renderWithProviders already calls userEvent.setup() and returns the instance. Each test creates a second instance. Two sessions can hold separate pointer and keyboard state. Reuse the returned user in all five tests.

- const user = userEvent.setup()- renderWithProviders(<XeroQuoteCard jobId="job-1" />)+ const { user } = renderWithProviders(<XeroQuoteCard jobId="job-1" />)
🤖 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/XeroQuoteCard.test.tsx` around lines 72 -
73, Update all five tests in XeroQuoteCard.test.tsx to destructure and reuse the
user instance returned by renderWithProviders, removing each redundant
userEvent.setup() call while preserving the existing test interactions.
frontend/src/features/job/costing/CostLineGrid.tsx (1)

192-199: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Move the create call out of the setDrafts updater.

commitDraftField runs persistDraftIfReady inside a state updater. That updater executes during render. It starts a network mutation and calls syncPersisting (another setState) from render. React requires updaters to be pure. The persistingRef guard hides the StrictMode double invocation today, but any future replay of the updater still re-enters impure code.

Keep the latest drafts in a ref and run the persistence after the state update.

♻️ Proposed refactor: read drafts from a ref instead of a state updater
 const [drafts, setDrafts] = useState<DraftRow[]>([freshPhantom()])
+ // persistDraftIfReady must see the same render's updateDraft result, but a+ // network call inside a state updater is not a pure update; the ref carries+ // that latest value out of the updater instead.+ const draftsRef = useRef<DraftRow[]>(drafts)+ draftsRef.current = drafts
@@
updateDraft: (localId, patch) => {
setDrafts((current) => {
...
- return next+ draftsRef.current = next+ return next
})
},
commitDraftField: (localId) => {
- setDrafts((current) => {- persistDraftIfReady(current, localId)- return current- })+ persistDraftIfReady(draftsRef.current, localId)
},
🤖 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 192 - 199,
Refactor commitDraftField so persistDraftIfReady is no longer called inside the
setDrafts updater; keep the latest drafts synchronized in a ref and invoke
persistence after the state update using that ref. Ensure the setDrafts updater
remains pure while preserving the existing localId persistence behavior and
syncPersisting flow.
apps/xero/tests/test_provider_documents.py (2)

431-438: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the 404 status code, not only the error text.

XeroQuoteManager.delete_document (apps/xero/documents/quote.py, line 311) branches on result.status_code == 404 to clean up the local row when the quote is already gone from Xero. The error string is not part of that contract; the status code is. This test passes today even if delete_quote returns status 500 for an absent quote, which would brick the recovery path.

♻️ Proposed assertion
 result = provider.delete_quote(str(uuid.uuid4()))
assert not result.success
assert result.error is not None and "no quote" in result.error
+ # The manager keys its local-cleanup branch on this code, not the text.+ assert result.status_code == 404
🤖 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_provider_documents.py` around lines 431 - 438, Update
test_missing_quote_is_an_error_result to assert that the failed delete_quote
result has status_code 404, preserving the existing unsuccessful-result check
while treating the status code—not the error text—as the contract for a missing
quote.

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

Tighten the match, or drop "persists" from the test name.

match="quote" matches every ValueError this method raises, including the "returned N quotes" guard. The name promises a persistence assertion that the body does not make.

♻️ Proposed change
- with pytest.raises(ValueError, match="quote"):+ with pytest.raises(ValueError, match="for a request naming"):
provider.download_quote_pdf(str(uuid.uuid4()))
🤖 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_provider_documents.py` around lines 462 - 467, Align
test_quote_id_mismatch_raises_and_persists with its actual coverage: either
assert the specific quote-ID mismatch error message rather than the broad
“quote” match, and add the intended persistence assertion, or rename the test to
remove “persists” if persistence is not being verified.
apps/xero/tests/test_quote_manager.py (1)

170-197: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cover the unvoidable-orphan branch.

This test covers the race loser whose compensating void succeeds. The branch at apps/xero/documents/quote.py lines 216-224 is untested: when delete_quote also fails, the manager raises a ValueError carrying the orphan quote id. That id is the only record an operator has, because the AppError row cannot carry it. A regression that swallowed this raise would leave a real Xero quote orphaned with no trace.

♻️ Proposed companion test
+ def test_race_loser_raises_when_the_orphan_cannot_be_voided(+ self, company: Company, job: Job, office_staff: Staff+ ) -> None:+ """An unvoidable orphan must reach an operator with its Xero id."""+ provider = Mock()+ provider.get_account_code.return_value = "200"+ orphan = _success_result()++ def concurrent_winner_lands_first(payload: object) -> DocumentResult: # noqa: ARG001+ _existing_quote(job, company)+ return orphan++ provider.create_quote.side_effect = concurrent_winner_lands_first+ provider.delete_quote.return_value = DocumentResult(+ success=False, error="Quote is ACCEPTED", status_code=400+ )+ manager = _manager(company, job, office_staff, provider)++ with pytest.raises(ValueError, match=str(orphan.external_id)):+ manager.create_document(breakdown=False)++ assert Quote.objects.count() == 1 # only the winner's row
🤖 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 170 - 197, Add a
companion test for the race-loser path in create_document where
provider.delete_quote fails after Quote.objects.create is rejected; assert the
manager raises ValueError containing the orphan Xero quote id, while preserving
the existing assertions for the successful compensation case.
stubs/xero_python/accounting/__init__.pyi (1)

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

Add the Quote fields the provider sets.

apps/xero/provider.py (lines 271-272) passes line_amount_types and currency_code into Quote(...). The stub does not declare either attribute. The call type-checks today only because __init__ accepts **kwargs: Any. Any later attribute read of those fields would fail under strict mypy.

♻️ Proposed stub additions
 class Quote:
quote_id: str | None
quote_number: str | None
contact: Contact | None
date: Any
expiry_date: Any
status: str | None
line_items: list[LineItem] | None
+ line_amount_types: str | None+ currency_code: str | None
branding_theme_id: str | None
terms: str | None
reference: str | None
updated_date_utc: Any
🤖 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 `@stubs/xero_python/accounting/__init__.pyi` around lines 80 - 93, Update the
Quote stub by declaring the line_amount_types and currency_code attributes
alongside the existing quote fields, using types consistent with the values
passed by apps/xero/provider.py. Keep the existing constructor and serialization
declarations unchanged.
🤖 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 `@apps/accounting/management/commands/inspect_xero_quote_pdf.py`:
- Line 28: Replace the narration docstring near the command’s
validation/inspection flow in
apps/accounting/management/commands/inspect_xero_quote_pdf.py:28-28 with either
no comment or rationale explaining the rejected behavior and the subprocess
contract requiring the selected behavior. Update the comment in
frontend/tests/e2e/job/job-xero-quote.spec.ts:139-140 to explain that treating
the phantom row as a persisted cost line would create invalid repair work
because it has no persisted cost-line data.
In `@apps/accounting/services/quote_pdf.py`:
- Around line 32-69: Wrap the PdfReader usage in the inspection flow with a
context manager so its streams close before any cleanup. Ensure the reader is
closed on both successful validation and failure paths, while preserving the
diagnostic PDF when the expected text is absent and allowing unlink after the
reader scope ends.
In `@frontend/src/features/job/costing/calc.ts`:
- Around line 32-44: Update stockPickPatch to explicitly set labour_subtype to
null when constructing the material CostLineUpdateRequest, while preserving the
existing stock and cost field mappings.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 410-419: Update the onPickStock handler to preserve null for
missing patch.unit_cost and patch.unit_rev instead of converting absent values
to empty strings. Ensure the resulting DraftLine remains incomplete when either
price is unavailable, and omit null price fields when constructing the create
PATCH request.
In `@frontend/src/features/job/costing/useAutosaveField.ts`:
- Around line 52-64: Update dispatch to compare against a live serverValue ref
rather than the render-captured serverValue when the debounce callback runs.
Keep the existing knownApplied and untouched deduplication behavior, and ensure
the ref is synchronized with the latest server value before dispatch evaluates
parsed.
In `@frontend/src/features/job/costing/XeroQuoteCard.tsx`:
- Around line 170-190: Update the button label logic in the XeroQuoteCard
component to distinguish ping.isPending from a genuinely disconnected
xeroConnected state. While the Xero connection check is pending, show a
checking/loading label and prevent the logged-out “Login to Xero first” message;
retain the existing labels once the check resolves.
- Around line 81-95: The executeDelete handler currently deletes the quote
without confirmation. Update executeDelete to show a visible confirmation
dialog, such as window.confirm with a clear deletion message, and only call
deleteQuote.mutate when the user confirms; preserve the existing pending guard
and success/error callbacks.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Line 173: Update the AccountingApi quote PDF call to use the method name
provided by xero-python==15.0.0 instead of get_quote_as_pdf, avoiding the
AttributeError. If a local get_quote_as_pdf implementation is retained, pass
headers={"Accept": "application/pdf"} and write its response to a temporary file
before applying the return-type check, rather than treating the raw response as
a path.
---
Nitpick comments:
In `@apps/xero/tests/test_provider_documents.py`:
- Around line 431-438: Update test_missing_quote_is_an_error_result to assert
that the failed delete_quote result has status_code 404, preserving the existing
unsuccessful-result check while treating the status code—not the error text—as
the contract for a missing quote.
- Around line 462-467: Align test_quote_id_mismatch_raises_and_persists with its
actual coverage: either assert the specific quote-ID mismatch error message
rather than the broad “quote” match, and add the intended persistence assertion,
or rename the test to remove “persists” if persistence is not being verified.
In `@apps/xero/tests/test_quote_manager.py`:
- Around line 170-197: Add a companion test for the race-loser path in
create_document where provider.delete_quote fails after Quote.objects.create is
rejected; assert the manager raises ValueError containing the orphan Xero quote
id, while preserving the existing assertions for the successful compensation
case.
In `@frontend/src/features/job/costing/calc.test.ts`:
- Around line 123-140: Add two `itemLabel` tests covering unresolved references:
verify a time line with a labour subtype absent from `rates` uses the
labour-rate fallback, and verify a bound line whose stock ID is absent from
`stockById` returns the distinct “Stock item” fallback rather than “Select
Item”.
In `@frontend/src/features/job/costing/CostLineGrid.test.tsx`:
- Around line 67-108: Annotate the labourRates fixture as JobLabourRateOut[] and
annotate stockPage with the generated page type used for StockItem results,
matching the equivalent fixtures in calc.test.ts. Preserve the existing fixture
values while ensuring wire-type changes are caught during compilation.
In `@frontend/src/features/job/costing/CostLineGrid.tsx`:
- Around line 192-199: Refactor commitDraftField so persistDraftIfReady is no
longer called inside the setDrafts updater; keep the latest drafts synchronized
in a ref and invoke persistence after the state update using that ref. Ensure
the setDrafts updater remains pure while preserving the existing localId
persistence behavior and syncPersisting flow.
In `@frontend/src/features/job/costing/ItemSelect.tsx`:
- Around line 88-92: Debounce the search value used by the stock query in
ItemSelect rather than passing the raw search state into
purchasingStockSearchRetrieveOptions on every keystroke. Keep CommandInput
responsive with the immediate value, and use a small deferred or timer-based
value so requests occur after typing pauses while preserving the existing
short-query behavior.
In `@frontend/src/features/job/costing/useCostLines.ts`:
- Around line 121-140: Update deleteLine’s onError rollback comment to document
why restoring the entire snapshot is intentional: unlike patchLine, a deleted
row cannot be reconstructed field-by-field, so a wholesale restore is required
despite potentially reverting interleaved writes until invalidate refetches.
In `@frontend/src/features/job/costing/XeroQuoteCard.test.tsx`:
- Around line 72-73: Update all five tests in XeroQuoteCard.test.tsx to
destructure and reuse the user instance returned by renderWithProviders,
removing each redundant userEvent.setup() call while preserving the existing
test interactions.
In `@stubs/xero_python/accounting/__init__.pyi`:
- Around line 80-93: Update the Quote stub by declaring the line_amount_types
and currency_code attributes alongside the existing quote fields, using types
consistent with the values passed by apps/xero/provider.py. Keep the existing
constructor and serialization declarations unchanged.
🪄 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: 6120c3fb-75ad-489f-9410-9c2daf5d7eaa

📥 Commits

Reviewing files that changed from the base of the PR and between cd24a0f and 9ae11da.

⛔ Files ignored due to path filters (6)
  • frontend/package-lock.json is excluded by !**/package-lock.json
  • frontend/src/api/generated/@tanstack/react-query.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/index.ts is excluded by !**/generated/**
  • frontend/src/api/generated/sdk.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/types.gen.ts is excluded by !**/generated/**
  • frontend/src/api/generated/zod.gen.ts is excluded by !**/generated/**
📒 Files selected for processing (45)
  • apps/accounting/management/__init__.py
  • apps/accounting/management/commands/__init__.py
  • apps/accounting/management/commands/inspect_xero_quote_pdf.py
  • apps/accounting/provider.py
  • apps/accounting/services/quote_pdf.py
  • apps/accounting/tests/test_quote_pdf.py
  • apps/accounting/types.py
  • apps/job/api.py
  • apps/job/schemas.py
  • apps/job/services/job_service.py
  • apps/job/tests/test_job_api.py
  • apps/xero/api.py
  • apps/xero/documents/base.py
  • apps/xero/documents/invoice.py
  • apps/xero/documents/quote.py
  • apps/xero/provider.py
  • apps/xero/readonly_provider.py
  • apps/xero/tests/test_document_api.py
  • apps/xero/tests/test_provider_documents.py
  • apps/xero/tests/test_quote_manager.py
  • docs/accepted-api-differences.yml
  • docs/rewrite-status.md
  • frontend/package.json
  • frontend/schema.v2.yml
  • frontend/src/api/index.ts
  • frontend/src/components/ui/command.tsx
  • frontend/src/components/ui/popover.tsx
  • frontend/src/features/job/JobDetailPage.tsx
  • frontend/src/features/job/costing/CostLineGrid.test.tsx
  • frontend/src/features/job/costing/CostLineGrid.tsx
  • frontend/src/features/job/costing/ItemSelect.tsx
  • frontend/src/features/job/costing/JobQuoteTab.test.tsx
  • frontend/src/features/job/costing/JobQuoteTab.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.test.tsx
  • frontend/src/features/job/costing/XeroQuoteCard.tsx
  • frontend/src/features/job/costing/calc.test.ts
  • frontend/src/features/job/costing/calc.ts
  • frontend/src/features/job/costing/types.ts
  • frontend/src/features/job/costing/useAutosaveField.ts
  • frontend/src/features/job/costing/useCostLines.ts
  • frontend/src/lib/format.ts
  • frontend/src/test/setup.ts
  • frontend/tests/e2e/job/job-xero-quote.spec.ts
  • scripts/v1-frontend-operations.yml
  • stubs/xero_python/accounting/__init__.pyi

Comment threadapps/accounting/services/quote_pdf.py Outdated
Comment threadfrontend/src/features/job/costing/calc.ts
Comment threadfrontend/src/features/job/costing/CostLineGrid.tsx
Comment threadfrontend/src/features/job/costing/useAutosaveField.ts
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadfrontend/src/features/job/costing/XeroQuoteCard.tsx
Comment threadstubs/xero_python/accounting/__init__.pyi
corrinand others added 2 commits August 9, 2026 18:13
Movements all belong to this slice: two call-time manager imports and test
fixtures (PLC0415), the provider's deliberate exception-to-result
conversions (BLE001, returns-instead), the TableMeta module augmentation
(eslint-disable), and the new manager/provider try shapes. Passthrough
stays pinned at zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est states
Close the PdfReader before any unlink (a held stream fails deletion on
Windows); a stock pick clears labour_subtype so a converted time line
carries no stale subtype; the draft item-pick preserves absence as null
instead of '' (an empty string would satisfy the persist-ready check);
the debounce dispatch compares against the live server value via a ref;
quote deletion asks for confirmation like a cost-line delete; a pending
Xero ping reads 'Checking Xero…' instead of the logged-out label; two
comments now state their rejected alternatives. Declined with evidence in
the thread: the claim that xero-python 15.0.0 lacks get_quote_as_pdf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@corrin
corrin merged commit cd5943d into mainAug 9, 2026
3 checks passed
corrin added a commit that referenced this pull request Aug 9, 2026
* Quote hardening B1-B5: explicit validation, full compensation, unbricked 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>
* One document-endpoint adapter: auth refusal, failure map, success build
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>
* Grid hardening F1-F6 + assertive spec: drafts persist, retries retry
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>
* Record the deferred structure work; regenerate metrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Persist the collision at its catch site; refresh derived rows
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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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