# Pricing Plugin — Developer Reference

## Overview

The `pricing` plugin lives at `plugins/webkul/pricing/` with namespace `Webkul\Pricing\`.
It persists a calculated selling price per product per channel so the Channels plugin
(and any future integration) can read `pricing_product_channel_prices` without triggering
a live calculation.

---

## Multi-company & multi-channel scoping

Rules and fee profiles support two optional scoping dimensions:

| Field | Nullable? | Meaning when NULL |
|-------|-----------|-------------------|
| `company_id` (on `pricing_rules`) | Yes | Applies to all companies |
| `channel_id` (on `pricing_rules`) | Yes | Applies to all channels |
| `company_id` (on `pricing_channel_fee_profiles`) | Yes | Applies to all companies |

`channel_id` is a FK to `channels_channels.id` — this enables per-store pricing (e.g.
"Banwell Shopify" and "Somerset4x4 Shopify" can have different markup rules).

`ChannelFeeProfile.channel_key` is intentionally a **platform string** ('shopify', 'ebay', etc.)
not a channel_id — fees are platform-level and apply to all stores of that type.

**Somerset4x4 = `company_id = 2`.** There are two companies in the system.

### Specificity order (highest wins, all else equal)

| company_id | channel_id | Specificity |
|------------|------------|-------------|
| Set | Set | 1 — most specific |
| Set | NULL | 2 |
| NULL | Set | 3 |
| NULL | NULL | 4 — global fallback |

Within the same specificity level, lower `priority` number wins.
`brand_category` scope always beats single-scope at the same specificity + priority.

### How the resolver applies scoping

`PricingRuleResolver::resolve(Product $product, float $cost, int $channelId)`:

1. Queries `WHERE (company_id = product.company_id OR company_id IS NULL)`
2. AND `WHERE (channel_id = $channelId OR channel_id IS NULL)`
3. Orders by: `company_id IS NULL ASC`, then `channel_id IS NULL ASC`, then `priority ASC`
4. Separates candidates into `brand_category` vs single-scope buckets
5. Returns first hit from the `brand_category` bucket, falling back to single-scope

### How the fee registry applies company scoping

`ChannelFeeRegistry::get(string $channelKey, ?int $companyId)`:
- `$channelKey` is the **platform string** (e.g. 'shopify') from `$channel->platform->value`
- Queries `WHERE channel_key = ? AND is_active = true AND (company_id = ? OR company_id IS NULL)`
- Orders company-specific profiles before global ones
- Throws `RuntimeException` if no matching active profile is found

### Bulk recalculation scoping

`FlagAndQueueBulkRecalculation` respects both dimensions:
- If the changed rule has `channel_id`, only flags `product_channel_prices` rows with that channel_id
- If the changed rule has `company_id`, only flags products belonging to that company (via JOIN on `products_products.company_id`)
- If the changed fee profile has `company_id`, only flags products belonging to that company
- Fee profile changes look up all channel IDs matching `platform = profile.channel_key` (e.g. all Shopify stores)

---

## How rule matching works

**Priority:** lower number wins (e.g. priority 10 beats priority 20).

**Scope specificity:** when multiple rules match at the same priority number, a
`brand_category`-scoped rule beats a single-scope rule. The `PricingRuleResolver` separates
candidates into two buckets and returns the first `brand_category` hit, falling back to
single-scope only when no `brand_category` candidate exists.

**Brand matching** uses `LOWER(products_products.vendor) = LOWER(pricing_rules.brand_name)` —
case-insensitive. The brand name stored in the rule must match the vendor string exactly
(no fuzzy matching). There is no brands table; vendor is a free-text field on products.

**Tier matching:** `cost >= min_cost AND (max_cost IS NULL OR cost < max_cost)`.  
Tiers are loaded ordered by `min_cost ASC` and the first matching tier wins.

**No-match behaviour:** `PricingRuleResolver::resolve()` throws
`NoPricingRuleMatchedException` — never silently falls back to a zero price. The calling
job catches this, logs a warning, and clears the `needs_recalculation` flag to prevent
infinite re-queuing.

---

## Job / queue flow

```
Product.cost updated
  └─> ProductCostObserver
        └─> RecalculateProductAllChannels (default queue)
              └─> ChannelFeeRegistry::activeChannelIds()
                    — connected channels with active fee profiles + registered calculator
              └─> For each channel — PricingEngine::calculate() inline (not fanned out)
                    ├─> PricingRuleResolver::resolve(product, cost, channelId)
                    │     — picks rule + tier (company + channel_id scoped)
                    ├─> MarkupCalculator::calculate()     — applies markup
                    ├─> ChannelFeeRegistry::get(channel.platform, company_id)
                    │     — loads company+platform-scoped fee calculator
                    └─> ChannelFeeCalculator::calculateFees() — adds fee lines
              └─> ProductChannelPrice upserted per channel
              └─> PricingCalculationLog row inserted per channel
              └─> Shopify channels: SyncProductToChannels dispatched
                  eBay channels: NO price push (no bulk sync path exists)

