# Phase 5 — Finance formula contract v2

## Scope
This contract defines how **Analytics** (and the Phase 5 reconciliation helpers) compute **revenue**, **COGS**, **gross margin**, **VAT**, **refunds**, and **shrinkage** so finance and ops can reconcile numbers without ambiguity.

## Time and channel scope
- **Order window**: an order is included iff `getOrderCreatedAtMs(order)` is in `[startMs, endMs]` (inclusive bounds on ms; UI uses date pickers converted to UTC-day boundaries — see implementation in `finance-analytics-v5.js`).
- **Channel filter** `all | pos | online`: uses `classifyOrderChannel(order)`; `unknown` is excluded from `pos`/`online` slices but included in `all`.

## Line filters (category, weighted-only)
- **Category**: a line contributes only if `String(products[line.id].category || '') === categoryId` when `categoryId` is set.
- **Weighted-only**: when enabled, only lines whose `products[line.id].isWeighted === true` contribute.
- When any line filter is active, **order-level KPIs** that use `totalBill` (see below) still count **whole orders** that fall in the date+channel window; **line-derived KPIs** (COGS, margin, top products in Phase 5 UI) use only lines passing the filters.

## Net sold quantity (refund-aware)
Per order line:
- **Units**: `netQty = max(0, floor(quantity) - floor(refundedQty))`.
- **Weighted (kg)**: `netQty = max(0, round3(quantity - refundedQty))` (same rounding policy as Phase 4 `margin-analytics.js`).

## Net line revenue (margin-aligned)
For each included line:
\[
\text{netLineRevenue} = \text{roundMoney2}(\text{netQty} \times \text{unit line price})
\]
where `unit line price` is `Number(item.price) || 0`.

**Note:** This is **not** the same as `order.totalBill` when discounts, delivery, or tax-inclusive pricing differ. Reconciliation explicitly tracks **variance** between bill totals and net line revenue.

## COGS unit cost precedence (v2)
For each included line, **unit cost** is resolved in order:
1. **Ledger sale snapshot (windowed)**: weighted average of `unitCost` on `inventoryLedger` rows with `eventType === 'sale'`, valid `createdAt` in the **same analytics window** as orders, `unitCost > 0`, and `qtyDelta < 0`. Per SKU key `productId|variantId`: `avgUnitCost = sum(|qtyDelta| * unitCost) / sum(|qtyDelta|)`.
2. **Moving average v1** from purchases (and ledger `purchase` only when no purchase lines exist), as in Phase 4.
3. **Heuristic**: `salePrice * 0.65` (variant or product) when no cost basis exists.
4. **Missing**: `unitCost = 0`, `quality = missing`.

Then:
\[
\text{lineCOGS} = \text{roundMoney2}(\text{netQty} \times \text{unitCost})
\]

## Gross profit and margin %
\[
\text{grossProfit} = \text{roundMoney2}(\sum \text{netLineRevenue} - \sum \text{lineCOGS})
\]
\[
\text{marginPct} = \begin{cases}
0 & \text{if } \sum \text{netLineRevenue} \le 0 \\
\text{roundMoney2}(100 \times \text{grossProfit} / \sum \text{netLineRevenue}) & \text{otherwise}
\end{cases}
\]

## VAT (reporting)
- **VAT card**: sum of `readOrderAmountSnapshot(order).tax` over orders in the **date + channel** window (line filters do not exclude VAT from an order).

## Refunds (payment ledger)
- **Refund card**: sum of `paymentLedger[].refundAmount` where `kind === 'refund'` for orders in the **date + channel** window.

## Shrinkage cost (windowed)
Sum over ledger rows with `createdAt` in `[startMs, endMs]`:
- `eventType === 'wastage'`, or
- `eventType === 'adjustment'` and `qtyDelta < 0`.

Cost per row: `abs(qtyDelta) * unitCost` using row `unitCost` if valid, else Phase 4 fallback map.

## Rounding
- Money: 2 decimals (`roundMoney2`).
- Weight: 3 decimals where applicable (weighted SKUs).

## Related code
- Core math: [f:/14/margin-analytics.js](f:/14/margin-analytics.js)
- Day-close and CSV helpers: [f:/14/finance-analytics-v5.js](f:/14/finance-analytics-v5.js)
- Rollout: [f:/14/PHASE5_ROLLOUT.md](f:/14/PHASE5_ROLLOUT.md)
