Skip to content

KAN-323: Finish Job workspace with GST-inclusive customer balance - #511

Merged
corrin merged 8 commits into
mainfrom
KAN-323-build-finish-job-workspace-with-gst-inclusive-customer-balance
Aug 1, 2026
Merged

KAN-323: Finish Job workspace with GST-inclusive customer balance#511
corrin merged 8 commits into
mainfrom
KAN-323-build-finish-job-workspace-with-gst-inclusive-customer-balance

Conversation

@corrin

@corrin corrin commented Aug 1, 2026

Copy link
Copy Markdown
Owner

KAN-323 — also closes KAN-222.

Gives office staff one place to answer the counter question: what does this customer pay, including GST.

What changed

Cost Analysis becomes Finish Job. The tab leads with a server-owned customer balance — subtotal, GST, unpaid invoices, Total to pay — then the invoice card (moved out of Actual into JobInvoiceCard.vue), then a five-item completion checklist, then the cost comparison retained underneath. Actual keeps materials, adjustments and cost entry.

The balance is calculated server-side (apps/accounting/services/finish_job_summary.py), reusing the existing invoicing basis rather than reimplementing it. It reports over-invoicing as a separate diagnostic instead of netting it off, and offers no invoice action when a job is fully covered rather than raising a ceremonial $0 invoice.

CompanyDefaults.gst_rate is new — GST on an uninvoiced amount had no existing source. The cosmetic constant in the Xero read-only provider now reads it.

Completion checklist as five booleans on Job, declared once in Job.COMPLETION_CHECKLIST_FIELDS. Each tick writes a job-history event through the existing _FIELD_HANDLERS machinery. The dead Job.collected field is renamed to released — it was never writable, so no data carries forward. Deliberately advisory: tests pin that ticking nothing still lets a job be invoiced.

KAN-222 labour budget / used / remaining-or-overrun, in the component being rewritten anyway.

Duplication removed

pricing_methodology was branched on four times to answer "what is this job worth". There is now one get_job_invoicing_basis, and get_job_total_value and recalculate_job_invoicing_state both call it — which is why fully_invoiced no longer ignores a price cap the invoice respects. useJobFinancials.ts is deleted: a third copy of the same sum in the browser that counted VOIDED invoices and ignored the cap.

Bugs fixed

  • Job.DoesNotExist was re-raised as ValueError inside an except clause, which a sibling clause cannot catch — every job view answered 500 for a missing job. Now 404, tested.
  • A failed balance load rendered "Total to pay $0.00 — Nothing left to pay. This job is fully invoiced and settled." Now shows an error and no figures.

Verified

1123 backend tests, 432 frontend tests, check_mypy.sh clean (baseline shrank by 25), type-check and lint clean.

Not verified: frontend/tests/job/job-xero-invoice.spec.ts was retargeted to the Finish Job tab and strengthened, but has never been executed — it needs Vite and runserver.

Deferred

Job.collected semantics beyond the rename, cash-vs-account archive conditions, inferred collection after seven days, paid-state sync, archived-job compliance. Separately: the remaining cost-set totals duplication (six representations of the same number) is planned as its own ticket.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ACLSprAHJSeQErMxQqLKDH

Summary by CodeRabbit

  • New Features
    • Added a Finish Job workspace with server-calculated balances, invoice details, labour-hour metrics, and invoicing actions.
    • Added a completion checklist with saved updates and history tracking.
    • Added configurable GST rates for financial calculations.
  • Improvements
    • Standardized invoice calculations, including price caps and over-invoicing.
    • Moved invoice management from Actuals to Finish Job.
  • Documentation
    • Documented the Finish Job API endpoint and related fields.

corrin and others added 6 commits August 1, 2026 09:26
DocketWorks needs a sales-tax rate to quote amounts that Xero has not yet
invoiced (the Finish Job remaining balance). The only rate in the codebase was
a cosmetic constant in the read-only Xero provider, which meant no authoritative
source and two places to change.

gst_rate is a per-client tunable on CompanyDefaults (0.1500 = 15% NZ GST) and
the read-only provider now reads it, so the system has one rate. The dynamic
settings UI picks the field up from COMPANY_DEFAULTS_FIELD_SECTIONS, so it is
editable without a hand-written input.

KAN-323

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACLSprAHJSeQErMxQqLKDH
…cklist

The counter question — what does this customer owe, including GST — had no
authoritative answer. JobActualTab computed "to be invoiced" in the browser from
a quote total and an invoice list, which is a business calculation in the
presentation layer (ADR 0020) and silently ignored what Xero still expects to be
paid.

The summary service answers it server-side: job value on its own pricing basis,
valid invoiced, outstanding Xero balances, the positive remaining amount, GST on
it, and Total to pay. Over-invoicing is reported as a separate diagnostic rather
than netted off, so a T&M job whose actuals came in under its invoices shows the
excess instead of hiding it behind a smaller balance.