PricingRule saved/deleted
PricingTier saved/deleted
ChannelFeeProfile saved/deleted
  └─> Observer calls FlagAndQueueBulkRecalculation::flagStaleOnly(entityId, type) — SYNCHRONOUS,
        just Phase 1 below (UPDATE needs_recalculation=true), no job dispatched. (2026-09-16 — see
        "Save no longer triggers a bulk reprice" below. Previously dispatched the full job on every
        save, which could queue 100k+ jobs and stack further if edited again before the queue drained.)

"Force Update All SKUs" button clicked (Channel Profiles AND Pricing Rules tables — both have one)
  └─> FlagAndQueueBulkRecalculation::dispatch(entityId, type, triggeredBy)  [ShouldBeUnique, 10s window]
        ├─ Phase 1: UPDATE needs_recalculation=true on affected price rows
        ├─ Supersede: mark any in-progress pricing_bulk_price_syncs rows for these channels as superseded=1
        │     (ensures only the newest batch can ever dispatch BulkPriceUpdateJob)
        ├─ Phase 2: create one pricing_bulk_price_syncs row per channel (remaining = productCount)
        └─ Phase 3: cursor-dispatch RecalculateProductChannelPrice per product × channel
              each job carries bulkSyncId → links to its pricing_bulk_price_syncs row
              └─> PricingEngine::calculate() → upsert ProductChannelPrice
              └─> completeBulkPhase():
                    atomically decrement remaining on pricing_bulk_price_syncs
                    if remaining == 0 AND NOT superseded AND NOT already dispatched:
                      → BulkPriceUpdateJob dispatched (Shopify GraphQL bulk price push ✓)
                    if superseded: skip BulkPriceUpdateJob (stale batch — newer one wins)
                    eBay channels: completeBulkPhase() called but BulkPriceUpdateJob is Shopify-only
              └─> Correction after cursor: relative GREATEST(0, remaining - delta) update
                    (NOT an absolute SET — preserves decrements already applied by in-flight workers)
