# Profit & Loss plugin

New plugin (`plugins/webkul/profit-loss`, namespace `Webkul\ProfitLoss`), built 2026-09-30. Beta —
captures product cost and a channel fee estimate per order; packaging/other cost have no data
source yet and stay blank until populated by hand (no editing UI exists yet) or a future
integration. Shipping cost is filled via a manual CSV upload — see below.

## Shipping cost: CSV upload (added 2026-10-01)

Real per-order postage/label cost (what the business paid Shopify Shipping to print the label,
distinct from what the customer was charged for delivery) is genuinely **not available from any
Shopify API** — it only exists in Shopify's own billing/invoice data. Shopify Admin → Settings →
Billing lets you export a "Charges" CSV covering that billing data, including a `shipping_fee` row
per order (`Order` column = Shopify's own `#1435`-style reference, `Amount` = the real label cost).

**"Import Shipping Costs"** header action on `/admin/profit-loss` (`Overview::getHeaderActions()`)
opens a modal, takes that CSV, and fills `profit_loss_order_costs.shipping_cost` for every order it
can match:

- Only `Charge category = shipping_fee` rows are used; `subscription_fee` and anything else are
  ignored.
- Every row for the same `Order` reference is **summed** — a label purchase plus a later "Shipping
  Label Price Adjustment" row both legitimately belong to the same order (confirmed against the
  real file: 11 real order references have exactly this two-row shape).
- Matched by exact string equality against `Order.external_channel_reference`, already stored in
  Shopify's own `#1435` format — no normalisation needed.
- **Each upload overwrites, not adds** — re-uploading a fresh export replaces `shipping_cost` for
  every order it mentions rather than accumulating on top of a previous upload. This is
  deliberate: Shopify's own export is a full historical re-export each time (confirmed against the
  real file — it spans many months back), not an incremental delta, so treating each upload as
  the complete authoritative picture for the orders it covers is the only semantics that works
  reliably across repeated uploads.
- Orders mentioned in the CSV with no matching Cydekick order (wrong store's export, or an order
  Cydekick never imported) are reported by reference in the result notification, capped at 15
  shown, rather than silently dropped.
- **Multiple shipping_fee rows flag the Shipping cell red** (added 2026-10-01). `shipping_charge_count`
  (new column, `profit_loss_order_costs`) records how many CSV rows were summed into that order's
  figure — 1 is the normal case; the Shipping column goes bold red with a tooltip when it's more
  than that, since real orders with >1 row are almost always a label purchase plus a later real
  "Shipping Label Price Adjustment" charge on top — worth a second look, not just another ordinary
  cost. Found via a real order, C1424 (£32.43 shipping — a £5.72 label + a £26.71 adjustment).
  Verified with a synthetic CSV reproducing that exact shape: landed on £32.43/count=2/red/tooltip,
  matching the real order precisely. A table filter ("Additional shipping charges", a toggle in
  the filters panel) finds the same orders directly — `shipping_charge_count > 1` — rather than
  needing to hunt for red cells across hundreds of paginated rows. Verified against real local
  data: matched exactly the one order given a `shipping_charge_count = 2`.