The job-value basis, price cap and valid-invoice statuses are extracted from
invoice_calculation rather than reimplemented, so the amount a user can invoice
and the amount they are shown cannot drift apart.

Only the basis cost set is loaded, with its cost lines prefetched: the n+1 guard
caught both a query-per-cost-line and, once fixed, an eager load of the cost set
the summary never reads. A query-count test pins it.

The completion checklist is deliberately advisory — tests pin that confirming
items changes neither job status nor invoice eligibility. Each changed item
writes one job-history event, including withdrawals, which is the change most
worth finding later.

Also drops .__func__ from the JobEvent description registry: staticmethod objects
have been directly callable since 3.10, and removing it clears 23 baselined mypy
errors instead of adding a 24th.

KAN-323

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACLSprAHJSeQErMxQqLKDH
Cost Analysis was a read-only comparison table almost nobody opened, while
Actual mixed cost entry with the invoice controls. Neither answered the question
staff actually have at the counter with the customer standing there: what do
they pay, including GST.

Finish Job is that one place. It leads with the server-owned customer balance —
subtotal, GST, unpaid invoices, Total to pay — then the invoice card, modal,
history, Xero links and deletion moved intact from Actual, then the completion
checklist, then the cost comparison retained in compact form underneath.

Actual keeps materials, adjustments, its summary and detailed cost entry. Its
Invoiced and To Be Invoiced chips are gone, along with the toBeInvoiced
computation behind them: invoice figures now have exactly one authoritative
home, and no quote-minus-invoices arithmetic happens in the browser.

Behaviour changes worth naming:
- a fully covered job offers no invoice action at all, instead of an
  always-visible button that would raise a $0 invoice;
- invoice wording follows the stage of work — advance/deposit/progress before
  completion, remaining balance after — without gating invoicing by status;
- the balance is re-read from the server after create and delete rather than
  patched locally, because it is only correct against the current invoice set.

KAN-222 ships here too: labour budget, hours used, and remaining-or-overrun, in
the component that would otherwise have to be reopened next week to add them.

The checklist rollback uses v-model rather than a one-way :checked binding —
with the latter, Vue cannot repaint a value that never changed in state, so a
failed save left a tick showing that the server had rejected.

The invoice E2E now drives Finish Job and additionally asserts the acceptance
criterion the old spec could not see: after invoicing in full, the job shows
nothing remaining and stops asking for another invoice.

KAN-323, KAN-222

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACLSprAHJSeQErMxQqLKDH
A fixed three-column grid put the labour budget, used and remaining figures into
roughly 100px each on a phone, which is where staff read them while standing at
the job. They stack below the sm breakpoint now.

KAN-323

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACLSprAHJSeQErMxQqLKDH
The completion checklist shipped as a separate JobCompletionChecklist table with
a OneToOne back to Job. Being 1:1, that was columns with a join in front of them,
and it cost more than the join: a get_completion_checklist() that returned an
unsaved instance for jobs with no row yet, updated_at/updated_by columns that
recorded who last touched the checklist rather than who ticked what, and a
service that hand-wrote a JobEvent per item.

Job already does all of that. _FIELD_HANDLERS turns any tracked field change into
a job-history event, which is where "Marked as paid" comes from. So the items are
Job booleans now, the table and its service are gone, and the per-item audit is
the machinery that was already there.

The items also changed to the questions the front desk actually works through:
foreman signed off, timesheets collected, materials checked, customer called,
released. Same five on every job — urgency differs between T&M and quoted work,
but that is a note under the list, not a branch in the schema, so the
conditional rendering goes too.

collected is renamed to released rather than replaced. It meant the same thing
and was never writable — no serializer, endpoint, admin or UI ever set it — so
every row holds the default and the rename carries nothing stale forward. This
also drops customer_approval_confirmed: a customer is not handed work they have
just rejected, so the tick is implied by release for a collection, and for a
delivery it cannot be answered at the moment it would be ticked.

Migration 0010 is replaced rather than added to, since nothing has been pushed.

KAN-323

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACLSprAHJSeQErMxQqLKDH
…alance

Review found this PR re-implementing totals logic the app already had, and
presenting invented figures as fact. Both are fixed by deletion.

pricing_methodology was being branched on four times to answer one question —
what is this job worth. calculate_invoice_amount dispatched on it and then the
handlers asked again via two functions this PR had added; get_job_total_value
had its own copy reading the summary["rev"] float mirror; and
recalculate_job_invoicing_state had a fourth, which is how fully_invoiced could
ignore a price cap the invoice itself respects. There is now one
get_job_invoicing_basis and all of them call it, so a job cannot be reported at
one value and invoiced at another. A test pins the agreement, and another pins
the price cap the old reporting path dropped.