```

---

## Save no longer triggers a bulk reprice (2026-09-16)

`ChannelFeeProfileObserver`, `PricingRuleObserver`, and `PricingTierObserver`
used to call `FlagAndQueueBulkRecalculation::dispatch()` on every
`saved`/`deleted` event — the exact same full flag-and-dispatch job the
"Force Update All SKUs" button triggers. For a profile/rule affecting a
large product set (seen in practice: 100k+), that meant every single save
queued a full recalculation batch, and making several small edits in a row
before the previous batch finished draining just stacked more full batches
on top of each other.

Fixed by splitting the job's Phase 1 (flag existing rows
`needs_recalculation = true` — cheap, synchronous, no queue involvement)
out from Phase 2 (the actual cursor-dispatch of individual
`RecalculateProductChannelPrice` jobs) into a new public entry point:

```php
FlagAndQueueBulkRecalculation::flagStaleOnly(int $entityId, string $type): void
```

The three observers now call this instead of `dispatch()` — saving a rule
or fee profile marks affected prices as stale and does **nothing else**.
Actual repricing only happens when the user explicitly clicks
**"Force Update All SKUs"** (unchanged — full flag + dispatch, on both the
Channel Profiles *and* Pricing Rules tables now — the latter didn't
previously have this button at all, since rules had no other way to
trigger a recalculation once the auto-dispatch was removed).

`flagStaleOnly()` reuses the exact same scoping logic as the full job via
two extracted private helpers — `applyRuleFilterToExisting()` (pre-existing)
and the newly-extracted `resolveChannelIdsForProfile()` /
`applyProfileFilterToExisting()` (fee profiles) — so the "which rows count
as affected" logic has one source of truth regardless of which path
(flag-only vs full dispatch) is calling it.

**Consequence worth knowing**: with no auto-dispatch, nothing else marks
prices stale either — the 5-minute `ProcessStalePricesCommand` safety net
only picks up rows already flagged `needs_recalculation = true`, it never
discovers newly-stale ones on its own. So after editing a rule/profile,
prices stay exactly as they were (old rates) until Force Update is clicked,
however long that is — confirmed as the desired behaviour, not a bug to
fix later.

---

## Zero-cost product protection

### Why this matters

**This is a live revenue-safety issue.** Without a guard, when `products_products.cost` is
`NULL` or `0`, the engine treats it as `£0.00` cost but still runs the full fee pipeline.
Fixed fees (postage, platform fees) then make the final price non-zero:

- Shopify website channel: £0.01 + £0.25 + £0.05 = **£0.31** final price (pushed live)
- eBay channel: postage £2.95 + eBay FVF + regulatory = **£4.42** final price

Products end up publicly visible at prices well below actual cost.

### Current implementation — short-circuit to £0 (2026-09-01)

**File:** `plugins/webkul/pricing/src/Services/PricingEngine.php`

After resolving the rule/tier, `PricingEngine::calculate()` checks cost and short-circuits before
running markup or fee calculators:

```php
if ($cost <= 0) {
    $emptyFees = new FeeBreakdown(totalFees: 0.0, feeLines: [], netAfterFees: 0.0);

    return new PricingResult(
        productId:        $product->id,
        channelId:        $channelId,
        costPrice:        0.0,
        markupApplied:    0.0,
        markupPercentage: 0.0,
        basePrice:        0.0,
        feeBreakdown:     $emptyFees,
        finalPrice:       0.0,
        ruleMatchedId:    $rule->id,   // resolver still ran — rule is populated
        tierMatchedId:    $tier->id,
        triggeredBy:      $triggeredBy,
        taxRateApplied:   null,
    );
}
```

This applies to **every channel** — Shopify, eBay, Default, and any future platform.

### What each channel does with the £0 result

The `PricingResult` flows through `RecalculateProductChannelPrice` exactly as a normal result
would — no special branching is needed. The normal post-calculate path handles it:

| Channel | What happens |
|---------|-------------|
| **Default** | £0 stored in `product_channel_prices` + `pricing_calculation_logs`. No push. |
| **Shopify** | £0 stored in `channels_product_prices` → `SyncProductToChannels` dispatched → £0 pushed to Shopify store. |
| **eBay** | £0 stored in `channels_product_prices`. **No push** — no live eBay price push path exists (see eBay gap below). |

The calculation log records `Cost £0.00 → Markup £0.00 → Fees — → Sell £0.00` with the matched
rule/tier, giving a full audit trail.

### When cost is later set

When a zero-cost product gets a real cost (PSP scrape finds it, or manual entry):
1. `ProductCostObserver` fires on the Eloquent `updated` event
2. `RecalculateProductAllChannels` is dispatched
3. `PricingEngine` now sees a valid cost → full pipeline runs
4. Shopify gets the real price pushed; eBay `channels_product_prices` is updated

No manual intervention needed.

---

### Fan-in reliability — three bugs fixed (2026-08-13)

**Bug 1 — Absolute correction overwrote in-flight decrements**

The original correction after the dispatch cursor did `UPDATE SET remaining = $dispatched` (absolute).
If workers had already started and decremented `remaining`, this reset their progress. The counter
could never reach zero → `BulkPriceUpdateJob` never fired. Fixed: now uses
`GREATEST(0, remaining - $delta)` where `$delta = productCount - actualDispatched` — a relative
adjustment that only removes the shortfall from the initial estimate.

**Bug 2 — Overlapping batches silently dropped each other's jobs**

`RecalculateProductChannelPrice::uniqueId()` was `"pricing_recalc_{product}_{channel}"`. If two
concurrent batches both dispatched a job for the same product+channel, only the first was accepted
by Laravel's uniqueness lock. The second batch's counter was never fully decremented →
`BulkPriceUpdateJob` for that batch never fired. Fixed: uniqueId is now
`"pricing_recalc_{product}_{channel}_{bulkSyncId}"` — each batch has its own unique namespace.
Non-bulk jobs (bulkSyncId = null) keep the original key and still coalesce.

**Bug 3 — No protection against stacked batches**

Every save/observer fire created a new batch. 5 saves × 20k products = 100k queued jobs, and
potentially 5 separate `BulkPriceUpdateJob` dispatches. Fixed: `FlagAndQueueBulkRecalculation`
now supersedes (`superseded = 1`) all in-progress sync rows for the affected channels before
creating a new batch. `completeBulkPhase()` checks `superseded` before claiming the dispatch
slot → only the most recent batch ever pushes to Shopify.

### eBay gap

`BulkPriceUpdateJob` is Shopify-only (exits immediately if `channel.platform !== Shopify`).
When a fee profile or rule changes, eBay prices are recalculated in `channels_product_prices`
but **never pushed to eBay**. No bulk eBay price sync path exists. Manual listing push is
currently the only way to update eBay prices.

### Bug 4 — ProcessStalePrices triggered full product syncs during bulk repricing (2026-08-13)

`ProcessStalePricesCommand` runs every 5 minutes and dispatches `RecalculateProductChannelPrice`
with trigger `Manual` for every row with `needs_recalculation = true`. `FlagAndQueueBulkRecalculation`
Phase 1 sets that flag on all affected rows before dispatching the bulk jobs. This meant:

1. Fee profile saved → Phase 1 flags N rows stale → Phase 2 dispatches N bulk jobs (trigger: `FeeProfileChange`)
2. `ProcessStalePrices` runs at the next 5-min mark, sees all N stale rows, dispatches N more jobs (trigger: `Manual`)
3. `Manual`-triggered jobs are not `isBulkReprice` → dispatch `SyncProductToChannels` per product
4. `SyncProductToChannels` is a **full product sync** (inventory, images, title, tags, metafields, price) — not just price
5. This ran in parallel with the bulk batch, doubling queue load and doing unnecessary work

Fixed: `ProcessStalePricesCommand` now checks `pricing_bulk_price_syncs` for any active
(non-superseded, not-yet-dispatched) rows before querying stale prices. Any channel with an active
bulk sync is excluded from the query — those products will be handled by `BulkPriceUpdateJob`.
`ProcessStalePrices` only picks up genuine stragglers (products that failed in the bulk batch or
were not covered by it).

**File:** `plugins/webkul/pricing/src/Console/Commands/ProcessStalePricesCommand.php`

**Key invariant:** `ProcessStalePrices` = safety-net catch-up only. `FlagAndQueueBulkRecalculation` = authoritative bulk path. Never both simultaneously on the same channel.

### ShouldBeUnique behaviour summary

| Job | Unique key | Window |
|-----|-----------|--------|
| `FlagAndQueueBulkRecalculation` | `bulk_recalc_{type}_{entityId}` | 10 s |
| `RecalculateProductChannelPrice` (bulk) | `pricing_recalc_{product}_{channel}_{bulkSyncId}` | 30 s |
| `RecalculateProductChannelPrice` (non-bulk) | `pricing_recalc_{product}_{channel}` | 30 s |

The queue is `default` for all pricing jobs (configurable via `config/pricing.php` → `queue`).
`BulkPriceUpdateJob` is dispatched to `high` queue.

---

## How to add a new sales channel (eBay, Amazon, etc.)

1. **Create a Channel record** in Admin › Channels (sets platform to 'ebay', 'amazon', etc.)

2. **Create a fee calculator** in `src/Services/`:

```php
class EbayFeeCalculator implements ChannelFeeCalculator
{
    public function __construct(private readonly ChannelFeeProfile $profile) {}

