Skip to main content

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

MetricSourceType
pnl_salesrpt_pnl_sku_economics.salesL1, SUM, required
pnl_unit_pricerpt_pnl_sku_economics.average_sales_priceL1, SUM (per-row; see caveat)
pnl_landed_cogs_amtSkuCostsEnricherlanded_cogs dynamic attribute (per-unit, SKU-level) × units_soldComputed, SUM, required
pnl_referral_fee_amtrpt_pnl_sku_economics.referral_fee_totalL1, SUM
pnl_fba_feerpt_pnl_sku_economics.fba_fulfillment_fees_totalL1, SUM
pnl_fbm_shipping_fees_amtSkuCostsEnricherfbm_shipping_fees dynamic attribute (per-unit, SKU-level, FBM listings only) × units_soldComputed, SUM, required (FBM SKUs)
pnl_sponsor_products_charge_totalrpt_pnl_sku_economics.sponsored_products_charge_totalL1, SUM
pnl_mb_commission_amtMbCommissionEnricher (see below)Computed, SUM, required
pnl_pre_ad_marginformulaL2, Recalculate
pnl_pre_ad_margin_pctformulaL2, Recalculate
pnl_contribution_marginformulaL3, Recalculate
pnl_contribution_margin_pctformulaL3, Recalculate
pnl_breakeven_tacosformula (= pnl_pre_ad_margin_pct)L3, Recalculate
pnl_tacosformula (= 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:

SourceTax treatmentPnL handling
SKU Economics ReportTax-exclusiveUse sales as-is for revenue
All Orders ReportTax-inclusiveDeduct 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', with reference_id = the SKU's rpt_all_listings.listing_id (the stable identity) and value_float = the per-unit cost.
  • PnL reports speak seller_sku (the msku column on rpt_pnl_sku_economics), so the enricher bridges (seller_id, seller_sku) → listing_id via sku_to_listing_id and looks up each SKU's cost by listing_id. The bridge resolves over all listings (active or deleted), because a cost persists on the listing_id after 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 SKUpnl_fbm_shipping_fees_amt = 0 (the fee does not apply; never null). FBA units already carry their cost in pnl_fba_fee.
  • FBM SKU with a feefbm_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 override
  • seller_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=rowspnl_salespnl_mb_commission_amtpnl_pre_ad_margin
[]112,809.591,280.964,327.65
[seller_id]112,809.591,280.964,327.65
[parent_asin]512,809.591,280.964,327.65
[child_asin]912,809.591,280.964,327.65
[sku]1512,809.591,280.964,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_price rolls up as SUM-of-AVG when crossing periods/SKUs. Acceptable for V1; weighted-avg is the correct long-term fix.
  • pnl_contribution_margin sign: the registry literal pre_ad_margin + sp_charge was identified as a typo (SP charge is a cost). Implementation uses subtraction. Re-verify if registry changes.
  • MB commission effective_to semantics: currently picks latest config as of period_end_date. Intra-period config changes aren't prorated — querying at weekly grain tightens precision.