Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
)
79 changes: 52 additions & 27 deletions apps/accounting/services/invoice_calculation.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,38 @@ class InvoiceCalculationResult:
calculated_amount: Decimal = Decimal("0")


@dataclass(frozen=True)
class JobInvoicingBasis:
"""What a job is worth excluding tax, and which cost set says so."""

basis: str
target_total: Decimal


def get_job_invoicing_basis(job: Job) -> JobInvoicingBasis:
"""The complete value of a job excluding tax.

The single place a job's value is derived: fixed-price work is worth its
quote, T&M work its actual revenue limited by any price cap. Everything that
needs a job's value — invoice calculation, the Finish Job balance,
``job_service.get_job_total_value`` — reads it from here, so the three cannot
disagree about what a job is worth.
"""
if job.pricing_methodology == "fixed_price":
return JobInvoicingBasis(
basis="quote", target_total=Decimal(str(job.latest_quote.total_revenue))
)

actual_revenue = Decimal(str(job.latest_actual.total_revenue))
if job.price_cap is None:
return JobInvoicingBasis(basis="actual_revenue", target_total=actual_revenue)

return JobInvoicingBasis(
basis="actual_revenue",
target_total=min(actual_revenue, Decimal(str(job.price_cap))),
)


def get_prior_valid_invoice_total(job: Job) -> Decimal:
return Decimal(
Invoice.objects.filter(
Expand All @@ -40,9 +72,13 @@ def get_prior_valid_invoice_total(job: Job) -> Decimal:


def get_job_for_invoice_calculation(job_id: UUID) -> Job:
job = Job.objects.select_related("company", "latest_quote", "latest_actual").get(
id=job_id
)
"""Load a job with the cost set its value is measured against.

Only the basis cost set is fetched: loading both eagerly pulls one that is
never read, which the n+1 guard reports. Its cost lines are prefetched
because ``total_revenue`` sums them.
"""
job = Job.objects.select_related("company").get(id=job_id)

if job.pricing_methodology == "fixed_price":
prefetch_related_objects([job], "latest_quote__cost_lines")
Expand All @@ -59,27 +95,26 @@ def calculate_invoice_amount(
amount: Decimal | None = None,
) -> InvoiceCalculationResult:
prior_invoiced = get_prior_valid_invoice_total(job)
job_basis = get_job_invoicing_basis(job)

# Which modes are legal is a different question from what the job is worth,
# so it keeps its own branch; the handlers below are given the value.
if job.pricing_methodology == "fixed_price":
return _calculate_fixed_price(job, mode, prior_invoiced, percent, amount)
return _calculate_fixed_price(job_basis, mode, prior_invoiced, percent, amount)
else:
return _calculate_time_materials(job, mode, prior_invoiced, percent, amount)
return _calculate_time_materials(
job_basis, mode, prior_invoiced, percent, amount
)


def _calculate_fixed_price(
job: Job,
job_basis: JobInvoicingBasis,
mode: str,
prior_invoiced: Decimal,
percent: Decimal | None,
amount: Decimal | None,
) -> InvoiceCalculationResult:
quote = job.latest_quote
if not quote:
raise InvoiceCalculationError(
"Fixed-price job has no quote to invoice against."
)
target_total = Decimal(str(quote.total_revenue))
target_basis = "quote"
target_total = job_basis.target_total

if mode == "invoice_full":
calculated = target_total - prior_invoiced
Expand Down Expand Up @@ -108,7 +143,7 @@ def _calculate_fixed_price(

return InvoiceCalculationResult(
mode=mode,
target_basis=target_basis,
target_basis=job_basis.basis,
target_total=target_total,
prior_invoiced_total=prior_invoiced,
requested_percent=percent,
Expand All @@ -118,23 +153,13 @@ def _calculate_fixed_price(


def _calculate_time_materials(
job: Job,
job_basis: JobInvoicingBasis,
mode: str,
prior_invoiced: Decimal,
percent: Decimal | None,
amount: Decimal | None,
) -> InvoiceCalculationResult:
actual = job.latest_actual
if not actual:
raise InvoiceCalculationError(
"T&M job has no actual cost set to invoice against."
)
target_total = Decimal(str(actual.total_revenue))

if job.price_cap is not None:
target_total = min(target_total, Decimal(str(job.price_cap)))

target_basis = "actual_revenue"
target_total = job_basis.target_total

if mode == "invoice_costs_to_date":
calculated = target_total - prior_invoiced
Expand All @@ -158,7 +183,7 @@ def _calculate_time_materials(

return InvoiceCalculationResult(
mode=mode,
target_basis=target_basis,
target_basis=job_basis.basis,
target_total=target_total,
prior_invoiced_total=prior_invoiced,
requested_percent=percent,
Expand Down
Loading
Loading