    public function getChannelKey(): string { return 'ebay'; }

    public function calculateFees(float $sellingPrice, Product $product): FeeBreakdown
    {
        $override = $this->profile->getOverrideForCategory($product->category_id);
        $pct   = (float) ($override['pct']   ?? $this->profile->getBasePct());
        $fixed = (float) ($override['fixed'] ?? $this->profile->getBaseFixed());
        // ... build FeeBreakdown
    }
}
```

3. **Register in config:**

```php
// config/pricing.php
'channels'         => ['shopify', 'ebay'],
'fee_calculators'  => [
    'shopify' => \Webkul\Pricing\Services\ShopifyFeeCalculator::class,
    'ebay'    => \Webkul\Pricing\Services\EbayFeeCalculator::class,
    'manual'  => \Webkul\Pricing\Services\FlatFeeCalculator::class,
    'default' => \Webkul\Pricing\Services\FlatFeeCalculator::class,
],
```

4. **Seed a fee profile** (with company scoping if needed):

```bash
# Use the admin UI (Admin › Pricing › Channel Fees › New Profile)
# or add a default to SeedFeeProfilesCommand::$defaults and run:
php artisan pricing:seed-fee-profiles
```

5. **That's it.** `ChannelFeeRegistry::activeChannelIds()` returns connected channels that have
an active fee profile for their platform type, so the new channel is included automatically
once connected and its profile activated.

---

## Fee calculators by platform

| Platform key | Calculator | Notes |
|---|---|---|
| `shopify` | `ShopifyFeeCalculator` | Circular algebra — fee% on full buyer price inc. VAT + fixed. Shopify charges % on the total so VAT and fee interact simultaneously. |
| `ebay` | `EbayFeeCalculator` | Circular algebra — FVF lower/upper band + regulatory %, per-order fixed fee, shipping profile, markup-on-landed-cost. |
| `amazon` | `AmazonFeeCalculator` | Circular algebra — referral fee % with a per-item **minimum floor** (not an additive fixed fee), optional tiered rate above a threshold, category overrides, shipping profile, markup-on-landed-cost. FBM (merchant-fulfilled) only — no FBA fee support. |
| `manual` | `FlatFeeCalculator` | Circular algebra — fee% on full buyer price inc. VAT (e.g. card processing fee). Supports shipping profile and markup-on-landed-cost. Used for Direct/wholesale channels. |
| `default` | `FlatFeeCalculator` | Same as `manual`. Default channel normally has no fees configured, but registering the calculator allows fees to be added in future without code changes. |

### FlatFeeCalculator — circular algebra

`plugins/webkul/pricing/src/Services/FlatFeeCalculator.php`

Used for `manual` and `default` platforms. Mirrors Shopify's circular derivation:

```
P = (base + fixed) × (1+v) / (1 − pct × (1+v))
```

where `base = priceBeforeFees + postage` (or `(cost + postage) × (1 + markup%)` when `markup_on_landed_cost` is true) and `v = vatRate / 100`.

Supports shipping profiles identically to EbayFeeCalculator — link a shipping profile to the fee profile in the UI and postage is resolved from product weight and added as a fee line before the algebra runs.

### AmazonFeeCalculator — floor-based referral fee (2026-09-16)

`plugins/webkul/pricing/src/Services/AmazonFeeCalculator.php`

Amazon's referral fee is **not** "percentage + additive fixed fee" like
eBay's FVF — it's a percentage with a per-item **floor**:

```
referral_fee = MAX(price × pct%, min_referral_fee)
```

Below a certain price the fee is a flat minimum; above it, it's purely
percentage-based. Solved with the same closed-form piecewise technique
`EbayFeeCalculator` uses, just with the band boundary redefined as the
**crossover price** (where `price × pct` first exceeds `min_referral_fee`)
instead of eBay's £10 order-value boundary:

```
p = pct / 100, v = vatRate / 100, base = priceBeforeFees + postage
crossover = minFee / p

