# Channel order taxes (eBay / Amazon / Shopify)

Master reference for how VAT/tax is handled on channel orders in Cydekick — what was broken, what
was tried, and what the current design actually does. For the detailed reasoning behind the two
reversals along the way, see [order-line-tax-fallback.md](order-line-tax-fallback.md) (kept
because several code comments point straight at it by filename — don't rename it).

## Delivery charges never had VAT applied at all (fixed 2026-10-01)

A real, separate bug from everything below — this is about VAT on the **delivery cost**, not on
product lines. `Order::recalculateTotals()` has always computed `deliveryTax = delivery_cost *
(delivery_tax_rate / 100)`, and there's a real, user-facing global setting for exactly this
("Delivery / Shipping Tax Rate", Settings → Orders — `OrderSettings::delivery_tax_rate`,
`ManageOrders.php`) — but **no importer ever read that setting and wrote it onto the order's own
`delivery_tax_rate` column**, so it was always null/0 regardless of what the setting said,
meaning every order's delivery VAT silently computed as £0 since this plugin was built. Found via
a real order (C1439/#1444): Subtotal £3.91, Tax £0.78 (= exactly 20% of the product line, correct),
Delivery £4.99, but **zero VAT on that £4.99** — Total came out £9.68 instead of the real ~£10.68.

`recalculateTotals()` runs after *every* `OrderLine::create()`/update regardless of the order's
fulfilled status — `OrderLine::create()` is never wrapped in `Order::withoutEvents()` the way
`Order::create()` conditionally is for a fulfilled/historical import, only the Order row's own
events are suppressed there. So the fix only needed to happen in one place per importer: set
`delivery_tax_rate` on `$orderData` (from `app(OrderSettings::class)->delivery_tax_rate`) *before*
the order's lines are created — `EbayOrderImporter.php`, `AmazonOrderImporter.php`,
`ShopifyOrderImporter.php`, all three. Verified via a rolled-back tinker test reproducing C1439's
exact real numbers (£3.91 subtotal, £4.99 delivery): before the fix, tax_amount lands at £0.78
(product only); after, correctly £1.78 (£0.78 product + £1.00 delivery), total_amount £10.68.

**Only fixed orders imported from here on, automatically** — the global setting itself was already
correctly configured to 20%, this was purely a case of nothing ever reading it. Every order
imported before the fix still had the old, VAT-missing figures, so a backfill command was built
to catch them up.

### A second bug, introduced by the first fix: `total_amount` got inflated (fixed same day)

The first version of `Order::recalculateTotals()`'s delivery-tax support (and the first version of
`channels:backfill-delivery-tax`) **added** `deliveryTax` on top of `delivery_cost`:
`total_amount = subtotal + productTax + deliveryTax + deliveryCost - discount`. That's wrong:
`delivery_cost` is the REAL, already-charged amount — **VAT-inclusive**, the exact same convention
every order line price already uses (`EbayOrderImporter::computeLines()`'s own
`$exVatCost = round($lineCost / (1 + $taxRate / 100), 2)` — VAT is always *extracted from* a real
charged figure in this codebase, never *added on top* of one). Adding tax on top of an already-
inclusive delivery charge silently inflated `total_amount` beyond what the customer actually paid
— caught immediately by the user on a real order, C1439: real total £9.68 became a wrong £10.68
the moment the (real, non-dry-run) backfill ran on production (384 orders affected).

Fixed properly in `Order::recalculateTotals()`: VAT is now *extracted* from `delivery_cost`
(`deliveryTax = deliveryCost − round(deliveryCost / (1 + rate/100), 2)`), and `total_amount` drops
the separate `deliveryTax` term entirely (`total_amount = subtotal + productTax + deliveryCost −
discount` — `deliveryCost` already contains its own tax, it's not added again). The practical
result: **`total_amount` never changes from this fix, in either direction** — only how much of the
already-correct total gets *classified* as VAT (`tax_amount`) shifts. Reproducing C1439 exactly
confirms it: subtotal £3.91, product tax £0.78, delivery £4.99 → **tax_amount £1.61** (matches an
independent third-party VAT calculator exactly), **total £9.68, unchanged**.

Any order already touched by the first (buggy) run needed a direct correction, not just a code
fix — see `channels:recalculate-delivery-tax` below.

### Backfill: `channels:backfill-delivery-tax`

```
php artisan channels:backfill-delivery-tax --dry-run
php artisan channels:backfill-delivery-tax
```

For orders that have **never** had a `delivery_tax_rate` set at all. Scoped to `delivery_cost > 0
AND (delivery_tax_rate IS NULL OR delivery_tax_rate = 0)` — an order with no delivery charge at
all (free delivery over £100, a newer business rule) correctly has nothing to fix and is left
alone; an order that already has a real `delivery_tax_rate` is also left alone (so it's safe to
rerun, and won't re-touch anything `channels:recalculate-delivery-tax` already corrected). Uses
**today's** configured rate uniformly for every historical order (UK VAT has been 20% throughout
this business's trading history, no per-period rate history exists to draw from even if it had).

Reuses `Order::recalculateTotals()` itself (sets `delivery_tax_rate` on the in-memory model, then
calls it) rather than re-deriving the formula by hand — guarantees it can never drift from what a
freshly-imported order computes. That method's own `save()` is already wrapped in
`Order::withoutEvents()`, so this never fires `OrderObserver` (no stock re-deduction, no
dispatch/tracking/channel-sync changes) — confirmed by spot-checking a backfilled order's
`stock_deducted_at`/`status`/line count were byte-for-byte unchanged before/after. The command now
also asserts `total_amount` didn't move on every row it touches, printing a loud error if it ever
does — a direct safeguard against this exact class of bug recurring silently.

Verified locally (post-fix): a fresh synthetic order reproducing C1439 exactly → dry-run and real
run both correctly show `tax £0.78 → £1.61 | total £9.68 unchanged`.

### Correction: `channels:recalculate-delivery-tax`

```
php artisan channels:recalculate-delivery-tax --dry-run
php artisan channels:recalculate-delivery-tax
```

One-off, needed **only because of the inflation bug above** — re-runs the now-corrected
`Order::recalculateTotals()` against every order that already has a `delivery_tax_rate` set (i.e.
every order the buggy version touched, whether via `channels:backfill-delivery-tax`'s first run,
or a live import that happened in the window between that bug shipping and this fix going out).
Restores `total_amount` to its real original value on each; only `tax_amount` moves. Idempotent —
confirmed locally: after running it once, an immediate re-run (and `--dry-run`) correctly reports
every order "already correct", 0 changed.

**Run this BEFORE `channels:backfill-delivery-tax`** if both are ever needed in the same session —
it only matters if `channels:backfill-delivery-tax`'s buggy first run already executed for real
somewhere (which it did, on production, before this was caught).

Verified locally: ran against the 14 orders the original buggy run had touched — every one
restored exactly to its real original total (e.g. C1008: tax £3.31→£3.14, total £19.83→£18.83,
matching the figure from before any of this ever ran); immediate re-run correctly reported "0
order(s), 15 already correct".

## Discounted Shopify orders overstated VAT too (fixed 2026-10-01, same day)

A third, related bug in `ShopifyOrderImporter` — found via a real order, C1040: a `MUDDERZ5`
discount code (-£26.56) was applied, and VAT came out as £88.56 when the real figure (confirmed
against an independent third-party VAT calculator) is £84.13.

**Cause:** each order line's tax was computed from Shopify's raw `price` field — the *undiscounted*
list price — with no awareness that a real discount (Shopify's `total_discount`, allocated per
line) had reduced what was actually charged. `EbayOrderImporter` never had this problem — it
already correctly uses `discountedLineItemCost` (the real, post-discount figure) when splitting
VAT. `ShopifyOrderImporter` used the equivalent of the *undiscounted* figure instead.

**Fix:** each line now computes `$discountedLineTotal = (qty × price) − total_discount` first, and
VAT is extracted from/added to *that*, never the raw undiscounted price — same "work from the real
charged amount" principle as the delivery fix above. Reproducing C1040's exact real payload through
the fixed importer: line tax_amount = **£84.13** exactly, matching the real figure.

**A second, cascading bug this one surfaced:** `Order::recalculateTotals()` was still subtracting
`discount_amount` from `total_amount` — correct under the *old* behaviour (lines held the
undiscounted figure, so the order-level subtraction was the only place the discount was ever
actually applied), but now wrong: each line's `subtotal`/`tax_amount` already reflects the real,
post-discount amount, so subtracting `discount_amount` again double-counted it. Caught immediately
via the same tinker verification: `total_amount` came out £478.23 instead of the real £504.79 — off
by exactly the £26.56 discount. Fixed by removing that subtraction entirely — `discount_amount`/
`discount_code` are now purely informational/display fields (the "Discount (CODE): -£X.XX" row on
the order's own view page), never part of the total math. Re-verified after this fix:
`total_amount` = **£504.79** exactly, matching Shopify's real reported total.

Only `Order::recalculateTotals()` and `ShopifyOrderImporter.php` changed — `BackfillDeliveryTaxCommand`/
`RecalculateDeliveryTaxCommand`'s hand-copied dry-run preview formulas were updated to match (drop
the same discount subtraction), so their previews stay consistent with what a real run does.

**A fourth bug, caught before it ever shipped**, while building the backfill below: the live fix
above used `item['total_discount']` as the per-line discount source. Live-fetching a REAL discounted
order (C1025, a "manual"/custom discount, not a code) to test the backfill against showed
`total_discount = 0.00` on its line while `discount_allocations[].amount` correctly showed the real
£10.00 — `total_discount` is Shopify's older, less reliable convenience field and isn't always
populated. Fixed by summing `discount_allocations[].amount` first, falling back to `total_discount`
only if that's empty. Both `ShopifyOrderImporter::createFromPayload()`'s line loop and the formula
now live in one place — see below.

### Reusable formula: `ShopifyOrderImporter::resolveLineAmounts()`

The per-line unit_price/subtotal/tax_rate/tax_amount/line_total computation was extracted from the
live import loop into a `public static` method, specifically so the backfill command below can call
the *exact* live formula against a freshly re-fetched payload instead of re-deriving it by hand —
the same "can never drift" principle as `PricingEngine::resolveTaxRate()` being public static.
Re-verified after the extraction that both test cases (synthetic C1040-shape, real C1025 payload)
produce byte-identical output to before the refactor.

### Backfill: `channels:backfill-discount-tax`

```
php artisan channels:backfill-discount-tax --dry-run
php artisan channels:backfill-discount-tax
```

Unlike `channels:recalculate-delivery-tax`, this **cannot** be derived from already-stored data —
an affected line's stored `unit_price`/`subtotal`/`tax_amount` are themselves wrong (built from the
undiscounted price), and the real per-line discount allocation was never persisted in Cydekick to
begin with. The only source of truth is Shopify's own order record, so this command re-fetches each
eligible order fresh via `ShopifyClient::getOrder()` — the same read-only call
`channels:reimport-shopify-order` already makes — and recomputes from that via
`resolveLineAmounts()`.

Scoped to orders on a Shopify channel with `discount_amount > 0`. Lines are matched to the
re-fetched payload by SKU (grouped, then matched positionally within each SKU group, for the rare
case of two lines sharing one). If the live Shopify order's line count or SKU set no longer matches
what's stored — the order was edited on Shopify since it was imported — the whole order is skipped
with a warning rather than guessed at.

Deliberately does **not** delete/recreate the order the way `channels:reimport-shopify-order` does
(that command's own docblock warns it would re-run stock deduction/channel-sync as if the order
were brand new — real risk of double-deducting stock for an already-processed order). This command
only ever `UPDATE`s existing `channel_orders_order_lines`/`channel_orders_orders` rows in place via
raw `DB::table()` — ids, relations, and stock-deduction history are completely untouched, no
Eloquent event fires.

Verified locally against the 2 real discounted orders in this environment (C1025, C1026 — both
genuinely affected, both showing £0.00 tax_amount beforehand despite a real ~20% rate): dry-run and
real run both correctly left `total_amount` **completely unchanged** in both cases (£1112.61 and
£169.21 respectively) while correcting `subtotal`/`tax_amount` to the real figures (e.g. C1025:
tax £0.00 → £185.43); spot-checked afterward that `status`/`stock_deducted_at`/line count were
untouched; immediate re-run of `--dry-run` correctly reported "0 line(s)... 2 already correct".

**Amazon not checked.** `AmazonOrderImporter::computeLines()` uses `ItemPrice.Amount` without
referencing Amazon's own `PromotionDiscount` field — it's possible the same class of bug exists
there if Amazon orders in this store ever carry a promotion, but this hasn't been confirmed against
a real Amazon payload the way the Shopify case was, so no fix was made there without evidence.

## TL;DR — current design (2026-09-30)

eBay/Amazon never reliably report what VAT this seller owes on a sale (see "Why" below), so
**Cydekick never reads or trusts their reported tax figures at all**. Every eBay/Amazon order
line's tax is instead derived from the **linked product's own configured VAT rate** —
`PricingEngine::resolveTaxRate()`'s cascade: channel-specific override →
product's default "VAT Rate %" → 0. The real, already-charged amount never changes — only how
it's *split* into subtotal + tax.

This is applied consistently everywhere an eBay/Amazon order's tax gets touched:

| Stage | File | What it does |
|---|---|---|
| Direct-to-Cydekick import | `EbayOrderImporter.php` | Splits each line's real cost via product rate; order `subtotal`/`tax_amount` = sum of lines |
| Direct-to-Cydekick import | `AmazonOrderImporter.php` | Same |
| Push to Shopify (for label printing) | `EbayToShopifyPusher.php` | Builds Shopify `tax_lines` from product rate, so Shopify's own admin/invoices show correct VAT |
| Push to Shopify | `AmazonToShopifyPusher.php` | Same |
| Reading a pushed order back from Shopify | `ShopifyOrderImporter.php` | Falls back to product rate when no usable `tax_lines` rate — applies to every source, not just non-eBay/Amazon |
| Historical/retroactive fix, line level | `channels:backfill-order-line-tax` | Recomputes every eBay/Amazon line from its product's current rate. Idempotent. |
| Historical/retroactive fix, order level | `channels:backfill-marketplace-order-tax` | Sums the (by-then-corrected) lines back into the order's own `subtotal`/`tax_amount`. No longer calls any external API. Idempotent. |

## The original bug (fixed correctly, first time)

`EbayToShopifyPusher`/`AmazonToShopifyPusher` never sent `tax_lines`/`taxable`/`taxes_included` to
Shopify at all when pushing an order for label printing — every eBay/Amazon order showed **£0.00
tax everywhere**, in Shopify and in Cydekick. Fixed by attaching a proper tax breakdown to each
line before pushing. Verified via synthetic-payload tinker tests proving order totals never
changed, only the internal tax split.

That fix needed a *source* for each line's tax rate/amount — which is where this got interesting
and was gotten wrong twice before landing on the current design:

## Two false starts (full story in order-line-tax-fallback.md)

1. **Trust the marketplace's own reported tax, including a reported zero.** Reasoned as UK/EU
   marketplace-facilitator VAT rules (eBay/Amazon sometimes collect/remit VAT themselves). Caught
   wrong: real order C1035 had eBay confirm `£0.00` tax, but a product-rate guess had *already*
   fabricated `£3.11` on that same order before this fix landed.
2. **Never guess a rate when the marketplace reports zero — always trust the marketplace's own
   aggregate/per-line figure as authoritative.** Reworked all four affected files (line-level
   distribution from eBay's order-level aggregate / Amazon's real per-line `ItemTax`). Caught
   wrong within the same session: the user's own eBay payout screenshot for that exact order
   showed eBay paid out the **full £18.64**, minus only its own selling fees — never withholding
   or remitting any VAT. That proves eBay's `pricingSummary.tax = 0` doesn't mean "eBay collected
   it" for this seller; it means eBay just doesn't report seller-side VAT for ordinary UK domestic
   sales, because accounting for it isn't eBay's job here — it's the seller's own, confirmed
   directly: *"eBay i dont think are keeping the vat, this is my responsability"* — and the same
   was confirmed to apply to Amazon.

## Current design, in detail

- **`PricingEngine::resolveTaxRate(Product $product, int $channelId, ?Channel $channel = null): float`**
  (`plugins/webkul/pricing/src/Services/PricingEngine.php`) — the single canonical cascade,
  `public static` specifically so the channel-orders/channels importers can reuse it.
- **`EbayOrderImporter::computeLines()`** / **`AmazonOrderImporter::computeLines()`** — new private
  helper, called *before* the `Order` row is created, so the order's own `subtotal`/`tax_amount`
  can be set as the real sum of what's about to be written to its lines (rather than trusting
  `pricingSummary.tax`/summed `ItemTax` directly).
- **`EbayToShopifyPusher`/`AmazonToShopifyPusher`** now take the *source* channel (eBay/Amazon) as
  well as the destination Shopify channel — needed to resolve each line's SKU → Cydekick product
  via `MarketplaceSkuResolver` so its VAT rate can be looked up. Call sites updated in
  `SyncEbayOrders.php`/`SyncAmazonOrders.php`. The Shopify `price` field sent per line stays
  VAT-inclusive (the real, unchanged buyer-facing amount) — only the attached `tax_lines` entry
  changed to reflect the product-rate split, `taxes_included: true` still tells Shopify not to add
  tax on top.
- **`ShopifyOrderImporter`** — the `ebay`/`amazon` exclusion on its own product-rate fallback was
  removed; it's now just a universal safety net for whatever the pusher didn't resolve a rate for.

## Backfilling historical orders

Two commands, run in this order (both support `--dry-run` and an optional `{order}` argument for
a single order like `C1035`):

```
php artisan channels:backfill-order-line-tax --dry-run
php artisan channels:backfill-order-line-tax
php artisan channels:backfill-marketplace-order-tax --dry-run
php artisan channels:backfill-marketplace-order-tax
```

- `channels:backfill-order-line-tax` — recomputes every eBay/Amazon order line with a linked
  product from that product's current VAT rate, splitting the line's own unchanged `line_total`.
  Writes via raw `DB::table()` update — never fires `OrderLineObserver`, so no stock/reservation
  changes.
- `channels:backfill-marketplace-order-tax` — sums each eBay/Amazon order's own lines back into
  its `subtotal`/`tax_amount`. Never touches `total_amount`, never calls eBay/Amazon's API.

Both are **idempotent** — rerunning either after it's already correct reports "already correct"
and changes nothing, so there's no harm running them again after a fresh sweep of new orders.

One handled edge case: `Order.channel_id` can be genuinely `null` on some historical orders (the
origin channel was since deleted) — `(int) null` coerces to `0`, which matches no real channel's
override row, so `resolveTaxRate()` correctly falls through to the product's own default rate
instead of throwing.

## Verification performed

- `php -l` + Pint on every changed file.
- Tinker test: synthetic eBay order matching C1035's real figures (£18.64 total, eBay reports
  £0.00 tax, linked product has a 20% VAT rate) → correctly produces subtotal £15.53 / tax £3.11 /
  total £18.64 unchanged, both at order and line level.