Three more things existed already and were being duplicated:
get_job_for_invoice_calculation (this PR had written a second loader),
Invoice.amount_due (already serialized), and useJobFinancials.ts — a third copy
of the same sum in the browser, which counted VOIDED invoices and ignored the
price cap, so the Rejected-Job warning disagreed with what the job would
actually be invoiced for. It is deleted and that warning now reads the server's
figure.

The basis field went too: it was pricing_methodology with the values relabelled,
returned by the API and never read.

On the frontend, loadAll's catch cleared `loading` while leaving a fabricated
zero summary on screen, so a failed request rendered "Total to pay $0.00 —
Nothing left to pay. This job is fully invoiced and settled." There is no
placeholder now; a failed load says so and shows no figures. Tested.

Job.DoesNotExist was re-raised as ValueError inside an except clause, which a
sibling clause cannot catch, so every job view answered 500 for a missing job.
http_status_for_exception already maps it to 404, so the clauses are deleted
across the file and a test covers it.

Structurally: the invoice card moves to JobInvoiceCard.vue and the checklist
items are declared once as Job.COMPLETION_CHECKLIST_FIELDS, which both
serializers derive from. JobFinishTab is now recognisably a rename of
JobCostAnalysisTab rather than a rewrite of it.

KAN-323

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACLSprAHJSeQErMxQqLKDH
@coderabbitai

coderabbitai Bot commented Aug 1, 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: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

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: 919d64a5-73de-4ff6-b5e0-2a4de13cc86f

📥 Commits

Reviewing files that changed from the base of the PR and between 9992798 and 44f305f.

📒 Files selected for processing (1)
  • frontend/src/components/job/JobInvoiceCard.vue
📝 Walkthrough

Walkthrough

The change adds shared invoicing calculations, a Finish Job API, completion checklist tracking, configurable GST, and frontend invoicing and balance workflows. It also updates related tests, documentation, and static-analysis baselines.

Changes

Finish Job workflow

Layer / File(s) Summary
Shared invoicing basis and GST
apps/accounting/services/..., apps/accounting/tests/..., apps/workflow/..., frontend/schema.yml
Centralizes fixed-price and time-and-materials valuation, price-cap handling, valid invoice totals, Finish Job summaries, and configurable GST rates.
Completion checklist model and service
apps/job/models/..., apps/job/services/..., apps/job/tests/...
Adds five checklist fields, audit descriptions, staff-attributed updates, and shared invoicing-state comparisons.
Finish Job API contract and endpoint
apps/job/serializers/..., apps/job/views/..., apps/job/urls_rest.py, frontend/schema.yml, docs/urls/job.md
Adds GET and PATCH support for server-calculated financial summaries and staff-only checklist updates.
Finish Job frontend workflow
frontend/src/components/job/..., frontend/src/pages/jobs/..., frontend/tests/job/...
Moves invoice workflows to Finish Job, displays server balances, adds checklist persistence and labour-hour metrics, and updates frontend tests.
Test and type maintenance
apps/accounts/tests/..., apps/workflow/tests/..., apps/job/tests/test_mcp_tool_integration.py, mypy-baseline.txt
Updates structured logging assertions, removes obsolete MCP and backup tests, and refreshes mypy diagnostics.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.88% 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 name Status Explanation
Title check ✅ Passed The title clearly describes the main Finish Job workspace change and the GST-inclusive customer balance.
Description check ✅ Passed The description explains the feature, implementation, tests, deferred work, and known E2E limitation in sufficient detail.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check 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 KAN-323-build-finish-job-workspace-with-gst-inclusive-customer-balance

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.

@coderabbitai coderabbitai Bot 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: 3

🧹 Nitpick comments (9)
frontend/src/components/job/__tests__/finishTabFixtures.ts (1)

7-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Type the fixture overrides against the fixture shape.

Record<string, number> and Record<string, boolean> accept any key. A misspelled key in a spec adds a new field instead of overriding one, and the assertion then tests the default value. Use Partial<ReturnType<...>> so the compiler rejects unknown keys.