Band FLOOR (resolved P ≤ crossover): fee is flat, doesn't scale with P
    P = (base + minFee + fixed) × (1+v)

Band PCT (resolved P > crossover, ≤ upper-band threshold if configured):
    P = (base + fixed) × (1+v) / (1 − p × (1+v))

Band UPPER (P > threshold — optional tiered rate, rare for Automotive):
    correction = threshold × (p − pUpper)
    P = (base + fixed + correction) × (1+v) / (1 − pUpper × (1+v))
```

Solve Band FLOOR first; if the result is self-consistently ≤ crossover it's
used as-is (same self-consistency check as eBay's `if ($pA <=
self::LOW_ORDER_THRESHOLD)`), otherwise fall through to Band PCT, then Band
UPPER if configured.

`fee_structure` keys: `base.pct` (referral %), `base.fixed` (rare extra
fixed fee, usually 0), `min_referral_fee` (the floor — new key, only
Amazon uses it), `upper_band_pct`/`upper_band_threshold` (optional tiered
rate — reuses eBay's exact same keys, no new shape needed), `overrides`
(category-specific rates — Amazon's referral % varies significantly by
category, generic mechanism reused as-is).

`shipping_profile_id` and `markup_on_landed_cost` are shared with eBay in
the admin form (`ChannelFeeProfileResource`) — both concepts are
platform-agnostic, not eBay-specific, so their `hidden()` gating now
allows either platform.

**Scope**: FBM (merchant-fulfilled) only — matches every existing Amazon
configurator (`fulfillment_channel_code: DEFAULT`). FBA (Fulfilled by
Amazon) pick & pack / storage fees are a different, weight-tiered fee
schedule and are **not modelled** — deliberately deferred, same as the
`AmazonFeeCalculator` gap this replaces. Build it if/when an Amazon channel
actually switches to FBA.

### Pricing tab company scoping (2026-09-02)

`ManagePricing::loadRows()` previously loaded **all** channels globally, causing products to show pricing rows for channels belonging to other companies (e.g. a Somerset4×4 product showing Tool365 pricing).

Fix: channels are now filtered to only those with a `ChannelFeeProfile` for `$product->company_id`:

```php
$companyChannelIds = ChannelFeeProfile::where('company_id', $product->company_id)
    ->whereNotNull('channel_id')
    ->pluck('channel_id');

