Skip to content

Product margins

Backend domain: app/graphql/invoices/, app/graphql/reports/services/sales/product_margin_report_service.py Migration: add_unit_cost_snapshot RBAC Path: REPORTS

Overview

Each InvoiceDetail now stores unitCostSnapshot: the item's averageCost (falling back to lastUnitCost) at the moment the invoice was created. The snapshot freezes the cost-per-line so historical gross-margin reporting does not shift when the moving-average cost on the item later changes.

The snapshot is captured automatically inside InvoiceMutationRepository.add_from_input. Lines without an itemId (ad-hoc lines) and lines whose item has never recorded a cost will have unitCostSnapshot = null.

Derived margin fields on InvoiceDetail

The detail response exposes three new fields:

Field Type Definition
unitCostSnapshot Decimal? Cost-per-unit frozen at sale time. null for legacy rows and ad-hoc lines.
grossMarginAmount Decimal? subTotalAmount − (unitCostSnapshot × quantity). null when unitCostSnapshot is null.
grossMarginPct Decimal? grossMarginAmount / subTotalAmount × 100. null when unitCostSnapshot is null.

subTotalAmount is net of discount but excludes tax, so the margin is comparable across tax regimes.

Product margin report

A new aggregated query rolls margin up by item over a date range.

query Margins($start: Date!, $end: Date!) {
  getProductMarginReport(startDate: $start, endDate: $end) {
    totalRevenue
    totalCogs
    totalMarginAmount
    totalMarginPct
    rows {
      itemId
      itemNumber
      description
      quantitySold
      revenue
      cogs
      marginAmount
      marginPct
      costCoveragePct
    }
  }
}

Rows come back sorted by revenue descending. Voided invoices are excluded.

Interpreting costCoveragePct

costCoveragePct = snapshotRevenue / revenue × 100 for each row.

  • 100 means every line for this item captured a cost snapshot. The margin numbers are exact.
  • < 100 means some sales of this item are legacy (pre-feature) or ad-hoc lines without an item-cost. The row's cogs/marginAmount only count lines where a snapshot existed; revenue still reflects every sale. Surface this caveat in the UI when coverage is materially below 100.
  • 0 means no lines for this item carry a snapshot — likely a service item with no cost. The margin row is unreliable; consider hiding or flagging it.

Frontend handoff notes

  • Show grossMarginPct next to subTotalAmount on the invoice line detail view; dim/hide when null and explain why on hover ("Cost not recorded when this invoice was issued").
  • The owner dashboard summary should pull from getProductMarginReport with startDate = first of month, endDate = today.
  • Format costCoveragePct as a badge color: green ≥ 95, yellow 50–95, red < 50.

Backfill

Existing invoice lines stay null. A migration-time backfill from current Item.averageCost is not done on purpose: it would attribute today's cost to old sales and distort historical margin. If a tenant wants a one-shot rough backfill, write a one-off script that copies items.average_cost to the snapshot column for invoice_details where invoice_date >= <cutoff>.