PnL Metrics
PnL is computed at totals grain (sums of sales, fees, COGS), with MB commission resolved per-ASIN and aggregated up. Sales and Amazon fees come from actuals in the SKU Economics report; landed COGS, FBM shipping fees, and MB commission are sourced from dynamic attributes (COGS and FBM fees per SKU; commission at ASIN/seller level), not from the report — see SKU costs via dynamic attributes and MB commission.
Source of truth
| Metric | Source | Type |
|---|---|---|
pnl_sales | rpt_pnl_sku_economics.sales | L1, SUM, required |
pnl_unit_price | rpt_pnl_sku_economics.average_sales_price | L1, SUM (per-row; see caveat) |
pnl_landed_cogs_amt | SkuCostsEnricher — landed_cogs dynamic attribute (per-unit, SKU-level) × units_sold | Computed, SUM, required |
pnl_referral_fee_amt | rpt_pnl_sku_economics.referral_fee_total | L1, SUM |
pnl_fba_fee | rpt_pnl_sku_economics.fba_fulfillment_fees_total | L1, SUM |
pnl_fbm_shipping_fees_amt | SkuCostsEnricher — fbm_shipping_fees dynamic attribute (per-unit, SKU-level, FBM listings only) × units_sold | Computed, SUM, required (FBM SKUs) |
pnl_sponsor_products_charge_total | rpt_pnl_sku_economics.sponsored_products_charge_total | L1, SUM |
pnl_mb_commission_amt | MbCommissionEnricher (see below) | Computed, SUM, required |
pnl_pre_ad_margin | formula | L2, Recalculate |
pnl_pre_ad_margin_pct | formula | L2, Recalculate |
pnl_contribution_margin | formula | L3, Recalculate |
pnl_contribution_margin_pct | formula | L3, Recalculate |
pnl_breakeven_tacos | formula (= pnl_pre_ad_margin_pct) | L3, Recalculate |
pnl_tacos | formula (= pnl_sponsor_products_charge_total / pnl_sales) | L3, Recalculate, lower_is_better |
lower_is_better: the cost lines (pnl_landed_cogs_amt, pnl_referral_fee_amt, pnl_fba_fee, pnl_fbm_shipping_fees_amt, pnl_sponsor_products_charge_total, pnl_mb_commission_amt) and pnl_tacos carry lower_is_better: true in the YAML — a presentation flag surfaced through the catalog so consumers colour a rising value as worse (a higher cost / TACoS reads red). It does not affect computation. Note pnl_breakeven_tacos is the margin ceiling (higher = more ad headroom) so it is not flagged; pnl_tacos is the realised ad-cost ratio, which is.
rpt_pnl_sku_economics is the Amazon "Selling Economics and Fees" report — uploaded per (seller, marketplace, period), keyed by (seller_id, marketplace, period_start_date, period_end_date, period_granularity, msku, asin). Supported granularities: weekly, monthly, quarterly.
Tax handling for UK and Canada
For PnL, use the SKU Economics Report sales value as revenue without deducting tax again. The SKU Economics Report is already tax-exclusive, so applying a second tax deduction would understate revenue.
Core source rule:
| Source | Tax treatment | PnL handling |
|---|---|---|
| SKU Economics Report | Tax-exclusive | Use sales as-is for revenue |
| All Orders Report | Tax-inclusive | Deduct tax before using as PnL revenue |
UK (Amazon.co.uk)
- Buyer-paid VAT flows through the seller account and is remitted to HMRC by the seller.
- VAT is a balance-sheet liability, not seller revenue and not an operating expense.
- Because the SKU Economics Report already excludes VAT, do not deduct VAT again in PnL.
Canada (Amazon.ca)
- Buyer-paid GST/HST/PST is collected and remitted directly by Amazon under the marketplace facilitator model.
- The tax never becomes seller revenue, expense, or liability.
- Because the SKU Economics Report already excludes GST/HST/PST, do not deduct tax again in PnL.
This treatment assumes the operator-entered cost lines (pnl_landed_cogs_amt, pnl_fbm_shipping_fees_amt) are also tax-exclusive. If a tax-inclusive source such as the All Orders Report is used instead of the SKU Economics Report, add an explicit tax-removal step before calculating PnL revenue.
Formulas
pnl_landed_cogs_amt = SUM( landed_cogs_per_unit(sku, range) * units_sold ) ← enricher, per-row
pnl_fbm_shipping_fees_amt = SUM( fbm_fee_per_unit(sku, range) * units_sold ) ← enricher, per-row, FBM SKUs only
pnl_mb_commission_amt = SUM( compute_mb_fee(config_per_asin, sales) ) ← enricher
pnl_pre_ad_margin = pnl_sales
- pnl_landed_cogs_amt
- pnl_referral_fee_amt
- pnl_fba_fee
- pnl_fbm_shipping_fees_amt
- pnl_mb_commission_amt
pnl_pre_ad_margin_pct = pnl_pre_ad_margin / pnl_sales * 100
pnl_contribution_margin = pnl_pre_ad_margin - pnl_sponsor_products_charge_total
pnl_contribution_margin_pct = pnl_contribution_margin / pnl_sales * 100
pnl_breakeven_tacos = pnl_pre_ad_margin_pct
pnl_tacos = pnl_sponsor_products_charge_total / pnl_sales * 100
All L2/L3 metrics use Recalculate: their source components are SUMmed first during aggregation, then the formula re-evaluates on the summed components. This is what makes ratios correct across grains (you can't average pre-ad-margin-percent across ASINs; you must sum margin and sales separately and divide at the requested grain).
SKU costs via dynamic attributes
Dynamic attributes are the source of truth for landed COGS and FBM shipping fees. Both are per-unit costs the operator enters per SKU; neither comes from the SKU Economics report. Amazon's report only carries a carried-forward COGS number (no weighted average, no per-period actuals), so the landed_cogs and fbm_shipping_fees dynamic attributes are canonical instead. SkuCostsEnricher computes both lines from them.
What this means in practice:
- Costs live on SKUs. Each is a changelogged dynamic attribute at
entity_type='sku', withreference_id= the SKU'srpt_all_listings.listing_id(the stable identity) andvalue_float= the per-unit cost. - PnL reports speak
seller_sku(themskucolumn onrpt_pnl_sku_economics), so the enricher bridges(seller_id, seller_sku) → listing_idviasku_to_listing_idand looks up each SKU's cost bylisting_id. The bridge resolves over all listings (active or deleted), because a cost persists on thelisting_idafter a listing is deleted — gating on active would drop cost for a deleted-but-still-selling SKU and inflate margin. - PnL uses the most recent cost in the time-frame asked. For each SKU, the enricher resolves the single most-recent attribute value in force within the queried range and multiplies it by each row's
units_sold.
FBM gating (FBA/FBM from All Listings)
FBM shipping fees are a real out-of-pocket cost only for SKUs the seller ships themselves, so they are subtracted only for FBM listings, gated on rpt_all_listings.fulfillment_type (fba | fbm):
- FBA SKU →
pnl_fbm_shipping_fees_amt = 0(the fee does not apply; never null). FBA units already carry their cost inpnl_fba_fee. - FBM SKU with a fee →
fbm_fee_per_unit × units_sold. - FBM SKU with no fee → null (a coverage gap; see required metrics).
handle_null: {replace: 0}counts it as 0 in the margin so it doesn't poison the formula, while the raw column stays null so the gap is surfaced.
The FBA/FBM column is the sole authority on whether the fee applies — a stale fbm_shipping_fees value left on a now-FBA SKU is ignored.
Resolution semantics (most-recent-in-range, carry-forward)
For each SKU the enricher picks the one attribute row whose effective_from is the latest that is still ≤ date_to (never a value that only takes effect after the window — the "in the requested range" guard), and whose effective_to is NULL or > date_from (not already superseded before the window began), carrying forward the most recent known cost.
Carry-back: if the queried window predates every value a SKU has (nothing is in force at date_to — e.g. an FBM fee entered effective 23 Jun, queried for March–May), the SKU's earliest-ever value is carried backward to cover the period instead of resolving to null. This mirrors carry-forward at the other end of time, so a changelog cost entered late still applies to historical periods. A SKU with no value at all stays null (a genuine coverage gap, flagged).
This is one value per SKU for the whole query window (range-level carry-forward, not per-period). It's deliberately simple: because the policy lives entirely in the enricher, it can later be swapped for per-period resolution without touching the metric contract, the REST/MCP transports, or the FE. Unlike MbCommissionEnricher (which ignores effective dates), this enricher honors the changelog.
Wiring
Both metrics carry no sources: — they're enricher: sku_costs with metric_refs: [pnl_units_sold]. SkuCostsSourceEnricher injects sku as an internal dimension (like MbCommissionSourceEnricher) so there are per-SKU rows to attach the per-unit costs to even when the query only groups by seller_id. Resolution needs the SUM over cost_per_unit × units_sold row-by-row (it cannot be SUM(cost_per_unit) × SUM(units_sold), which double-counts), which is exactly what the post-join enricher computes before collapse. The enricher computes only the cost columns the query actually requests — a COGS-only query never runs the FBM fulfillment lookup, and vice versa.
MB commission
Commission is a JSON config (StaticCommission | SalesRangeCommission) stored in dynamic_attributes_values.value_json at two keys:
asin_mb_commission_config(entity_type='asin') — per-ASIN overrideseller_mb_commission_config(entity_type='seller') — per-seller default
Resolution chain: ASIN override → seller default → None (the seller is resolved from the ASIN's brand via seller_brand_mapping; EQU-460 moved the default from brand to seller).
Static config
{"type": "static", "rate": 10}
Fee = sales * rate / 100, applied to every row.
Sales-range config
{
"type": "sales-range",
"tiers": [
{"min_sales": 0, "rate": 5},
{"min_sales": 1000, "rate": 12}
]
}
Tier-pick depends on query granularity:
- Monthly / quarterly → the enricher computes the seller-level period sales total (SUM of sales across all the seller's ASINs for the period) and picks the tier from that total. Mirrors how the seller is billed — every ASIN under the same (seller, period) gets the same effective rate.
- Weekly → the enricher applies the highest tier's rate to every row regardless of sales. Weekly seller totals rarely cross higher thresholds even though monthly billing would; using the top tier is the conservative-margin proxy. See the sales-range caveat below.
In both cases the per-row commission is row_sales × rate / 100. The rate is the only thing that varies with granularity.
The compute_mb_fee helper itself implements full bracket-pick on whatever total you pass — used by the sellers API for projections. The enricher only swaps in granularity-aware totals.
Math helper
compute_mb_fee(config, total_sales) in app/schemas/mb_commission.py is the single source of truth. The enricher reuses it; the sellers API reuses it. Don't reimplement.
Config-resolution semantics (V1: latest wins)
The metrics engine ignores effective_from / effective_to and uses the most-recently-set config for each ASIN (override) or seller (default), regardless of when the query period falls. Helper: get_latest_mb_commission_batch in app/services/sellers/mb_commission.py.
- Setting a 10% config today, then querying April PnL, applies 10% to April.
- If you later set a 12% config (newer
effective_from), every PnL query — historical and current — switches to 12%.
This trades historical accuracy for not requiring explicit backdating when a seller's first config is created. Acceptable approximation for analytical PnL; if you need true historical rates (e.g. for invoice reconciliation), use get_effective_mb_commission(as_of=…) directly — it still does strict temporal matching and is what the sellers API calls for projections.
Known trade-off: changing the rate mid-period (or after-the-fact) silently re-prices historical PnL. We accept this until it bites; flag it as a follow-up if it does.
Required metrics + data-coverage flagging
pnl_sales, pnl_landed_cogs_amt, pnl_fbm_shipping_fees_amt, and pnl_mb_commission_amt are marked required: true. When a required metric is null for a row, the engine's required-metric validation step (run post-join, pre-collapse, at the SKU source-row grain) flags the row in an error_rows manifest — it does not drop the row or abort the query. Each entry names the affected entity (seller_id, msku, asin, …) and the missing metric(s):
error_rows: [
{ "seller_id": "…", "msku": "RNG_Pureflex1+45ft", "asin": "B0B1JLTSXL",
"missing_metrics": ["pnl_fbm_shipping_fees_amt"] },
…
]
This drives the data-coverage card: the row still appears in the result (so the FE can highlight the missing cell), and the manifest tells the operator exactly which SKUs need data entered — a landed_cogs value, an FBM SKU's fbm_shipping_fees, an MB commission config, etc. The MCP layer surfaces the same manifest as a structured missing_metrics list.
Because the cost lines also carry handle_null: {replace: 0}, a missing value is both flagged (so the gap is visible) and treated as 0 in the margin formula (so one missing cost doesn't null every downstream margin). The two act at different pipeline stages: flagging reads the raw enricher output post-join; the null-fill runs later, inside aggregation, before the Recalculate formulas.
FBM is flagged only where it's a real gap. The enricher emits 0 (not null) for FBA SKUs, so they are never flagged — only a merchant-fulfilled SKU with no fbm_shipping_fees value surfaces as missing.
See Seller Metrics Engine — required-metric validation.
Conservation of mass
PnL should hold the same totals across any dimension grain. With a Beauticone test seed (static 10% MB commission, monthly Apr 2026):
dimensions= | rows | pnl_sales | pnl_mb_commission_amt | pnl_pre_ad_margin |
|---|---|---|---|---|
[] | 1 | 12,809.59 | 1,280.96 | 4,327.65 |
[seller_id] | 1 | 12,809.59 | 1,280.96 | 4,327.65 |
[parent_asin] | 5 | 12,809.59 | 1,280.96 | 4,327.65 |
[child_asin] | 9 | 12,809.59 | 1,280.96 | 4,327.65 |
[sku] | 15 | 12,809.59 | 1,280.96 | 4,327.65 |
Sales-range tiers ([{0,5%},{1000,12%}]): per-ASIN math holds (each ASIN's fee = its sales × its tier rate), and totals sum cleanly.
Querying
# Seller-level monthly PnL
QueryRequest(
metrics=[
"pnl_sales", "pnl_landed_cogs_amt", "pnl_referral_fee_amt",
"pnl_fba_fee", "pnl_fbm_shipping_fees_amt", "pnl_mb_commission_amt",
"pnl_pre_ad_margin", "pnl_pre_ad_margin_pct", "pnl_contribution_margin"
],
dimensions=["seller_id"],
granularity="monthly",
date_range_start="2026-04-01", date_range_end="2026-04-30",
seller_id="AGBDRQ8IA12WY", marketplace="US",
)
For SKU/ASIN slicing add dimensions=["sku"] or dimensions=["child_asin"]. For named-ASIN filtering, call metrics_list_seller_asins first to resolve normalized names → ASINs, then pass filters={"child_asin": [...]}.
Caveats
MB commission sales-range: weekly uses highest tier
Bracket-based sales-range configs are problematic at sub-monthly granularity. Example: a seller with tiers <10k = 3%, >=10k = 6%.
- A weekly PnL query rarely sees any single (seller, week) cross $10k — even though monthly billing crosses the threshold and bills at 6%. Picking the tier from weekly totals would always apply 3%, under-counting the commission the seller actually pays.
- The "right" model is to know the seller's projected monthly total and apply the corresponding tier rate proportionally across weeks. We don't have projection signals.
Current behavior:
- Monthly / quarterly → tier picked from the seller-level period sales total (matches billing semantics). This is the accurate case.
- Weekly → fall back to the highest tier's rate applied to every row. Conservative-margin proxy; over-counts when the seller is genuinely below the top tier monthly.
Static configs are unaffected by granularity in either case.
Halo effect on SKU-level ad cost
Amazon's ad reports attribute ad spend to the advertised SKU, but a customer who clicks an ad for SKU xyz may end up buying a related SKU abc (different variant, different size). The SKU Economics report shows xyz's ad cost as $X and abc's ad cost as $0, even though abc's sale was driven by xyz's ad.
At SKU grain this distorts PnL: abc looks artificially profitable (real sales, zero ad attribution) while xyz looks artificially unprofitable (ad cost but no purchase). pnl_contribution_margin at SKU level is not reliable until halo attribution is modeled (cross-SKU ad-spend redistribution by family/parent ASIN).
Workaround until then: aggregate to parent_asin or child_asin grain when looking at contribution margin — the halo attribution issue cancels out within a family.
Other caveats
pnl_unit_pricerolls up as SUM-of-AVG when crossing periods/SKUs. Acceptable for V1; weighted-avg is the correct long-term fix.pnl_contribution_marginsign: the registry literalpre_ad_margin + sp_chargewas identified as a typo (SP charge is a cost). Implementation uses subtraction. Re-verify if registry changes.- MB commission
effective_tosemantics: currently picks latest config as ofperiod_end_date. Intra-period config changes aren't prorated — querying at weekly grain tightens precision.
Related
- Seller Metrics Engine — overall pipeline + YAML reference
- Metrics Engine Enrichers — how MB commission and required-validation plug in
- Reporting Structure — upstream rpt_pnl_sku_economics ingestion