$channels = Channel::whereIn('id', $companyChannelIds)
    ->orderByRaw('is_system DESC')->orderBy('name')->get();
```

The `$feeProfiles` collection is similarly scoped by `company_id`. This relies on every company having its own `ChannelFeeProfile` rows — channels without a profile for the product's company are simply not shown.

---

## fee_structure JSON schema

```json
{
  "structure_type": "flat",
  "base": {
    "pct": 1.5,
    "fixed": 0.20
  },
  "overrides": [
    {
      "category_id": 12,
      "category_name": "Suspension",
      "pct": 2.5,
      "fixed": 0.30
    }
  ]
}
```

For tiered-by-price channels (e.g. Amazon where the % changes by price band), add a
`price_tiers` array alongside `base`. The channel's fee calculator reads whatever keys
it needs from `fee_structure` — the shape is flexible and no migration is required to
add new keys:

```json
{
  "structure_type": "tiered",
  "base": { "pct": 15.3, "fixed": 0.00 },
  "price_tiers": [
    { "max_price": 10.00, "pct": 8.0 },
    { "max_price": null,  "pct": 15.3 }
  ],
  "overrides": [...]
}
```

---

## Adding a competitor-price positioning layer (Phase 2)

The `PricingEngine::calculate()` method returns a `PricingResult` DTO. To add a
post-processing step that adjusts the final price based on competitor data:

1. Create a `PostProcessorInterface` with `process(PricingResult $result, Product $product): PricingResult`.
2. Inject it optionally into `PricingEngine` (nullable, default null).
3. Call `$postProcessor?->process($result, $product)` after fee calculation.
4. No changes needed to jobs, observers, or database schema.

---

## Artisan commands

```bash
# Recalculate a specific product × channel
php artisan pricing:recalculate --product=123 --channel=5

# Recalculate all channels for a product
php artisan pricing:recalculate --product=123

# Recalculate every product (full refresh — use sparingly)
php artisan pricing:recalculate

# Seed default Shopify fee profile
php artisan pricing:seed-fee-profiles

