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.
100means every line for this item captured a cost snapshot. The margin numbers are exact.< 100means some sales of this item are legacy (pre-feature) or ad-hoc lines without an item-cost. The row'scogs/marginAmountonly count lines where a snapshot existed;revenuestill reflects every sale. Surface this caveat in the UI when coverage is materially below 100.0means 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
grossMarginPctnext tosubTotalAmounton the invoice line detail view; dim/hide whennulland explain why on hover ("Cost not recorded when this invoice was issued"). - The owner dashboard summary should pull from
getProductMarginReportwithstartDate= first of month,endDate= today. - Format
costCoveragePctas 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>.