- Tinker test: `EbayToShopifyPusher::mapToShopify()` on the same synthetic order → `price` stays
  `18.64` (unchanged, VAT-inclusive), `tax_lines` correctly shows `£3.11` at `rate: 0.2`.
- `channels:backfill-order-line-tax --dry-run` against real production-shape local data — correctly
  proposed fixing 4 real eBay/Amazon lines (e.g. C1006/RTC3184: 0% → 20%, £0.00 → £2.48 tax on an
  unchanged £14.90 line).
- `channels:backfill-marketplace-order-tax --dry-run` — correctly reported "already correct" when
  run against lines not yet actually written (dry-run of the line command first), confirming its
  sum-of-lines logic is sound.

## If this needs revisiting again

- If Amazon turns out to behave differently from eBay — i.e. real Amazon payouts genuinely show
  less than the full sale price on some orders, consistent with real marketplace-facilitator VAT
  withholding — that would justify trusting Amazon's per-line `ItemTax` again. Only act on this
  with concrete payout evidence, the same standard that overturned the eBay assumption twice.
- The order-level API-fetch approach (`EbayClient::getOrder()`, `AmazonClient::getOrderItems()`
  for tax) was removed from `BackfillMarketplaceOrderTaxCommand` but the client methods themselves
  may still exist/be used elsewhere for non-tax purposes — check before removing them outright.
- `resolveSourceChannel()`-style "one channel per marketplace platform" assumptions no longer
  matter for tax (nothing calls the marketplace APIs for it any more), but if that assumption
  changes for other reasons, note it was previously confirmed true for this deployment only.