# Process all rows currently flagged needs_recalculation (safety-net / catch-up)
php artisan pricing:process-stale
php artisan pricing:process-stale --channel=5
```

---

## Migrations

All tables use the `pricing_` prefix. Registered in `PricingServiceProvider::configureCustomPackage()`.

| Table | Purpose |
|-------|---------|
| `pricing_rules` | Markup rule definitions (company + channel_id + brand/category scope, priority) |
| `pricing_tiers` | Cost-range tiers per rule |
| `pricing_channel_fee_profiles` | Channel fee config (company-scoped, platform-keyed, JSON fee_structure) |
| `pricing_channel_pricing_overrides` | Per-product manual price overrides (keyed by channel_id) |
| `pricing_product_channel_prices` | Current persisted prices (keyed by product_id + channel_id) |
| `pricing_calculation_logs` | Append-only audit trail of every recalculation |
| `pricing_bulk_price_syncs` | Fan-in counter rows — one per channel per bulk recalc batch. `remaining` decrements as jobs complete; `bulk_dispatched` prevents duplicate `BulkPriceUpdateJob` dispatches; `superseded` marks stale batches that should never fire their fan-in. |

### `pricing_bulk_price_syncs` schema

| Column | Type | Notes |
|--------|------|-------|
| `id` | bigint PK | |
| `channel_id` | unsignedInt | FK to `channels_channels.id` |
| `started_at` | datetime | Used by `BulkPriceUpdateJob` to find prices updated after this time |
| `remaining` | int | Atomically decremented by each `RecalculateProductChannelPrice` job |
| `bulk_dispatched` | boolean | Set to 1 atomically when `BulkPriceUpdateJob` is claimed |
| `superseded` | boolean | Set to 1 when a newer batch starts for the same channel; prevents stale fan-in firing |
| `created_at` | timestamp | |

Rows are deleted by `BulkPriceUpdateJob` in its `finally {}` and `failed()` blocks. Stale superseded rows with `bulk_dispatched=0` are never cleaned up automatically — safe to purge manually if they accumulate.

### Migration history

| File | What it does |
|------|-------------|
| `2026_07_02_000001_create_pricing_rules_table` | Initial `pricing_rules` table |
| `2026_07_02_000002_create_pricing_tiers_table` | Initial `pricing_tiers` table |
| `2026_07_02_000003_create_pricing_channel_fee_profiles_table` | Initial fee profiles table |
| `2026_07_02_000004_create_pricing_channel_pricing_overrides_table` | Per-product overrides table |
| `2026_07_02_000005_create_pricing_product_channel_prices_table` | Persisted prices table |
| `2026_07_02_000006_create_pricing_calculation_logs_table` | Audit log table |
| `2026_07_03_000001_add_company_id_to_pricing_tables` | Adds nullable `company_id` FK to `pricing_rules` and `pricing_channel_fee_profiles` |
| `2026_07_03_000002_add_channel_key_to_pricing_rules` | Added nullable `channel_key` string to `pricing_rules` (superseded by 000003) |
| `2026_07_03_000003_replace_channel_key_with_channel_id` | Replaces `channel_key` string with `channel_id` FK on `pricing_rules`, `pricing_product_channel_prices`, `pricing_calculation_logs`, and `pricing_channel_pricing_overrides` |
| `2026_07_16_000001_create_pricing_bulk_price_syncs_table` | Fan-in counter table for bulk reprice batches |
| `2026_08_07_000001_create_pricing_shipping_profiles_table` | Shipping profiles table |
| `2026_08_07_000002_create_pricing_shipping_tiers_table` | Shipping tiers table |
| `2026_08_07_000003_add_shipping_profile_id_to_fee_profiles` | Links fee profiles to shipping profiles |
| `2026_08_10_000001_add_markup_on_landed_cost_to_fee_profiles` | Adds `markup_on_landed_cost` flag |
| `2026_08_13_000001_add_superseded_to_bulk_price_syncs` | Adds `superseded` boolean to `pricing_bulk_price_syncs` — enables stale-batch protection |

When adding a new migration: create the file in `database/migrations/`, name it
`YYYY_MM_DD_NNNNNN_description.php`, then add the filename (without `.php`) to the
`hasMigrations([...])` array in `PricingServiceProvider`.

---

## Admin UI — Pricing Rules form layout

```
Row 1 (6 cols): Company (2) | Rule name (2) | Priority (1) | Status toggle (1)
Row 2 (6 cols): Channel (2) | Scope toggle (2) | Brand (1) | Category (1)
```

- **Company** — required. Select from `companies` table. Rules are resolved per product's `company_id`.
- **Channel** — optional (placeholder "All channels"). Dropdown of actual Channel records from `channels_channels`. When set, rule only applies when calculating for that specific channel store.
- **Scope** — Brand / Category / Brand + Category. Controls which field(s) are shown below.
- **Brand** — hidden when scope = Category. Searchable dropdown of distinct vendor values from `products_products`.
- **Category** — hidden when scope = Brand. Searchable dropdown from `products_categories`.
- **Status** — green toggle. Inactive rules are excluded from resolver queries entirely.

The table shows Company (blue badge), Channel (amber badge — shows channel name or "All channels"), Scope, Brand/Category, Priority, Tiers count, Status, and Last modified. Filters: Company, Channel, Status, Scope.

## Admin UI — Channel Fees form layout

```
Row 1 (4 cols): Company (2) | Channel (2)
Row 2 (4 cols): Profile name (3) | Active toggle (1)
```

- **Company** — required. Fee profiles are resolved per product's `company_id` via `ChannelFeeRegistry::get()`.
- **Channel** — the platform key this profile applies to ('shopify', 'ebay', etc.). All stores of that platform type share the same fee profile (unless company-scoped).
