# Channel pricing: cost source (average cost from a stock location) (2026-09-29)

Opt-in setting on a **Channel Profile** (company + channel), so a channel's calculated prices
can be based on what stock actually cost (a location's moving-average cost) instead of always
using the product's flat standard cost — applied automatically to every product priced through
that profile on the next reprice/bulk update, no per-product setup needed.

**This was originally built per-product** (a picker on each product's own Pricing tab) before
the user clarified the real requirement: "This cost price selection needs to be in the channel
profiles so its run on a bulk reprice... This needs its own card." The per-product version was
fully removed and rebuilt at the Channel Profile level the same day — see "History" below if
you're reading old commits/docs that still describe the per-product design.

Motivating example: **ERR4686** (product id 2409) — flat `cost` is £1.26, but it was bought in
extra on a special and landed in the Somerset4x4 warehouse (location id 48) at a moving-average
cost of **£0.3333** per `inventory_valuations`.

---

## 1. How it works

- **Where the setting lives**: `pricing_channel_fee_profiles.cost_location_id` — nullable FK to
  `inventories_locations`, `nullOnDelete()`. Null for every profile unless a human explicitly
  picks a location on that profile's edit form (Pricing → Channel Profiles → edit → "Product
  Cost" card). No backfill on the migration.
- **Per channel-profile, not per-product.** One location choice applies to every product priced
  through that profile's channel — e.g. "always prefer the Somerset4x4 warehouse's average cost
  for anything priced on the Direct channel, where we have one." This is deliberately a policy
  decision made once per company+channel, not something curated product-by-product.
- **Per-channel isolation**: since a profile is channel-specific, opting in on one channel's
  profile has zero effect on the same product's price on any other channel — each channel
  resolves its own cost independently via whichever profile matches it.
- **The average cost data itself already existed** — `inventory_valuations` (model `Valuation`,
  `plugins/webkul/inventory-valuation`), maintained by a true moving-weighted-average-cost
  algorithm on every completed stock move. This feature is a new *consumer* of that table, not
  a new cost-tracking mechanism.
- **Fallback, always safe, per product**: if the selected location has no `inventory_valuations`
  row for a given product (no stock movement there yet) **or** has one with `average_cost <= 0`,
  that one product falls back to its own flat `cost` — a channel-wide setting doesn't require
  every product to have stock history at that location. A price can never go blank/broken/zero
  because of this feature. A literal `0.0000` average is deliberately treated the same as "no
  data": `PricingEngine` has its own pre-existing zero-cost guard that would otherwise turn a
  stored zero average into a real £0 price for a product with a perfectly good flat cost.
- **Automatic re-sync in both directions** — editing the flat `cost` field already auto-repriced
  every listed channel before this feature (`ProductCostObserver`, pre-existing, unrelated). This
  feature adds the matching case for its own setting: a change to a location's average cost
  (e.g. a new stock receipt) auto-reprices the *specific channel(s)* whose profile uses that
  location as its cost source (`ValuationCostObserver`) — not every channel the product is on,
  and not every product on the channel, just the one product whose valuation changed, for
  whichever channel(s) actually care.

## 2. Code

- **`plugins/webkul/pricing/src/Services/ProductCostResolver.php`** — the single place that
  decides "flat cost vs. location average cost, and why", now channel-aware:
  `resolve(Product $product, int $channelId)`. Internally resolves the matching
  `ChannelFeeProfile` via `ChannelFeeRegistry::resolveProfile()` (company-specific profile wins
  over a company-agnostic one, same matching rule `ChannelFeeRegistry::get()` already used for
  fees), reads its `cost_location_id`, then the same average-cost-or-fallback logic as before.
  Also `usingAverageCost()`/`fallbackReason()` for UI messaging, both also now channel-aware.
- **`ChannelFeeRegistry::resolveProfile(int $channelId, ?int $companyId): ?ChannelFeeProfile`**
  (new, extracted from `get()`'s existing matching query) — `ProductCostResolver` and `get()`
  both call this one method, so which profile is "the" cost/fee source for a given
  product+channel can never drift apart between the two.
- **`PricingEngine::calculate()`** — the *only* place cost previously entered the whole pricing
  pipeline now reads `$cost = $this->costResolver->resolve($product, $channelId);` ($channelId
  was already a parameter). Nothing else in the file changed — markup, fees, tax all already
  consumed `$cost` generically.
  - **Why `ProductCostResolver` is its own class, not a method on `PricingEngine`**: that class's
    own docblock states it's "never called directly from controllers or Livewire for live
    display" — but the Pricing tab needs the *resolved* cost for display. Extracting the
    resolution logic keeps `PricingEngine` itself invoked only from the recalculation job, while
    both it and the UI share the exact same resolution logic via `ProductCostResolver`.
- **`plugins/webkul/pricing/src/Observers/ValuationCostObserver.php`** — on `Valuation::saved()`,
  finds every active `ChannelFeeProfile` whose `cost_location_id` matches the changed valuation's
  location (scoped to the product's own company, or a company-agnostic profile), and dispatches
  `RecalculateProductChannelPrice` (not `RecalculateProductAllChannels`) for just that one product
  on just those specific channel(s). Registered in `PricingServiceProvider.php` alongside the
  pre-existing `ProductCostObserver`.
- **`ChannelFeeProfileResource.php`** (Pricing → Channel Profiles edit form) — new **"Product
  Cost"** section, between "Profile details" and "Channel Fees": a `cost_location_id` Select,
  options built from the *profile's own selected company's* warehouses only
  (`Warehouse::lot_stock_location_id`, the same "warehouse's primary stock location" concept used
  elsewhere in this codebase for PO destinations) — reactive to the Company field (`->live()`
  added to `company_id`), so picking a different company refreshes the location list.
- **`ManagePricing.php`** (the product's Pricing tab) — the per-product "Cost source" picker,
  its supporting properties/methods, and `getCostProperty()` were all removed entirely. Cost is
  now resolved **per row** in `loadRows()` (`$costResolver->resolve($product, $channel->id)`,
  stored as `$row['cost_price']`) since it can genuinely differ per channel now — the blade's
  "Cost (ex tax)" column and the Alpine markup/landed-cost math both read `row.cost_price`
  instead of a single page-wide `$cost` value.
- **`Product::costLocation()`** and `pricing_cost_location_id` — fully removed from the Product
  model and dropped from `products_products` (migration
  `2026_09_29_000003_remove_pricing_cost_location_id_from_products_products_table.php`).

## 3. What deliberately did NOT change

- `RecalculateProductChannelPrice`, `RecalculateProductAllChannels`, `FlagAndQueueBulkRecalculation`,
  `MarkupCalculator`, `PricingRuleResolver`, any per-platform fee calculator, `ProductChannelPrice`,
  `PricingCalculationLog`, `TriggerReason`, `ChannelPricingOverride` — all already operate
  generically on whatever cost/result they're handed, no changes needed.
- The unrelated "Override" button on the Pricing tab (pins a manual fixed price, bypasses the
  engine entirely) — not the right extension point for this feature.

## 4. Safety / no-op proof for every profile that doesn't opt in

`cost_location_id` defaults to `null` for every profile (new column, no backfill), and
`ProductCostResolver::resolve()`'s only behavioural branch is gated on the *matched profile*
having a non-null `cost_location_id` — so for any channel whose profile doesn't opt in,
`resolve()` returns `(float) ($product->cost ?? 0)`, literally the same expression the code used
to inline. Verified in `tests/Unit/Pricing/PricingEngineCostResolutionTest.php` (9 tests) and
`tests/Feature/ChannelFeeProfileCostLocationFormTest.php` (3 tests):

- `resolve()` is a no-op when the matched profile has no `cost_location_id`.
- Opt-in against real data (product 2409 / profile 3, channel 6 / location 48): `resolve()`
  returns `0.3333`, `PricingEngine::calculate()`'s `costPrice` matches — including a
  same-test baseline assertion that the *same* channel, *before* opting in, still calculates
  off `1.26` exactly as before.
- **Per-channel isolation**: opting in channel 6's profile leaves channel 8 (same product, no
  matching profile) on flat cost — proves this is genuinely per-channel, not product-wide.
- Fallback: a location with no `inventory_valuations` row, and separately one with
  `average_cost = 0` — both fall back to the flat cost, never to 0 or an exception.
- Migration safety: `products_products.pricing_cost_location_id` no longer exists as a column at
  all (confirms the old per-product mechanism was actually removed, not just unused); zero
  profiles have `cost_location_id` set except where a test or a human explicitly opted in.
- `ValuationCostObserver` dispatches only `RecalculateProductChannelPrice` for the one matching
  channel, never for an unrelated channel on the same product.
- The "Product Cost" form card renders, saves, and clears correctly on the real edit page.

12 tests total, 35 assertions, all passing as of 2026-09-29. Broader regression check across the
whole suite also run clean (no other area affected).

## 5. History: the removed per-product version

Built first as `products_products.pricing_cost_location_id` (a picker on each product's Pricing
tab, `ManagePricing::updateCostLocation()`), with its own `ValuationCostObserver` keyed off
`Product::pricing_cost_location_id` directly. Fully removed the same day once the user clarified
the real requirement was profile-level, not per-product — see git history around
2026-09-29 for the original commit if archaeology is ever needed, but there should be no trace
of it left in the current codebase (migration reversed, model relation removed, UI removed,
observer rewritten). If you find a stray reference to `pricing_cost_location_id` on the
`products_products` table anywhere, it's dead and should be removed.

## 6. Known gaps / not built

- **Bulk visibility**: no list/filter showing which Channel Profiles currently have a cost
  location configured at a glance. Given there are typically few profiles per company (one per
  channel), this is much less pressing than it would have been for the per-product version.
- The location picker is scoped to the **profile's own selected company's** warehouses only —
  no cross-company cost sourcing, and a profile with no company selected yet shows no location
  options (pick a company first).