- **Duplicate label vs. courier surcharge — distinguished, not just counted** (added 2026-10-01).
  `shipping_charge_count > 1` alone doesn't tell you WHY — checked the real export (not guessed)
  and every multi-row order split cleanly into exactly two unrelated cases with no ambiguity:
  - A genuinely duplicate label purchase — two or more real carrier-service rows for the same
    order, e.g. C1024 ("DPD UK Next Day to Sidcup" billed twice) or C1016/C1076 (same pattern).
    Confirmed against the order's own Shopify timeline for C1024: the first label was purchased,
    then voided within ~8 minutes, then a replacement purchased — but Shopify billed for both
    anyway, since voiding a label only refunds it within the carrier's own cancellation window.
    This case is worth a human checking — the void may have been refundable and wasn't claimed.
  - A single real label plus a routine courier "Shipping Label Price Adjustment" row (a normal
    post-purchase reweigh/dimension surcharge, not a mistake) — e.g. C1397, C1387, C1386, C1316.
  - New `shipping_has_duplicate_label` boolean column (`profit_loss_order_costs`,
    `importShippingCosts()`) records which case it is: a row's `Description` containing "Price
    Adjustment" is never counted as a real label purchase, so the flag only goes `true` when 2+
    non-adjustment rows exist for the order.
  - First version coloured the Shipping amount itself red vs amber by case. Changed on request
    (2026-10-01): the Shipping amount now stays a single consistent **red + bold** whenever
    `shipping_charge_count > 1`, regardless of which case it is — the amount's job is just "this
    isn't an ordinary shipping cost, look closer", not "which case". A new `shipping_flag`
    Filament badge **pill** sits next to it and carries the "why": **"Multiple labels printed"**
    (red) for the genuine-duplicate case, **"Surcharged"** (amber) for the courier-adjustment
    case, rendered via a `getStateUsing()` that returns `null` for an ordinary order
    (`shipping_charge_count <= 1`) — a `null`/blank badge state renders nothing, not an empty
    pill (confirmed against `TextColumn::toEmbeddedHtml()`'s own `blank($state)` branch). The
    tooltip on the Shipping amount is unchanged — still names the specific reason on hover.
  - Verified against the real CSV via reflection on `importShippingCosts()`: a real duplicate-label
    order (C1016, two identical £4.42 rows) → Shipping red, pill "Multiple labels printed"/red; the
    same order relabelled with a real surcharge order's reference (C1397, one label + one Price
    Adjustment row) → Shipping red, pill "Surcharged"/amber; an ordinary order → Shipping
    uncoloured, no pill at all.
  - Third filter, **"Surcharged"** (added 2026-10-01), added as the mirror of "Duplicate label
    (needs checking)" — narrows to just the courier-adjustment case instead. Its query needs BOTH
    `shipping_has_duplicate_label = false` AND `shipping_charge_count > 1`, not the boolean alone —
    `shipping_has_duplicate_label` is written `false` for every ordinary single-label order too
    (importShippingCosts() only ever sets it `true` for a genuine duplicate; everything else,
    including the vast majority of normal orders with exactly one real label row, gets `false`),
    so a first version of this filter using only the boolean matched almost every order on the
    page. Caught via a real local-data test (an ordinary order with no multi-row shipping history
    at all showed up in the results) before it shipped; fixed by adding the charge-count condition,
    then re-verified against the same test data — only the genuine surcharge order matched.
- **Cancelled orders showed a fee and a profit figure they never actually had** (fixed 2026-10-01).
  Payment isn't authorised until dispatch, so an order cancelled before then was never actually
  charged a marketplace/payment-processing fee by the channel — and with no real sale having
  completed, "profit" isn't a meaningful figure for it either. Deliberately **not** the same as a
  return/refund after dispatch — payment WAS taken there, and the channel still charges its fee
  despite the refund; that's a different order status, untouched by this. Found via a real order,
  C1360 — cancelled, but still showing a captured £0.57 Payment Fee and a £12.67 Est. Profit left
  over from before it was cancelled.
  - **Display-time fix** (`Overview.php`, `isCancelled()`) — Marketplace Fee, Payment Fee, and
    Est. Profit all show "—" for a cancelled order regardless of whatever's stored, so an order
    that was already cancelled after a fee was captured fixes itself immediately, no backfill
    needed first.
  - **Capture-time fix** (`OrderCostCapturer::captureFeeEstimate()`) — skips estimating a fee for
    a cancelled order going forward, and explicitly *clears* `marketplace_fee`/
    `payment_processing_fee` back to null if a stale value is found, so re-running capture (the
    backfill command, or an order that gets cancelled after already being processed) actually
    scrubs the stale data rather than just leaving it in place for the display guard to keep
    masking forever.
  - Verified end-to-end against a real local order reproduced into C1360's exact shape (stale
    £0.57 fee, then cancelled): `estProfit()` and the fee columns both correctly return null
    while the stored value is untouched by display logic; a subsequent `captureForOrder()` call
    then correctly clears the stored fee fields to null.
- **Filters dropdown panel was rendering behind the sticky header** — a direct side effect of the
  sticky-header fix: both it and Filament's own filter-dropdown panel use a z-index in the same
  range, and the sticky rows won once they existed. Fixed with a scoped override
  (`.fi-ta-filters-dropdown .fi-dropdown-panel { z-index: 30; }` in `overview.blade.php`) — scoped
  to the filters dropdown specifically, not every dropdown on the page.
- File parsing (`fgetcsv`, handles the export's embedded commas inside quoted description fields
  correctly) and upload-path resolution are self-contained in `Overview.php` — deliberately not a
  new dependency on the `imports` plugin's heavier async-job infrastructure
  (`ImportManager`/`ImportJob`/queue worker), since a ~400-row file processes synchronously in
  under a second and doesn't need progress polling.

Verified against the real exported file (`z_notes/sheets/charges_export.csv`, 393 real
`shipping_fee` rows): ran the actual handler method against it, confirmed two real local orders
matched correctly with the exact right summed amount (e.g. `#1419` → C1019, £4.52), and separately
confirmed via direct CSV parsing that 11 real order references have two rows each (the adjustment
case) — the command's `$sums[$ref] += $amount` accumulation handles this correctly by
construction.

## To backfill the server

```
php artisan profit-loss:backfill-costs --dry-run
php artisan profit-loss:backfill-costs
```

Run the dry-run first to see the count, then drop the flag to actually write. Safe to re-run any
time — see "Idempotency" below for exactly what re-running does and doesn't change.

**Before it can do anything on a fresh server**: this is a brand new plugin, so it must be marked
"installed" in the `plugins` DB table before its migrations will run at all — `php artisan
migrate --force` will otherwise silently say "Nothing to migrate" with zero error output. This is
a real, separate gotcha from the usual `hasMigrations([...])` one — see "Second, separate cause"
in [plugin-migrations-not-registered.md](plugin-migrations-not-registered.md) for the exact tinker
snippet to mark it installed (or just click Install on its row in Connectors first).

## What it captures, and from where

**Product cost** (`profit_loss_order_line_costs`, one row per order line) — the linked product's
cost resolved via `Webkul\Pricing\Services\ProductCostResolver::resolveOrNull()`, the same cascade
every other cost-aware figure in the system trusts: a per-channel "cost location" average cost
when the channel's fee profile opts into one (none do today), otherwise the product's own flat
`cost` field — which is the real, always-populated cost source for the vast majority of the
catalog (see "Known limitations" below; `inventory_valuations` alone, the original source used
here, only covers a small fraction of real products).

**Channel fee estimate** (`profit_loss_order_costs`, one row per order, `marketplace_fee` for
eBay/Amazon or `payment_processing_fee` for Shopify) — computed from **today's** real, already-
configured `ChannelFeeProfile` (Pricing → Channel Fee Profiles — the same rates used for live
pricing calculations: Shopify 2%+£0.25, eBay 9.5%/3% above £750/£0.40 fixed/0.35% regulatory,
Amazon 15%/9% above £45/2% DST, as configured at the time of writing). See
`Webkul\ProfitLoss\Services\ChannelFeeEstimator` — **deliberately not** the same operation as
`Webkul\Pricing\Contracts\ChannelFeeCalculator::calculateFees()`, which solves the opposite,
circular problem (given a target margin, algebraically solve for what listing price a seller needs
to charge). Here the order's real total is already known, so `ChannelFeeEstimator` just applies
each platform's rate structure directly to it — the same trusted formulas
(`EbayFeeCalculator`/`AmazonFeeCalculator`/`ShopifyFeeCalculator`'s own "compute fee amounts from a
resolved price" step), fed the real known total instead of an algebraically-derived one. Verified
by hand against all three real configured rates before trusting it, including landing on the exact

£7.70 that `AmazonFeeCalculator`'s own docblock cites as verified against a real settlement report.

Deliberate simplifications (acceptable for a beta estimate, not for live pricing):
- **Order-level, not per-line** — one fee figure per order using the order's real `total_amount`.
  Avoids double-counting eBay/Amazon's per-order fixed fee across multiple lines (eBay's own
  `ChannelFeeCalculator` docblock: "assumes a single-item order") and avoids needing to pick one
  representative product/category out of a multi-line order.
- **Base rate only** — category-level overrides on the fee profile are ignored (no single product
  to resolve them against at order level).

## When it runs automatically (not just the backfill)

Two events, both handled by `Webkul\ProfitLoss\Listeners\CaptureOrderLineCosts` →
`Webkul\ProfitLoss\Services\OrderCostCapturer::captureForOrder()`:

- **`Webkul\ChannelOrders\Events\OrderImported`** — fires once at the end of every importer's
  `createFromPayload()` (`EbayOrderImporter`/`AmazonOrderImporter`/`ShopifyOrderImporter`), for
  every order **regardless of status** (open or already-processed) — so a brand new open order
  gets captured immediately, not just once it's fulfilled. Never fires for a historical import
  (`ShopifyOrderImporter`'s `historicalImport: true`).
- **`Webkul\ChannelOrders\Events\OrderStockDeducted`** — fires when an order later transitions to
  processed (from `OrderObserver::processOrderDeductions()`). Re-runs the same capture, so an
  order imported open today and processed next week gets its cost/fee **refreshed** at that point
  (current cost may have moved; the order's own total may have changed) — not left frozen at
  whatever was true on the day it was imported.

Both events are cheap/idempotent enough that an order that's already fulfilled at import time
firing both back-to-back in the same call is accepted as harmless rather than worth guarding
against.

## Idempotency / "manual" protection

Unlike a typical "only fill once" backfill, **both** product cost and fee estimate are refreshed
on every capture pass — not written once and left alone. The only thing that stops a refresh is an
explicit `cost_source`/`fee_source` of `'manual'` on the relevant row (`profit_loss_order_line_costs.cost_source`,
`profit_loss_order_costs.fee_source`) — there's no editing UI yet to actually produce a `'manual'`
value, but the guard is there and tested (`OrderCostCapturer` skips overwriting it) for whenever
one gets built. Everything else, including a value this same capturer wrote on a previous run, is
treated as an estimate that's safe and correct to recompute — re-running the backfill after a fee
profile's rate changes, or after inventory cost moves, is expected to change numbers, not a bug.

## VAT was inflating Est. Profit (fixed 2026-10-01)

Real bug, found via a real order (C1438: £7.50 total inc VAT, £1.25 of that is VAT, £6.25 real ex-
VAT revenue). Est. Profit was computing `total_amount − product cost − fees`, but `total_amount`
includes VAT the business collects on behalf of HMRC and owes them — it was never real revenue, so
leaving it in overstated every order's profit by exactly its VAT amount.

Fixed by subtracting `tax_amount` (Order's own always-calculated VAT total — see
`Order::recalculateTotals()`, never null) before the other costs in `estProfit()`. **Deliberately
not** switched to using `order.subtotal` instead of `total_amount − tax_amount`: `subtotal`
excludes delivery cost entirely (`recalculateTotals()`: `total_amount = subtotal + productTax +
deliveryTax + deliveryCost − discount`), so using it directly would have wrongly dropped real
delivery revenue from the calculation too. `total_amount − tax_amount` correctly backs out only
the VAT while keeping delivery revenue in — confirmed by hand against a real order with a non-zero
delivery cost.

The Total column itself is unchanged (still shows the real inc-VAT transaction value — what the
customer actually paid, same as everywhere else in Cydekick) — a new **VAT** column sits next to
it in the Revenue group so the deduction is visible, not hidden inside the Est. Profit math.

## Table layout (updated 2026-10-01)

The blue "Beta." explainer banner above the table was removed 2026-10-01 at the user's request —
the table itself (coverage labels, tooltips, column grouping) now carries enough of that context
inline that the standing banner was no longer needed.

Columns are grouped under five headers via Filament's `ColumnGroup`, left to right — all five
columns are grouped now (none left "ungrouped" the way Order/Source/Processed/Status originally
were, which also fixed a real sticky-header rendering bug, see below):

- **Order Info** (grey tint) — Order Date, Order, Source, Processed, Status.
- **Revenue** (green tint) — Total, VAT.
- **COGS** (red tint) — Product Cost only.
- **Operating Expenses** (red tint, same as COGS — one visual "cost" block, two labelled groups)
  — Marketplace Fee, Payment Fee, Packaging, Shipping. Shipping deliberately sits after Packaging,
  not first. Split out of a single "Expense" group into COGS + Operating Expenses 2026-10-01 at
  the user's request — COGS now means literally only product cost. **Other removed entirely**
  2026-10-01 — nothing in the codebase ever wrote to `other_cost` (no import, no automatic
  capture, no manual-entry UI, same as Packaging's own still-true state), so it was a permanently
  blank column. Also removed from `estProfit()`'s sum and `estProfitIsPartial()`'s completeness
  check — leaving it in the latter would have kept Est. Profit stuck "provisional" forever even
  once Shipping and Packaging were both genuinely known, for a field that can never become known.
  Verified: with shipping+packaging set and `other_cost` still null, `estProfitIsPartial()` now
  correctly returns `false`. The underlying `other_cost` column/model attribute is untouched, in
  case a future manual-entry UI ever needs it — just not surfaced or summed on this page.
- **P/L** (grey tint, same as Order Info, deliberately not its own color — requested explicitly)
  — Est. Profit.

- **Order Date** — `created_at`, the real date/time the order was placed (always known), distinct
  from **Processed** (`processed_at`, fulfilment date, blank for an open order).
- **Order** — the `C####` name, with the channel's own reference (e.g. Shopify's `#1441`,
  `external_channel_reference`) shown underneath.
- **Source** — the real marketplace's icon, resolved identically to how `OrderResource`'s own
  order header resolves it (`source_name ?? channel->platform->value`, falling back to plain text
  for 'manual' or an unrecognised platform) — see `sourceIconHtml()`. Requires `channel` eager-
  loaded on the base query (added alongside `profitCost`/`orderLines.profitCost`) to avoid N+1.
- **VAT** — `tax_amount`, see the VAT section above.
- **Est. Profit** no longer shows a visible `(partial)` suffix — that information moved to a
  hover tooltip (`estProfitIsPartial()`) instead, since the beta banner above the table already
  explains the "excludes unknown costs, never assumes zero" rule in prose (the banner itself was
  later removed — this page's own coverage labels/tooltips/grouping now carry that context).
- Each section carries a very light background tint (`.pl-col-info`/`.pl-col-revenue`/
  `.pl-col-expense`/`.pl-col-pl` in `overview.blade.php`) down the whole column, not just its
  shared header row — applied through `->extraHeaderAttributes()`/`->extraCellAttributes()` on
  every column and on each `ColumnGroup` itself. Low-opacity `rgba()`, not a flat hex, so it reads
  as "slightly different" on both light and dark panel themes.

### Scrollable body + sticky header (fixed 2026-10-01)

The table body scrolls within a fixed-height container (`.pl-scroll-body .fi-ta-content-ctn {
max-height: 70vh; overflow-y: auto; }`, targeting Filament's own wrapper class, confirmed against
the installed package). The header stays pinned while scrolling — but `position: sticky` was
originally applied to the `<thead>` element itself, which is inconsistently honoured across
browsers (`<thead>`'s default `display: table-header-group` doesn't reliably support sticky): the
symptom was exactly what that causes — the sticky header's bottom border disappearing mid-scroll
and a gap opening up between the two header rows (group-label row, column-label row) that scrolled
body content showed through.

Fixed by making each `<tr>` sticky individually instead of the `<thead>`: the group-header row
(`tr.fi-ta-table-head-groups-row`) sticks at `top: 0`, and the column-label row (the next `<tr>`)
sticks at `top: 2.75rem`. `box-shadow` draws the dividing line under the sticky rows rather than
`border-bottom`, since a plain border on a sticky table row is commonly clipped during scroll
repaint in a way `box-shadow` isn't.

A first attempt only set `height: 2.75rem` on the group-header row's `<th>`s and left it there —
still showed the gap. `height` on a table cell is only a soft *minimum* in table layout, not a
cap: the browser still grows the row to whatever its natural content/padding needs, so the row
rendered taller than 2.75rem while the second row's `top` stayed at 2.75rem, reopening the same
gap. Fixed properly by also pinning `padding`/`line-height` explicitly (`!important`, overriding
Filament's own Tailwind utility classes on `fi-ta-header-group-cell`) so the row's *natural* height
is already exactly 2.75rem, not just nominally capped.

## The beta page

`/admin/profit-loss` (`Webkul\ProfitLoss\Filament\Pages\Overview`) — a real Filament table
(`InteractsWithTable`/`HasTable`, not a plain capped blade table), since this is expected to grow
large: searchable by order number, sortable columns, genuine per-page loading via Livewire rather
than fetching everything upfront. Default sort: order number ascending. Shows open/processed/
cancelled orders. Est. Profit only renders once product cost **and** at least one fee figure are
known, marked `(partial)` while shipping/packaging/other remain unknown — it excludes unknown
components from the subtraction rather than assuming they're zero.

## Known limitations

- **Some orders showed a blank "Order" cell (no `C####` name)** — this was a real bug (not a
  limitation of this page), found via this page and fixed 2026-09-30. See
  [order-numbering.md](order-numbering.md) for the cause and the backfill command
  (`channels:backfill-order-numbers`) — separate from, and run independently of, anything in this
  plugin.
- **Whole open orders were missing from this page entirely** — also a real bug, also fixed
  2026-09-30. The table's base query originally required `whereHas('orderLines', ...
  whereNotNull('product_id'))`, silently excluding any order where every line's SKU is still
  unlinked to a Cydekick product — common for a recently-placed order. Removed entirely: a fee
  estimate only needs the order's own channel + total, not a linked product at all, so excluding
  the whole order lost real, available data. `BackfillOrderCostsCommand` had the identical
  restriction, fixed the same way. Product Cost still correctly renders "—" for a line/order with
  nothing to sum — `productCost()` already handled an empty result gracefully, this only ever
  needed the base query widened.
- **Product Cost was £0.00 for almost every line even though every product has a real cost** —
  the actual root cause, a genuine bug, fixed 2026-09-30 after the "no cost data recorded" label
  (below) exposed just how widespread it was: of ~20,500 products in the real production catalog,
  only ~300 have ever had a real stock-movement/AVCO event and therefore a row in
  `inventory_valuations` — but ~20,100 have a real flat `cost` value set directly on the product,
  which is the actual, always-populated cost source used everywhere else in the system
  (`Webkul\Pricing\Services\ProductCostResolver::resolve()` — used by the Pricing tab and
  `PricingEngine` — already falls back to `product.cost` for exactly this reason, since no channel
  fee profile has a "cost location" configured today). `OrderCostCapturer::captureLineCosts()` was
  reading `inventory_valuations` directly and *only* that, so the ~98% of the catalog with no AVCO
  history got no cost captured at all, even though the real, correct figure was sitting right on
  `product.cost` the whole time. Fixed by resolving cost through `ProductCostResolver::
  resolveOrNull()` (a new method added alongside the existing `resolve()`, preserving the
  null-vs-known-zero distinction `resolve()` intentionally collapses for pricing display) —
  same cascade, same source of truth, no more silent gap.
- **A marketplace order pushed into Shopify got a way-too-low fee estimate** — a real bug, fixed
  2026-09-30. An order can be *imported* through one channel but actually have been *sold* on a
  different real marketplace — e.g. an eBay order that reaches Cydekick via the Shopify order
  stream (Marketplace Connect / an eBay-to-Shopify pusher) has `channel_id` pointing at the
  Shopify integration channel, but `ShopifyOrderImporter::resolveSourcePlatform()` already tags
  its real origin on `source_name` (e.g. `'ebay'`). The old code estimated fees straight off
  `$order->channel` — the Shopify channel's own card-processing rate (~2%+£0.25) — instead of the
  real marketplace's commission (eBay: ~9.5%+£0.40 fixed+0.35% reg.), understating the fee by a
  large margin. Found via a real order, C1437 (£9.11 total showing a £0.43 "Payment Fee" — exactly
  Shopify's formula, not eBay's). Fixed in `ChannelFeeEstimator::estimateForOrder()`
  (`OrderCostCapturer` now calls this instead of `estimate($order->channel, ...)` directly): when
  `source_name` names a real marketplace different from the importing channel's own platform, it
  looks up this company's actual Channel for that platform (`Channel::where('platform', ...)`)
  and fees off that channel's profile instead — landing in `marketplace_fee`, not
  `payment_processing_fee`, once fixed. Falls back to the importing channel when no sibling
  channel exists for that platform.
- **Both Marketplace Fee and Payment Fee showed populated at once on the same order** — a direct
  follow-on bug from the fix above, fixed the same day once spotted on C1437: eBay doesn't have a
  separate "payment fee" at all — Managed Payments processing is bundled into their one Final
  Value Fee, so `marketplace_fee`/`payment_processing_fee` are meant to be mutually exclusive per
  order. `captureFeeEstimate()` only ever wrote the one field the current estimate resolved to, so
  a stale `payment_processing_fee` written by an earlier, wrongly-resolved capture (back when
  C1437 was still mis-priced as a Shopify order, before the fix above) was never cleared once the
  order started correctly resolving to `marketplace_fee` — both fields sat there together,
  double-counting in `estProfit()`. Fixed: every capture now explicitly nulls out whichever of the
  two fields it did NOT just set.
- **Order numbers can look chronologically out of sequence** — e.g. an order dated weeks ago
  showing a higher `C####` than one created today. This is the accepted, documented consequence of
  the order-numbering backfill described above (an old order's number was only assigned when the
  backfill ran, not when the order actually happened) — see order-numbering.md's "accepted,
  unavoidable quirk" note. Not a bug, just worth knowing when the default (order-number-descending)
  sort on this page looks like it jumps around.
- **No shipping/packaging/other cost source at all** — `profit_loss_order_costs` has the columns,
  nothing populates them yet. `EbayClient`/`AmazonClient`/`ShopifyClient` were checked; none of
  them capture real courier cost or a general "other" cost anywhere today.
- **Orders with `channel_id = NULL` never get a fee estimate** — a known pre-existing data gap on
  some historical orders (same one found during the eBay/Amazon tax-backfill work earlier this
  session), not something this plugin can fix; no channel means no way to resolve a fee profile.
  `profit-loss:backfill-costs` reports these separately ("skipped for fees: N with no channel")
  rather than silently pretending nothing's wrong.
- **Historical Shopify-imported orders have no product cost** — orders brought in via
  `channels:import-historical-shopify-orders` deliberately never deduct real stock (see that
  command's own note), so there's no `inventory_valuations` movement tied to them to read a cost
  from at the time of the real sale. Their Product Cost will show `£0.00`-ish or reflect *today's*
  cost if a product's cost hasn't changed since — not a true historical figure.
- **No manual-entry UI** — the `'manual'` guard described above exists in the schema and code but
  nothing in the admin panel lets a person actually set a value that way yet.
- **Open orders could be buried off page 1** — fixed 2026-09-30. The page originally defaulted to
  `id desc`, but `id` reflects when a row was *inserted* into this table, not the order's real
  date. A bulk historical import (see order-numbering.md) inserts hundreds of old orders in one
  batch, all landing with ids newer than genuinely-recent live orders imported just before that
  batch ran — so a real open order (e.g. C1040/C1039) could sort below a whole page of old
  historical rows and never appear on page 1. Fixed by ordering the base query directly: open
  orders always sort first (still actionable), everything else falls back to its real
  `processed_at` date. A status filter was also added so any status can be viewed explicitly
  without depending on sort/pagination at all.
- **Product Cost can look "too low" on a big multi-line order** — not a bug in the capture/sum
  logic; `captureLineCosts()` only has a cost to look up for a line with a linked `product_id` —
  an unmapped SKU (not yet in `channels_sku_mappings`) is skipped entirely, not counted as £0, so a
  30-line order where only a few SKUs are mapped will correctly show a low sum. Found via a real
  report on order C1041 (~30 lines, most unmapped). The Product Cost column now shows a small
  "`N of M lines linked to a product`" note whenever coverage is incomplete, so this is visible at
  a glance instead of looking like silently-wrong arithmetic.

## Files

- `plugins/webkul/profit-loss/src/ProfitLossServiceProvider.php` — migrations list, relations
  (`Order::profitCost`, `OrderLine::profitCost`), event listener registration, console command
  registration. Depends on `channel-orders` and `pricing`.
- `plugins/webkul/profit-loss/src/ProfitLossPlugin.php` — Filament nav registration (own
  top-level sidebar group, `heroicon-o-banknotes`).
- `plugins/webkul/profit-loss/src/Models/{OrderCost,OrderLineCost}.php`
- `plugins/webkul/profit-loss/src/Services/{OrderCostCapturer,ChannelFeeEstimator}.php`
- `plugins/webkul/profit-loss/src/Listeners/CaptureOrderLineCosts.php`
- `plugins/webkul/profit-loss/src/Console/Commands/BackfillOrderCostsCommand.php`
- `plugins/webkul/profit-loss/src/Filament/Pages/Overview.php` + its blade view
- `plugins/webkul/channel-orders/src/Events/{OrderImported,OrderStockDeducted}.php` — the two
  capture-trigger events, in `channel-orders` since that's where `Order`/`OrderObserver` live, not
  in `profit-loss` itself.
- `public/svg/profit-loss.svg` — the Connectors/Plugin Manager card icon.