♻️ Proposed refactor
-export const finishSummary = (overrides: Record<string, number> = {}) => ({
+const FINISH_SUMMARY_DEFAULTS = {
   job_value_excl_gst: 1000,
   valid_invoiced_excl_gst: 400,
   outstanding_invoiced_incl_gst: 460,
   remaining_to_invoice_excl_gst: 600,
   remaining_gst: 90,
   remaining_to_invoice_incl_gst: 690,
   total_to_pay_incl_gst: 1150,
   over_invoiced_excl_gst: 0,
+}
+
+export const finishSummary = (overrides: Partial<typeof FINISH_SUMMARY_DEFAULTS> = {}) => ({
+  ...FINISH_SUMMARY_DEFAULTS,
   ...overrides,
 })

Apply the same pattern to checklistState.

🤖 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/components/job/__tests__/finishTabFixtures.ts` around lines 7 -
26, Update the finishSummary and checklistState fixture factories to type
overrides as Partial<ReturnType<typeof finishSummary>> and
Partial<ReturnType<typeof checklistState>> respectively, while preserving their
existing defaults and spread behavior so unknown override keys are rejected.
frontend/src/components/job/JobInvoiceCard.vue (2)

388-415: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the shortcut casts and type the error payload correctly.

Two type contracts here do not describe the real data:

  • Line 389 casts an incomplete literal with as XeroInvoiceCreateRequest, then adds fields afterwards. Build the request object in one literal so the compiler checks it.
  • Line 410 declares the response body as { message: string }, but the code reads the error key and casts again. Declare the shape the API returns.
♻️ Proposed refactor
-    const body = { mode } as XeroInvoiceCreateRequest
-    if (percent !== undefined) body.percent = percent
-    if (amount !== undefined) body.amount = amount
+    const body: XeroInvoiceCreateRequest = {
+      mode,
+      ...(percent !== undefined ? { percent } : {}),
+      ...(amount !== undefined ? { amount } : {}),
+    }
     if ((err as AxiosError).isAxiosError) {
-      const axiosErr = err as AxiosError<{ message: string }>
-      const errorData = axiosErr.response?.data
-      if (typeof errorData === 'object' && errorData !== null && 'error' in errorData) {
-        msg = String((errorData as { error: string }).error)
-      }
+      const errorData = (err as AxiosError<{ error?: string }>).response?.data
+      if (errorData?.error) {
+        msg = errorData.error
+      }
     }
🤖 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/components/job/JobInvoiceCard.vue` around lines 388 - 415,
Update the invoice creation flow in JobInvoiceCard so the Xero request body is
constructed as a single object literal containing mode and any defined percent
or amount fields, allowing it to satisfy XeroInvoiceCreateRequest without a
shortcut cast. In the catch block, type AxiosError’s response payload with the
actual error property consumed by the code, then read that typed property
directly instead of casting the response data again.

Source: Coding guidelines


44-82: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Expose stable data-automation-id values for the invoice list and its row actions. JobInvoiceCard.vue provides no automation ids for the invoice list or the per-row buttons, so both test layers select those elements by CSS class, ARIA role, and list position. Any layout change breaks the tests silently.

  • frontend/src/components/job/JobInvoiceCard.vue#L44-L82: add :data-automation-id="JobInvoiceCard-open-${invoice.id}" to the Xero link button, :data-automation-id="JobInvoiceCard-delete-${invoice.id}" to the delete button, and data-automation-id="JobInvoiceCard-list" to the ul.
  • frontend/src/components/job/__tests__/JobFinishTab.balance.test.ts#L198-L202: replace the h-7 class filter and .at(1) index with a lookup on the new delete-button automation id.
  • frontend/tests/job/job-xero-invoice.spec.ts#L32-L33: replace page.locator('ul[role="list"] li') with a locator scoped to the new JobInvoiceCard-list id.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/job/JobInvoiceCard.vue` around lines 44 - 82, Add
stable automation identifiers in JobInvoiceCard.vue: set the list’s
data-automation-id to JobInvoiceCard-list, and add invoice-id-based identifiers
to the Xero link and delete buttons using JobInvoiceCard-open-${invoice.id} and
JobInvoiceCard-delete-${invoice.id}. In
frontend/src/components/job/__tests__/JobFinishTab.balance.test.ts:198-202,
replace the class-and-index lookup with the delete-button identifier. In
frontend/tests/job/job-xero-invoice.spec.ts:32-33, scope the invoice locator to
JobInvoiceCard-list instead of the role-based list selector.

Source: Coding guidelines

frontend/src/components/job/JobFinishTab.vue (1)

369-375: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the checklist object mutations with immutable updates.

checklist is a reactive object that is mutated in three places: v-model on the checkbox, Object.assign(checklist, response.checklist), and the rollback checklist[key] = !checked. The coding guidelines require replacement instead of direct mutation. Use a ref and assign a new object.

♻️ Proposed refactor
-const checklist = reactive<JobCompletionChecklist>({
+const checklist = ref<JobCompletionChecklist>({
   foreman_signed_off: false,
   timesheets_collected: false,
   materials_checked: false,
   customer_called: false,
   released: false,
 })
-  Object.assign(checklist, response.checklist)
+  checklist.value = { ...checklist.value, ...response.checklist }
-  const checked = checklist[key]
+  const checked = checklist.value[key]
   savingChecklistKey.value = key
   try {
     const response = await api.job_jobs_finish_partial_update(
       { [key]: checked },
       { params: { job_id: props.jobId } },
     )
-    Object.assign(checklist, response.checklist)
+    checklist.value = { ...checklist.value, ...response.checklist }
   } catch (error) {
     log('Failed to save checklist item %s: %o', key, error)
     toast.error('Failed to save checklist')
-    checklist[key] = !checked
+    checklist.value = { ...checklist.value, [key]: !checked }
   } finally {

The template binding becomes v-model="checklist[item.key]"v-model="checklist.value[item.key]" is not valid in templates; keep v-model="checklist[item.key]" working by binding explicitly instead:

-              v-model="checklist[item.key]"
+              :checked="checklist[item.key]"
               type="checkbox"
-              `@change`="onChecklistToggle(item.key)"
+              `@change`="onChecklistToggle(item.key, ($event.target as HTMLInputElement).checked)"

Then onChecklistToggle(key, checked) uses the passed value instead of reading it back.

Also applies to: 443-459

🤖 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/components/job/JobFinishTab.vue` around lines 369 - 375, Replace
the reactive checklist object with a ref-based checklist state in the component
containing onChecklistToggle. Update the checkbox template binding to avoid
mutating the ref-backed object directly, pass the new checked value into
onChecklistToggle, and replace both Object.assign(checklist, response.checklist)
and rollback checklist[key] mutations with assignments that create new checklist
objects while preserving the existing toggle and rollback behavior.

Source: Coding guidelines

apps/workflow/models/company_defaults.py (1)

23-32: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Bound gst_rate to a valid fraction.

The field is admin-editable and now drives customer-facing balances in build_finish_job_summary. A negative value produces negative GST and a total below the GST-exclusive amount. A value entered as 15 instead of 0.15 inflates every balance by 15x. Add validators, or a CheckConstraint, so an invalid rate is rejected at entry instead of reaching the counter.

♻️ Proposed validation
     gst_rate = models.DecimalField(
         max_digits=5,
         decimal_places=4,
         default=Decimal("0.1500"),
+        validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("1"))],
         help_text=(

Import the validators at the top of the module:

from django.core.validators import MaxValueValidator, MinValueValidator
🤖 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/workflow/models/company_defaults.py` around lines 23 - 32, Update the
gst_rate field in CompanyDefaults to validate values as a fraction between 0 and
1 using Django’s MinValueValidator and MaxValueValidator, while preserving its
existing precision and default.
apps/accounting/services/invoice_calculation.py (1)

34-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a Literal for basis.

basis only ever holds "quote" or "actual_revenue". A Literal makes the contract checkable and prevents a typo from reaching InvoiceCalculationResult.target_basis.

♻️ Proposed typing tightening
+JobValuationBasis = Literal["quote", "actual_revenue"]
+
+
 `@dataclass`(frozen=True)
 class JobInvoicingBasis:
     """What a job is worth excluding tax, and which cost set says so."""
 
-    basis: str
+    basis: JobValuationBasis
     target_total: Decimal

As per coding guidelines: "Use strict type annotations as real data contracts ... use named types for complex annotations."

🤖 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/accounting/services/invoice_calculation.py` around lines 34 - 39, Update
the basis annotation in JobInvoicingBasis to a Literal permitting only "quote"
and "actual_revenue", importing Literal from typing as needed, so invalid values
are rejected before reaching InvoiceCalculationResult.target_basis.

Source: Coding guidelines

apps/job/models/job.py (2)

160-166: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Declare COMPLETION_CHECKLIST_FIELDS as a tuple.

Ruff reports RUF012 on these lines. The list is a mutable class attribute. Both consumers accept a tuple: JobCompletionChecklistSerializer.Meta.fields and JobCompletionChecklistUpdateSerializer.Meta.fields in apps/job/serializers/job_serializer.py pass the value straight to DRF, and DRF accepts a tuple for Meta.fields. A tuple also prevents a caller from mutating the shared ordering.

♻️ Proposed fix
-    COMPLETION_CHECKLIST_FIELDS = [
+    COMPLETION_CHECKLIST_FIELDS = (
         "foreman_signed_off",
         "timesheets_collected",
         "materials_checked",
         "customer_called",
         "released",
-    ]
+    )
🤖 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/job/models/job.py` around lines 160 - 166, Change the class attribute
COMPLETION_CHECKLIST_FIELDS from a list to a tuple while preserving the existing
field order and values. Keep its use by
JobCompletionChecklistSerializer.Meta.fields and
JobCompletionChecklistUpdateSerializer.Meta.fields unchanged.

Source: Linters/SAST tools


1171-1194: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider deriving the checklist handlers from a label mapping.

The five entries are identical except for the display label. The comment at Line 157 states that an item is declared once, but adding an item currently requires an edit here as well as in COMPLETION_CHECKLIST_FIELDS. A single {field: label} mapping would keep the two in step.

♻️ Sketch of the mapping approach

Define the labels next to the field list:

    COMPLETION_CHECKLIST_LABELS = {
        "foreman_signed_off": "Foreman sign-off",
        "timesheets_collected": "Timesheets collected",
        "materials_checked": "Materials checked",
        "customer_called": "Customer called",
        "released": "Job released",
    }
    COMPLETION_CHECKLIST_FIELDS = tuple(COMPLETION_CHECKLIST_LABELS)

Then replace the five handler entries with one expansion:

-        "foreman_signed_off": _handle_boolean_change(
-            "completion_checklist_updated",
-            "completion_checklist_updated",
-            "Foreman sign-off",
-        ),
-        "timesheets_collected": _handle_boolean_change(
-            "completion_checklist_updated",
-            "completion_checklist_updated",
-            "Timesheets collected",
-        ),
-        "materials_checked": _handle_boolean_change(
-            "completion_checklist_updated",
-            "completion_checklist_updated",
-            "Materials checked",
-        ),
-        "customer_called": _handle_boolean_change(
-            "completion_checklist_updated",
-            "completion_checklist_updated",
-            "Customer called",
-        ),
-        "released": _handle_boolean_change(
-            "completion_checklist_updated",
-            "completion_checklist_updated",
-            "Job released",
-        ),
+        **{
+            field: _handle_boolean_change(
+                "completion_checklist_updated",
+                "completion_checklist_updated",
+                label,
+            )
+            for field, label in COMPLETION_CHECKLIST_LABELS.items()
+        },
🤖 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/job/models/job.py` around lines 1171 - 1194, Derive the completion
checklist handlers from a single field-to-label mapping instead of maintaining
five duplicate entries in the handler mapping. Update the checklist definitions
near COMPLETION_CHECKLIST_FIELDS to define the labels once, derive the field
tuple from that mapping, and generate each handler using the field and
corresponding label so adding an item only requires one declaration.
apps/workflow/migrations/0019_company_defaults_gst_rate.py (1)

18-23: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider bounding gst_rate with validators.

gst_rate feeds directly into every Finish Job balance calculation (build_finish_job_summary multiplies it against remaining_excl). Nothing stops an operator from saving a negative or absurdly large rate through the admin/settings UI, which would silently corrupt the customer-facing total. Add MinValueValidator/MaxValueValidator to the field.

♻️ Proposed validator addition
+from django.core.validators import MaxValueValidator, MinValueValidator
+
 migrations.AddField(
     model_name="companydefaults",
     name="gst_rate",
     field=models.DecimalField(
         decimal_places=4,
         default=Decimal("0.1500"),
         help_text="...",
         max_digits=5,
+        validators=[MinValueValidator(Decimal("0")), MaxValueValidator(Decimal("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 `@apps/workflow/migrations/0019_company_defaults_gst_rate.py` around lines 18 -
23, Update the gst_rate DecimalField in migration
0019_company_defaults_gst_rate.py to include MinValueValidator and
MaxValueValidator bounds appropriate for a fractional GST rate, preventing
negative or unreasonably large values while preserving the existing default and
precision settings.
🤖 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/job/services/job_service.py`:
- Around line 65-77: Update update_completion_checklist to pass an explicit
update_fields collection containing the checklist keys from updates when calling
job.save(staff=staff), matching the scoped-save pattern used by
recalculate_job_invoicing_state and preserving the existing checklist updates
and history behavior.

In `@frontend/schema.yml`:
- Around line 11265-11274: Update the API validation for CompanyDefaultsRequest
and PatchedCompanyDefaultsRequest so gst_rate uses a non-negative DecimalField
with unique_fields=['gst_rate'] and MinValueValidator(0). Apply validation at
write-time where the request serializer/API field is defined, and do not add
fallback handling for negative rates in the API schema.

In `@frontend/src/components/job/JobFinishTab.vue`:
- Around line 106-113: In the JobInvoiceCard invoices-changed binding, replace
the direct loadFinishSummary handler with a new reloadFinishSummary wrapper.
Have reloadFinishSummary await loadFinishSummary, catch failures, log the error,
and show the specified toast instructing the user to reload the job.

---

Nitpick comments:
In `@apps/accounting/services/invoice_calculation.py`:
- Around line 34-39: Update the basis annotation in JobInvoicingBasis to a
Literal permitting only "quote" and "actual_revenue", importing Literal from
typing as needed, so invalid values are rejected before reaching
InvoiceCalculationResult.target_basis.

In `@apps/job/models/job.py`:
- Around line 160-166: Change the class attribute COMPLETION_CHECKLIST_FIELDS
from a list to a tuple while preserving the existing field order and values.
Keep its use by JobCompletionChecklistSerializer.Meta.fields and
JobCompletionChecklistUpdateSerializer.Meta.fields unchanged.
- Around line 1171-1194: Derive the completion checklist handlers from a single
field-to-label mapping instead of maintaining five duplicate entries in the
handler mapping. Update the checklist definitions near
COMPLETION_CHECKLIST_FIELDS to define the labels once, derive the field tuple
from that mapping, and generate each handler using the field and corresponding
label so adding an item only requires one declaration.

In `@apps/workflow/migrations/0019_company_defaults_gst_rate.py`:
- Around line 18-23: Update the gst_rate DecimalField in migration
0019_company_defaults_gst_rate.py to include MinValueValidator and
MaxValueValidator bounds appropriate for a fractional GST rate, preventing
negative or unreasonably large values while preserving the existing default and
precision settings.

In `@apps/workflow/models/company_defaults.py`:
- Around line 23-32: Update the gst_rate field in CompanyDefaults to validate
values as a fraction between 0 and 1 using Django’s MinValueValidator and
MaxValueValidator, while preserving its existing precision and default.

In `@frontend/src/components/job/__tests__/finishTabFixtures.ts`:
- Around line 7-26: Update the finishSummary and checklistState fixture
factories to type overrides as Partial<ReturnType<typeof finishSummary>> and
Partial<ReturnType<typeof checklistState>> respectively, while preserving their
existing defaults and spread behavior so unknown override keys are rejected.

In `@frontend/src/components/job/JobFinishTab.vue`:
- Around line 369-375: Replace the reactive checklist object with a ref-based
checklist state in the component containing onChecklistToggle. Update the
checkbox template binding to avoid mutating the ref-backed object directly, pass
the new checked value into onChecklistToggle, and replace both
Object.assign(checklist, response.checklist) and rollback checklist[key]
mutations with assignments that create new checklist objects while preserving
the existing toggle and rollback behavior.

In `@frontend/src/components/job/JobInvoiceCard.vue`:
- Around line 388-415: Update the invoice creation flow in JobInvoiceCard so the
Xero request body is constructed as a single object literal containing mode and
any defined percent or amount fields, allowing it to satisfy
XeroInvoiceCreateRequest without a shortcut cast. In the catch block, type
AxiosError’s response payload with the actual error property consumed by the
code, then read that typed property directly instead of casting the response
data again.
- Around line 44-82: Add stable automation identifiers in JobInvoiceCard.vue:
set the list’s data-automation-id to JobInvoiceCard-list, and add
invoice-id-based identifiers to the Xero link and delete buttons using
JobInvoiceCard-open-${invoice.id} and JobInvoiceCard-delete-${invoice.id}. In
frontend/src/components/job/__tests__/JobFinishTab.balance.test.ts:198-202,
replace the class-and-index lookup with the delete-button identifier. In
frontend/tests/job/job-xero-invoice.spec.ts:32-33, scope the invoice locator to
JobInvoiceCard-list instead of the role-based list selector.
🪄 Autofix (Beta)

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: 86752d47-c2d5-43a9-9986-43564b96b43d

📥 Commits

Reviewing files that changed from the base of the PR and between 2594e93 and 1013c78.

⛔ Files ignored due to path filters (1)
  • frontend/src/api/generated/api.ts is excluded by !**/generated/**
📒 Files selected for processing (39)
  • apps/accounting/services/__init__.py
  • apps/accounting/services/finish_job_summary.py
  • apps/accounting/services/invoice_calculation.py
  • apps/accounting/tests/test_finish_job_summary.py
  • apps/accounts/tests/test_auth_observability.py
  • apps/job/migrations/0010_job_completion_checklist_fields.py
  • apps/job/models/job.py
  • apps/job/models/job_event.py
  • apps/job/serializers/__init__.py
  • apps/job/serializers/job_serializer.py
  • apps/job/services/__init__.py
  • apps/job/services/job_service.py
  • apps/job/tests/test_completion_checklist.py
  • apps/job/tests/test_job_event_tracking.py
  • apps/job/tests/test_mcp_tool_integration.py
  • apps/job/urls_rest.py
  • apps/job/views/__init__.py
  • apps/job/views/job_rest_views.py
  • apps/workflow/accounting/xero/readonly_provider.py
  • apps/workflow/migrations/0019_company_defaults_gst_rate.py
  • apps/workflow/models/company_defaults.py
  • apps/workflow/models/settings_metadata.py
  • apps/workflow/tests/test_access_logging_middleware.py
  • apps/workflow/tests/test_backup_scripts.py
  • docs/urls/job.md
  • frontend/schema.yml
  • frontend/src/components/job/JobActualTab.vue
  • frontend/src/components/job/JobFinishTab.vue
  • frontend/src/components/job/JobInvoiceCard.vue
  • frontend/src/components/job/JobViewTabs.vue
  • frontend/src/components/job/__tests__/JobFinishTab.balance.test.ts
  • frontend/src/components/job/__tests__/JobFinishTab.checklist.test.ts
  • frontend/src/components/job/__tests__/JobFinishTab.labourHours.test.ts
  • frontend/src/components/job/__tests__/finishTabFixtures.ts
  • frontend/src/composables/useJobFinancials.ts
  • frontend/src/constants/job-tabs.ts
  • frontend/src/pages/jobs/[id]/(index).vue
  • frontend/tests/job/job-xero-invoice.spec.ts
  • mypy-baseline.txt
💤 Files with no reviewable changes (4)
  • frontend/src/composables/useJobFinancials.ts
  • mypy-baseline.txt
  • apps/workflow/tests/test_backup_scripts.py
  • apps/job/tests/test_mcp_tool_integration.py

Comment thread apps/job/services/job_service.py
Comment thread frontend/schema.yml
Comment thread frontend/src/components/job/JobFinishTab.vue
…icated

The completion-checklist tests used force_login, which only ever worked by
accident: JWTAuthentication is the sole authenticator, and it short-circuits
to None when ENABLE_JWT_AUTH is off, so the session force_login creates is
never consulted. Locally .env turns JWT auth on and the authenticator's
middleware fallback honours the session; CI copies .env.precommit, which
turns it off, so every request 401'd. force_authenticate replaces the
authenticator list outright and is what the rest of the suite uses.

Also from review:

- Scope the checklist save to the ticked fields. A bare Job.save() rewrites
  every column held on the instance, so a concurrent write to fully_invoiced
  or status was reverted to whatever this request read first.
- Hold gst_rate to a fraction. The field was unbounded, and the API advertised
  -10 < rate < 10; a CHECK constraint carries it because the admin, management
  commands and the Xero sync all bypass serializer validation.
- Report a failed balance reload after invoicing instead of leaving the
  pre-invoice figure on screen looking current.
- Drop the casts around invoice creation. The mode arguments were typed str
  and only fit XeroInvoiceCreateRequest because a cast hid it, and the error
  payload was typed with a property the code below it did not read.
- Give the invoice list stable automation ids so its tests stop selecting by
  CSS class and list position.

@coderabbitai coderabbitai Bot 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: 1

🤖 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 `@frontend/src/components/job/JobInvoiceCard.vue`:
- Line 54: Add accessible names to the icon-only buttons in JobInvoiceCard,
including the buttons associated with the automation IDs
JobInvoiceCard-open-${invoice.id} and the corresponding deletion action. Use
clear labels that identify the Xero navigation and invoice deletion actions for
screen readers.
🪄 Autofix (Beta)

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: e06b03c7-7ca5-4fa0-8532-1006bb239335

📥 Commits

Reviewing files that changed from the base of the PR and between 1013c78 and 9992798.

⛔ Files ignored due to path filters (1)
  • frontend/src/api/generated/api.ts is excluded by !**/generated/**
📒 Files selected for processing (11)
  • apps/job/services/job_service.py
  • apps/job/tests/test_completion_checklist.py
  • apps/workflow/migrations/0020_company_defaults_gst_rate_bounds.py
  • apps/workflow/models/company_defaults.py
  • apps/workflow/tests/test_company_defaults_api.py
  • frontend/schema.yml
  • frontend/src/components/job/JobFinishTab.vue
  • frontend/src/components/job/JobInvoiceCard.vue
  • frontend/src/components/job/__tests__/JobFinishTab.balance.test.ts
  • frontend/src/components/job/__tests__/finishTabFixtures.ts
  • frontend/tests/job/job-xero-invoice.spec.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • frontend/tests/job/job-xero-invoice.spec.ts
  • apps/job/tests/test_completion_checklist.py
  • frontend/src/components/job/tests/finishTabFixtures.ts
  • apps/job/services/job_service.py
  • frontend/schema.yml
  • frontend/src/components/job/JobFinishTab.vue

Comment thread frontend/src/components/job/JobInvoiceCard.vue
The open and delete buttons on each invoice row carry only an icon, so a
screen reader announced them as unlabelled buttons. The label includes the
invoice number because these render once per row — five buttons all reading
"Delete" would not say which invoice they act on.
@corrin
corrin merged commit 2ebb8ef into main Aug 1, 2026
9 checks passed
@corrin
corrin deleted the KAN-323-build-finish-job-workspace-with-gst-inclusive-customer-balance branch August 1, 2026 09:10
@corrin corrin mentioned this pull request Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant