Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
1761de4
chore(deps): sweep backend and frontend dependencies
corrin Jul 27, 2026
544e5bb
chore(deps): upgrade past declared bounds; record the pinning policy
corrin Jul 27, 2026
9b7594a
refactor(data): make NULL the only unset for text columns
corrin Jul 28, 2026
2266192
fix: stop writing and sending "" for columns whose unset is NULL
corrin Jul 28, 2026
b8d76c1
test(e2e): allow the whole pre-auth 401 burst, not just the /me check
corrin Jul 28, 2026
a6add63
fix: clarify auth probes and dedupe pinia
corrin Jul 29, 2026
0b09674
docs: remove obsolete quoting chatbot drafts
corrin Jul 29, 2026
99f39c0
fix: let a cleared AI provider model name reach the API as null
corrin Jul 30, 2026
33e2489
fix: reconcile kanban changes incrementally
corrin Jul 30, 2026
75e8312
Merge pull request #501 from corrin/chore/dependency-bumps
corrin Jul 30, 2026
f6543ec
Merge remote-tracking branch 'origin/main' into KAN-312-kanban-freshn…
corrin Jul 30, 2026
9df6d46
fix: address kanban freshness review findings
corrin Jul 30, 2026
354eb9c
chore: shrink mypy baseline
corrin Jul 30, 2026
b977de5
fix: preserve strong resource versions through gzip
corrin Jul 30, 2026
e308828
test: narrow gzip middleware response
corrin Jul 30, 2026
b88dd90
fix: type resource version response headers
corrin Jul 30, 2026
430a314
fix: unify job person concurrency path
corrin Jul 30, 2026
7232804
chore: shrink mypy baseline
corrin Jul 30, 2026
d950123
fix: prevent stale search overriding kanban drag
corrin Jul 30, 2026
dd08050
fix: skip redundant save when clearing an already-empty person
corrin Jul 30, 2026
9b19ffd
Merge pull request #502 from corrin/KAN-312-kanban-freshness-reloads-…
corrin Jul 30, 2026
6d5c2a6
docs: clarify integration and release branches
corrin Jul 30, 2026
45c8ccf
Merge pull request #503 from corrin/agent/clarify-integration-release…
corrin Jul 30, 2026
b2cb5ed
KAN-316 test: shrink low-hanging mypy debt
corrin Jul 30, 2026
7271d55
KAN-316 test: annotate void test functions
corrin Jul 30, 2026
1527ab3
KAN-316 test: annotate test factory helpers
corrin Jul 30, 2026
09ac18b
chore: gitignore Qwen Code per-session state
corrin Jul 30, 2026
c134c1c
KAN-316 test: annotate remaining test factory helpers
corrin Jul 31, 2026
8c83c95
KAN-316 test: use BaseAPITestCase for company-defaults API tests
corrin Jul 31, 2026
794da51
KAN-316 test: address CodeRabbit review on PR 504
corrin Jul 31, 2026
03b7e13
chore: sort mypy-baseline so syncs diff cleanly
corrin Jul 31, 2026
03a4ef9
Merge pull request #504 from corrin/KAN-316-low-hanging-test-mypy-cle…
corrin Jul 31, 2026
537055b
fix: render unset phone credentials as null
corrin Jul 31, 2026
6f9995c
fix: normalise empty Xero account fields
corrin Jul 31, 2026
445afbf
fix: drop the dead last_login API and UI surface
corrin Jul 31, 2026
ac5e031
refactor: drop dead getattr fallbacks in Xero account sync
corrin Jul 31, 2026
2594e93
Merge pull request #505 from corrin/KAN-321-null-only-text-migration-…
corrin Jul 31, 2026
d382584
feat: add CompanyDefaults.gst_rate as the single sales-tax rate
corrin Jul 31, 2026
d105de8
feat: add backend-owned Finish Job balance and audited completion che…
corrin Jul 31, 2026
61fbc61
feat: turn Cost Analysis into the Finish Job workspace
corrin Jul 31, 2026
b6584c9
fix: stack the Finish Job labour tiles on narrow screens
corrin Jul 31, 2026
19c1108
refactor: front-desk checklist as Job fields, not its own table
corrin Aug 1, 2026
1013c78
refactor: derive job value in one place, stop the tab fabricating a b…
corrin Aug 1, 2026
9992798
fix: authenticate checklist tests the way the API is actually authent…
corrin Aug 1, 2026
44f305f
fix: name the icon-only invoice buttons for screen readers
corrin Aug 1, 2026
2ebb8ef
Merge pull request #511 from corrin/KAN-323-build-finish-job-workspac…
corrin Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: 3.12
cache: 'pip'
Expand Down Expand Up @@ -87,7 +87,7 @@ jobs:
- uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: 3.12
cache: 'pip'
Expand Down Expand Up @@ -125,7 +125,7 @@ jobs:
- uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: 3.12
cache: 'pip'
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,9 @@ session-replays/
.mcp.json
.codex

# Qwen Code per-session state (developer-specific permission grants)
.qwen/

# Codesight autogenerated context maps. Regenerated by the pre-commit
# `post-merge` / `post-rewrite` hooks (`pre-commit install` wires them up
# automatically — see `default_install_hook_types` in
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Major architectural decisions are recorded in [`docs/adr/`](docs/adr/README.md).
- **0020** — Backend owns data, calculations, and external systems; frontend owns presentation. The boundary is the kind of value, not the layer of code.
- **0021** — Frontend reads/writes the API only via the generated client; raw `fetch`/`axios` is forbidden.
- **0032** — Less code is better: prefer a maintained library over a homegrown implementation. Writing your own for something a library provides needs an explicit, recorded reason it isn't a library.
- **0033** — A version constraint records the newest version that passed testing, never a known incompatibility. Release sweeps ignore the declared bounds and re-test at latest; a version stays behind only when upstream metadata pins it or a gate fails.

CLAUDE.md is the operational layer (session behaviour, code-style gotchas, architecture facts). ADRs explain *why*.

Expand Down Expand Up @@ -153,7 +154,7 @@ Ratchet rules:
- ADR 0028: type annotations are data contracts. Touched code must improve mypy by preserving or tightening the real contract; no `Any`, fake `| None`, broad unions, casts, or ignores as checker shortcuts.
- ADR 0028 also treats broad `object`, fallback-style `dict.get()`, `hasattr()` contract probes, and happy-case-first branching as soft smells; prefer named types, explicit contracts, direct access after validation, and bad-case guards.
- If a touched type becomes hard to read, introduce a named type (`dataclass`, `TypedDict`, protocol, or alias) instead of repeating a complex inline annotation.
- After fixing baselined errors, run `poetry run mypy apps/ docketworks/ | poetry run mypy-baseline sync` and commit the shrunken baseline in the same PR.
- After fixing baselined errors, run `poetry run mypy apps/ docketworks/ | poetry run mypy-baseline sync --sort-baseline` and commit the shrunken baseline in the same PR. `--sort-baseline` keeps the file in canonical order so the diff shows only real add/remove, not mypy's output-order reshuffling.
- `# type: ignore[code]` requires the specific error code and a justification comment on the same line.

### Defensive programming
Expand Down Expand Up @@ -192,12 +193,13 @@ At the HTTP boundary, read the persisted id with `app_error_for(exc)` to include

## Environment Configuration

See `.env.example` for required environment variables. Key integrations: Xero API, Dropbox, PostgreSQL. Frontend tooling reads `APP_DOMAIN` from the backend `.env` at `../.env` and derives URLs from it (see ADR 0008's Consequences). Deploy uses `scripts/server/deploy.sh` (per-instance `<client>-<env>`); it also runs on boot via systemd so a cold machine catches up to `production`. Servers run the `production` branch by default; `main` is the integration branch — never deployed to production, but deployed to UAT as a release candidate via `deploy.sh --ref` / `instance.sh create --ref` (ADR 0029).
See `.env.example` for required environment variables. Key integrations: Xero API, Dropbox, PostgreSQL. Frontend tooling reads `APP_DOMAIN` from the backend `.env` at `../.env` and derives URLs from it (see ADR 0008's Consequences). Deploy uses `scripts/server/deploy.sh` (per-instance `<client>-<env>`). Testing and UAT servers typically track `main`; production servers typically track `production`. After UAT verification, a release PR promotes `main` to `production` (ADR 0029).

## Migration Management

- Keep migrations small and reviewable; include forward and (where feasible) reverse logic.
- Prefer schema changes over code workarounds that mask data shape issues.
- A nullable column says "unset" as NULL and nothing else — no `""`, no sentinel choice (`UNSPECIFIED`, `N/A`). A `CHECK (col <> '')` constraint enforces it, since the admin, management commands and Xero sync all bypass serializer validation. Serializers on such columns set `allow_blank=False` so bad input is a 400, not an `IntegrityError`.

## Critical Architecture Guidelines

Expand Down
55 changes: 55 additions & 0 deletions apps/accounting/migrations/0004_forbid_blank_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Forbid empty strings in nullable text columns (accounting).

NULL is this codebase's single representation of "unset" for text columns
(see CLAUDE.md). A CHECK constraint is the only guard nothing can bypass: the
API rejects "" at the serializer, but the Django admin, management
commands, the Xero sync and raw SQL all write straight past that.

Only columns physically on each table are listed: multi-table-inheritance
children (XeroError) store their inherited columns on the parent table, and
constraining them here would target a column the child table does not have.

Constraint names are unqualified because CHECK constraint names only need
to be unique within their table, and the qualified form overran Postgres's
63-character identifier limit.
"""

from django.db import migrations

COLUMNS_BY_TABLE = {
"accounting_bill": [
"xero_tenant_id"
],
"accounting_creditnote": [
"xero_tenant_id"
],
"accounting_invoice": [
"online_url",
"xero_tenant_id"
],
"accounting_quote": [
"number",
"online_url",
"xero_tenant_id"
]
}


class Migration(migrations.Migration):
dependencies = [
("accounting", "0003_rename_client_company"),
]

operations = [
migrations.RunSQL(
sql=(
f"ALTER TABLE {table} ADD CONSTRAINT {col}_not_blank "
f"CHECK ({col} <> '')"
),
reverse_sql=(
f"ALTER TABLE {table} DROP CONSTRAINT IF EXISTS {col}_not_blank"
),
)
for table, columns in COLUMNS_BY_TABLE.items()
for col in columns
]
6 changes: 4 additions & 2 deletions apps/accounting/serializers/rdti_spend_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from rest_framework import serializers

from apps.job.enums import RDTIType


class RDTISpendQuerySerializer(serializers.Serializer[Any]):
"""Validates query parameters for the RDTI spend report."""
Expand All @@ -22,7 +24,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
class RDTISpendCategorySummarySerializer(serializers.Serializer[Any]):
"""Summary data for a single RDTI classification category."""

rdti_type = serializers.CharField()
rdti_type = serializers.ChoiceField(choices=RDTIType.choices)
label = serializers.CharField()
hours = serializers.FloatField()
cost = serializers.FloatField()
Expand All @@ -37,7 +39,7 @@ class RDTISpendJobDetailSerializer(serializers.Serializer[Any]):
job_number = serializers.IntegerField()
job_name = serializers.CharField()
company_name = serializers.CharField()
rdti_type = serializers.CharField()
rdti_type = serializers.ChoiceField(choices=RDTIType.choices)
hours = serializers.FloatField()
cost = serializers.FloatField()
revenue = serializers.FloatField()
Expand Down
12 changes: 12 additions & 0 deletions apps/accounting/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@

if apps.ready:
from .core import JobAgingService, KPIService, StaffPerformanceService
from .finish_job_summary import (
FinishJobSummary,
build_finish_job_summary,
get_outstanding_invoiced_incl_tax,
)
from .invoice_calculation import (
InvoiceCalculationError,
InvoiceCalculationResult,
JobInvoicingBasis,
calculate_invoice_amount,
get_job_for_invoice_calculation,
get_job_invoicing_basis,
get_prior_valid_invoice_total,
)
from .payroll_reconciliation_service import PayrollReconciliationService
Expand All @@ -22,16 +29,21 @@
pass

__all__ = [
"FinishJobSummary",
"InvoiceCalculationError",
"InvoiceCalculationResult",
"JobAgingService",
"JobInvoicingBasis",
"KPIService",
"PayrollReconciliationService",
"RDTISpendService",
"SalesPipelineService",
"StaffPerformanceService",
"WIPService",
"build_finish_job_summary",
"calculate_invoice_amount",
"get_job_for_invoice_calculation",
"get_job_invoicing_basis",
"get_outstanding_invoiced_incl_tax",
"get_prior_valid_invoice_total",
]
80 changes: 80 additions & 0 deletions apps/accounting/services/finish_job_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Authoritative customer balance for the Finish Job workspace.

Answers the counter question — what does this customer need to pay, including
tax — as decimal currency values, so the frontend only formats what it is given
(ADR 0020).

Adds nothing but the tax and outstanding-balance arithmetic: the job's value, its
price cap and the valid-invoice statuses all come from ``invoice_calculation``,
so the amount a user can invoice and the amount they are shown cannot drift
apart.
"""

from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal

from django.db.models import Sum
from django.db.models.functions import Coalesce

from apps.accounting.models.invoice import Invoice
from apps.accounting.services.invoice_calculation import (
INVOICE_VALID_STATUSES,
get_job_invoicing_basis,
get_prior_valid_invoice_total,
)
from apps.job.models import Job
from apps.workflow.models import CompanyDefaults

CENT = Decimal("0.01")


@dataclass(frozen=True)
class FinishJobSummary:
"""Every currency value the Finish Job workspace displays."""

job_value_excl_gst: Decimal
valid_invoiced_excl_gst: Decimal
outstanding_invoiced_incl_gst: Decimal
remaining_to_invoice_excl_gst: Decimal
remaining_gst: Decimal
remaining_to_invoice_incl_gst: Decimal
total_to_pay_incl_gst: Decimal
over_invoiced_excl_gst: Decimal


def get_outstanding_invoiced_incl_tax(job: Job) -> Decimal:
"""What Xero still expects to be paid on this job's valid invoices."""
return Decimal(
Invoice.objects.filter(
job_id=job.id, status__in=INVOICE_VALID_STATUSES
).aggregate(total=Coalesce(Sum("amount_due"), Decimal("0")))["total"]
)


def build_finish_job_summary(job: Job) -> FinishJobSummary:
job_value = get_job_invoicing_basis(job).target_total.quantize(
CENT, rounding=ROUND_HALF_UP
)
invoiced = get_prior_valid_invoice_total(job)
outstanding = get_outstanding_invoiced_incl_tax(job)

# A job invoiced beyond its value has nothing left to invoice; the excess is
# reported separately for resolution in Xero rather than netted off, which
# would hide it behind a smaller remaining balance.
remaining_excl = max(job_value - invoiced, Decimal("0"))
over_invoiced = max(invoiced - job_value, Decimal("0"))

gst_rate = CompanyDefaults.get_solo().gst_rate
remaining_gst = (remaining_excl * gst_rate).quantize(CENT, rounding=ROUND_HALF_UP)
remaining_incl = remaining_excl + remaining_gst

return FinishJobSummary(
job_value_excl_gst=job_value,
valid_invoiced_excl_gst=invoiced,
outstanding_invoiced_incl_gst=outstanding,
remaining_to_invoice_excl_gst=remaining_excl,
remaining_gst=remaining_gst,
remaining_to_invoice_incl_gst=remaining_incl,
total_to_pay_incl_gst=outstanding + remaining_incl,
over_invoiced_excl_gst=over_invoiced,
)
Loading
Loading