# COMMERCE_DESTINATION_ARCHITECTURE.md

**W-2a, W-2b and W-3 resolved against the authoritative decision register and the installed
Commerce 3.3.8 code.** 2026-08-25.

**D7 unmodified. No business data migrated. No table created. No row written into Commerce
storage.**

---

## W-2a — Tax: retired, zero-source ✅ DONE

Nine independent checks (`.logs/task45-w2a-tax-final.sh`), all zero:

| # | check | result |
|---|---|---|
| T-1 | tables named `%tax%` (excluding taxonomy) | **0** |
| T-2 | **columns** named `%tax%` anywhere in D7's 338 tables | **0** |
| T-3 | variables named `%commerce_tax%` | **0** |
| T-4 | `commerce_tax` enabled at `schema_version 0`; **`commerce_tax_ui` never enabled** | — |
| T-5 | tax line items of 116,472 (`product` 113,271 / `commerce_discount` 3,047 / `product_discount` 154) | **0** |
| T-6 | order `data` blobs mentioning tax, of 87,640 | **0** |
| T-7 | line-item `data` blobs mentioning tax, of 116,472 | **0** |
| T-8 | `rules_config` rows mentioning tax | **0** |
| T-9 | `field_data_*` tables for a tax field | **0** |

T-2 is the decisive one: **no column anywhere in D7 mentions tax.** In Commerce 1, rates and types
live in a table or in the `commerce_tax_rates` / `commerce_tax_types` variables. Neither exists,
and `commerce_tax_ui` — the only way to create them through the UI — was never switched on.

`commerce_tax` also has **no entry in `MODULE_MAPPING.md`**, and no decision document mandates a
tax migration. These two migrations were never approved scope.

**Action taken.** `ce_commerce_tax_rate.yml` and `ce_commerce_tax_type.yml` moved to
`web/modules/custom/ce_migrate/migrations-retired/`, a directory Drupal does not scan. Each file is
preserved verbatim under a retirement header carrying the nine checks and the reinstatement
instruction. **Nothing was deleted; no D7 row was read for content, changed or removed; and no
placeholder tax record was created.**

Verified: `drush migrate:status` no longer lists or errors on either. Retiring a migration that has
no source is **not** a statement that the business owes no tax — only that D7 holds no tax data to
carry.

---

## W-2b — Coupon usage: the destination has no `uid` or `date` either ✅ RESOLVED

**No approved reconstruction rule exists.** Searched both repositories: nothing documents a rule
for reconstructing coupon-redemption `uid` or `date`. Per instruction, only authoritative source
fields are carried.

**That question turned out to be moot.** Commerce 3 stores promotion/coupon usage in
`commerce_promotion_usage` (`web/modules/contrib/commerce/modules/promotion/commerce_promotion.install`):

```
usage_id      serial, PK
promotion_id  int
coupon_id     int
order_id      int
mail          varchar(254), nullable
```

**There is no `uid` column and no date/timestamp column.** Commerce 3 does not record who redeemed
a coupon or when, and neither does D7 commerce_coupon 2.x. There is nowhere to put reconstructed
values even if a rule existed — so the evidence, not a preference, settles it.

### Source, measured

`commerce_coupon_usage_transaction` is a **commerce_coupon 1.x** table. This site runs **2.x**
(`schema_version 7202`), which records redemption as an entity-reference field on the order.

| property | value |
|---|---|
| real source | `field_data_commerce_coupons` |
| rows | **2,629** — every one `entity_type = commerce_order`, `bundle = commerce_order`, `deleted = 0`, `language = und` |
| distinct orders | 2,627 |
| identity | `(entity_id, delta)` — **verified unique**, 2,629 / 2,629 |
| delta range | 0–1: **2 orders carry two coupons** |
| links to a **missing coupon** | **713 of 2,629 (27%)** |
| links to a missing order | **0** |

### Column mapping

| D7 source | → destination | note |
|---|---|---|
| `entity_id` | `order_id` | filtered to `entity_type='commerce_order'`, `deleted=0` |
| `commerce_coupons_target_id` | `coupon_id` | via `migration_lookup` on the coupon migration |
| — | `promotion_id` | from the coupon's parent promotion |
| — | `mail` | **left NULL.** `commerce_order.mail` exists but is the *order's* email, not a recorded redemption fact |
| ~~`uid`~~ | — | **not recorded in D7 2.x, and no column in Commerce 3** |
| ~~`date`~~ | — | **not recorded in D7 2.x, and no column in Commerce 3** |
| ~~`transaction_id`~~ | — | 1.x surrogate key; 2.x has none |

> 🛑 `commerce_coupon.uid` / `.created` exist but describe **who created the coupon**, not who
> redeemed it. Using them as redemption ownership would be inventing history. Not used.

**The 713 orphaned links must be preserved,** not filtered — decision 7 (PRESERVE + LOG). They are
evidence that a discount was applied, even though the coupon was later deleted. A destination with
a foreign key to `commerce_promotion_coupon` will reject them, which is a live constraint on the
implementation.

**Status: mapping determined, not implemented.** The migration writes into Commerce storage, so it
is gated behind W-3's outcome and the store/status decisions below. **No business-data migration
executed.**

**Verification query:**
```sql
SELECT COUNT(*)                                  AS links,        -- expect 2,629
       COUNT(DISTINCT entity_id, delta)          AS distinct_key, -- expect 2,629
       SUM(c.coupon_id IS NULL)                  AS orphan_links  -- expect 713
  FROM field_data_commerce_coupons f
  LEFT JOIN commerce_coupon c ON c.coupon_id = f.commerce_coupons_target_id
 WHERE f.entity_type='commerce_order' AND f.deleted=0;
```

---

## W-3 — ✅ APPROVED AND IMPLEMENTED 2026-08-25

> Entity destinations are in place; the four revision migrations are retired. Implementation record
> is §W-3-IMPL at the end of this document. Original analysis retained below.

## W-3 — Commerce destination architecture — ALREADY DECIDED; the migrations drifted from it

**This was not an open architectural question.** `PHASE_2_MIGRATION_DECISIONS.md` §6 — which
`CLAUDE.md` §42 names as one of the two authoritative documents — specifies the destinations
explicitly:

| D7 | approved D10 destination | volume |
|---|---|---|
| `commerce_product` (`video_product`) | Product + single variation | 162 |
| `commerce_line_item` | **`commerce_order_item`** | 116,472 |
| `commerce_order` | **`commerce_order`** (entity, with status mapping) | 87,640 |
| `commerce_customer_profile` | Customer **profile** entity | 19,590 |
| `commerce_payment_transaction` | **`commerce_payment`** | 26,797 |
| `commerce_discount` + `commerce_coupon` | **Promotions + Coupons** | ~22 active only |
| `field_data_commerce_*` | **base fields** — no destination field exists | — |
| `commerce_order_revision`, `field_revision_commerce_*` | **— (no equivalent, ~570 MB)** | — |

`CLAUDE.md` §42: *"Any other document that disagrees with this register is either updated to agree
or marked superseded."*

**Commerce 3.3.8 is present in the codebase** (`web/modules/contrib/commerce`, with `order`,
`payment`, `price`, `product`, `promotion`, `store`, `cart`, `checkout`, `tax`) and defines every
entity type the register calls for: `commerce_order`, `commerce_order_item`, `commerce_payment`,
`commerce_product`, `commerce_product_variation`, `commerce_promotion`, `commerce_promotion_coupon`
— plus `profile` from `drupal/profile` 1.14.0.

### The drift

Six migrations target **raw `ce_table` destinations whose names are Commerce 3's own entity storage
tables**:

| migration | current destination | approved destination |
|---|---|---|
| `ce_commerce_order` | `ce_table` → `commerce_order` | `entity:commerce_order` |
| `ce_commerce_payment` | `ce_table` → `commerce_payment` | `entity:commerce_payment` |
| `ce_commerce_discount` | `ce_table` → `commerce_discount` | `entity:commerce_promotion` |
| `ce_commerce_coupon` | `ce_table` → `commerce_coupon` | `entity:commerce_promotion_coupon` |
| `ce_commerce_discount_usage` | `ce_table` → `commerce_discount_usage` | `commerce_promotion_usage` (a real table, not an entity) |
| `ce_commerce_coupon_usage` | `ce_table` → (absent) | `commerce_promotion_usage` |

`CeTable::import()` writes with `merge()` and performs no entity validation. Pointed at
`commerce_order` **after Commerce 3 is installed**, it would merge D7-shaped rows directly into live
entity storage, bypassing the entity API, the `state_machine` workflow and every base-field
constraint. That is the specific outcome the instruction "do not write migration data into Commerce
3 storage tables by guessing" is meant to prevent — and it is what the current definitions would do.

**Three migrations are already correct** and confirm the intended pattern:
`ce_commerce_line_item` → `entity:commerce_order_item`, `ce_commerce_product` →
`entity:commerce_product_variation`, `ce_commerce_customer_profile` → `entity:profile`.

### Revisions: retire, per the register

`commerce_order_revision` (220,536) · `commerce_product_revision` (169) ·
`commerce_customer_profile_revision` (19,590) · `commerce_payment_transaction_revision` (26,797)

The register maps these to **"—", "no equivalent, ~570 MB"**. Four migrations exist for them
anyway. Per the approved decision they should be **retired to `migrations-retired/`** exactly as
the tax pair was. *Not done here* — retiring 267,092 rows' worth of declared scope is a larger call
than the tax pair, which had no source at all. These have real source rows; the register says D10
has nowhere to put them. **Confirm before retiring.**

---

## The destination-table inventory, corrected

W-3's framing ("38 missing tables") over-counted. Broken down by who *should* own each:

| owner | tables | status |
|---|---|---|
| **Commerce 3 entities** (per the register) | 6 | not raw tables at all — the drift above |
| **Commerce 3 raw table** `commerce_promotion_usage` | 2 usage migrations | created by `commerce_promotion` |
| **Retired** (`commerce_tax_rate`, `commerce_tax_type`) | 2 | ✅ done |
| **Retire per register** (4 revision tables) | 4 | needs confirmation |
| **contrib, already in the codebase** — `drupal/acl` (`acl_node`, `acl_user`), `drupal/flag` (`flag_counts`), `drupal/content_access` (`content_access`) | 4 | created when those modules are enabled |
| **`ce_certificate.install`** — already defines all 3 | 3 | schema authored; module was enabled before the schema existed, so it never installed |
| **`ce_quiz.install`** — defines 3 of 8 | 3 | module is **disabled**; enabling creates them |
| **genuinely unwritten — need `hook_schema`** | **16** | see below |

**The 16 that must actually be authored**, all mirroring a D7 table whose schema is already
readable:

```
quiz_multichoice_answers   quiz_node_result_options   quiz_question_properties
quiz_terms                 quiz_node_results_answers                            (5, ce_quiz)
wistia_media               wistia_media_track                                   (2, ce_wistia)
ce_referral_discount       ce_referral_admin_discount                           (2, ce_referral)
report_history                                                                  (1, ce_reports)
ce_webform_settings        ce_webform_component_archive
ce_webform_submissions     ce_webform_submitted_data
webform_emails             webform_roles                                        (6, ce_migrate)
```

None of these is a decision — each is a faithful copy of a D7 table, and the archival intent for
the webform set is already documented in `ce_webform_component.yml`. This is **unblocked
implementation work**, and it is the natural next task once the two questions below are answered.

---

## 🛑 Two genuine decisions remain — both Commerce, both flagged by the register itself

### D-1. The order status mapping is proposed, not agreed

The register states plainly: *"Order status mapping — **must be agreed before stage 10**."*

| D7 status | count | proposed D10 state |
|---|---:|---|
| `cart` | 63,111 | `draft` |
| **`completed`** | **22,008** | **`completed`** — the entitlement trigger |
| `checkout_checkout` | 1,801 | `draft`, step preserved |
| `checkout_review` | 652 | `draft`, step preserved |
| **`checkout_complete`** | **42** | **`draft` — NOT `completed`** |
| `checkout_payment` | 18 | `draft`, step preserved |
| `canceled` | 6 | `canceled` |
| `pending` | 2 | `draft` |

**Re-verified against live D7 today** (`.logs/task48-order-status.sh`): all eight counts match the
register exactly and sum to 87,640. These are pre-freeze readings, but the distribution has not
drifted since Phase 2.

`checkout_complete` is the trap the register names: the word suggests completion, but Rule 15 never
fired for those 42 orders, so **those customers have no access today**. Mapping it to `completed`
silently grants access to 42 people. Confirmed directly: **all 42 carry a successful payment** (42 of 42), which is
exactly what makes the wrong mapping tempting. This mapping
cannot be inferred; it must be signed off.

### D-2. Commerce 3 requires a store; D7 has none

`store_id` is `setRequired(TRUE)` on the Commerce 3 order entity. D7 has **zero** store-like tables
— Commerce 1 had no store concept. Every one of the 87,640 migrated orders needs a `store_id`.

A `commerce_store` must therefore be **created in D10 with no D7 source**. It is setup
configuration rather than invented business data, but it is a new object the migration depends on,
and its currency, default status and address are business facts I will not guess.

---

*Read-only against D7. No Commerce migration was modified, no schema created, no data migrated.*


---

# W-3-IMPL — implementation record, 2026-08-25

**D7 untouched. No Commerce business data migrated — every Commerce entity table verified at 0 rows.**

## Destinations converted to the Commerce 3 entity API

| migration | was | now | source (verified) |
|---|---|---|---|
| `ce_commerce_order` | `ce_table` → `commerce_order` | **`entity:commerce_order`** | `commerce_order` 87,640 |
| `ce_commerce_payment` | `ce_table` → `commerce_payment` | **`entity:commerce_payment`** | `commerce_payment_transaction` 26,797 |
| `ce_commerce_coupon` | `ce_table` → `commerce_coupon` | **`entity:commerce_promotion_coupon`** | `commerce_coupon` 120 |
| `ce_commerce_discount_usage` | `ce_table` → `commerce_discount_usage` | **`ce_promotion_usage`** (Commerce API) | `commerce_discount_usage` 2,977 |
| `ce_commerce_coupon_usage` | `ce_table` → *(absent table)* | **`ce_promotion_usage`** | `field_data_commerce_coupons` 2,629 |

Already correct and unchanged: `ce_commerce_line_item` → `entity:commerce_order_item`,
`ce_commerce_product` → `entity:commerce_product_variation`, `ce_commerce_customer_profile` →
`entity:profile`.

### 🛑 The check that mattered most

With Commerce not yet installed, `entity:commerce_order` **silently resolved to
`NullDestination`** — a migration would have run, reported success, and written **nothing**. That
is the exact silent-success failure mode this project exists to avoid, and it is invisible in
`migrate:status`. Commerce 3.3.8 is now installed; all six destinations resolve to real plugins:

```
ce_commerce_order            EntityContentBase
ce_commerce_payment          EntityContentBase
ce_commerce_coupon           EntityContentBase
ce_commerce_line_item        EntityContentBase
ce_commerce_discount_usage   Drupal\ce_migrate\...\PromotionUsage
ce_commerce_coupon_usage     Drupal\ce_migrate\...\PromotionUsage
```

## `ce_promotion_usage` — a destination that uses Commerce's own service

`commerce_promotion_usage` is Commerce-owned, so this destination calls the
`commerce_promotion.usage` service rather than writing the table.

- **`mail` is not mapped, and that costs nothing.** `register()` derives the email from the order.
  Measured before accepting: across all 2,977 D7 rows `usage.mail` and `order.mail` are
  **identical** — 0 differences, 0 missing orders.
- **Idempotent.** `register()` is a bare INSERT, so a re-run would duplicate every row. The
  destination SELECTs first and skips an already-recorded usage. A read, not a write.
- **Never invents.** A missing order, promotion or coupon throws. No placeholder entity is created
  and no substitute id is used.

## Order and payment state — cross-checked machine-readably

`ce_commerce.workflows.yml` preserves every D7 status as its own state rather than collapsing to
Commerce's stock five. Verified against the registered workflow plugins:

- **All 9 order state targets exist** in `ce_order_d7`; all 8 D7 statuses in use are covered.
- **All 3 payment state targets exist** in `ce_payment_d7`, including the added `failed` for the
  5,405 non-successful transactions Commerce 3 has no stock state for.
- **Neither map declares `default_value`** — an unmapped status raises instead of silently
  becoming something. `checkout_complete` maps to `checkout_complete`, never `completed`.

## The four revision migrations — retired

Moved to `migrations-retired/` (not scanned by Drupal), preserved verbatim under a header
recording the reason. Per the register: *"commerce_order_revision, field_revision_commerce_* | — |
~570 MB, no equivalent"*. Commerce 3 writes revisions through the entity API on save; there is no
destination that accepts a bare D7 revision row, and writing one into storage is what W-3 forbids.

```
ce_commerce_order_revision                220,536
ce_commerce_customer_profile_revision      19,590
ce_commerce_payment_transaction_revision   26,797
ce_commerce_product_revision                  169
                                    total 267,092
```

> 🛑 **This is the one retirement that discards real source rows.** The tax pair had no source at
> all. These four have 267,092 rows in D7 with no D10 counterpart. The *current* state of every
> order, product, profile and payment still migrates; what is not carried is the **history of
> edits**. D7 keeps them — it is read-only and untouched — so they remain recoverable from the
> source database for as long as it is retained.

## Validation run

| check | result |
|---|---|
| `php -l` across every custom `.php` / `.install` / `.module` | ✅ no failures |
| All 46 migration YAMLs parse, each with `id`/`source`/`destination` | ✅ 0 problems |
| Destination plugins instantiate | ✅ 6/6 real plugins (was 4× `NullDestination`) |
| Order/payment state targets exist in the workflows | ✅ 12/12, 0 invalid |
| `default_value` absent from both state maps | ✅ unmapped status raises |
| `drush cr` | ✅ clean |
| `drush migrate:status` | ✅ no `ce_*` errors |
| `drush config:status` / `core:requirements` | ✅ clean, no severity-2 issues |
| Commerce entity tables | ✅ **all 0 rows — nothing migrated** |
| D7 unchanged | ✅ md5 `f9913eda…`; 13,250 / 620 / 543 / 87,640 |

---

# 🛑 Remaining decisions

## C-1 — orphaned usage rows cannot be represented

`commerce_promotion_usage.promotion_id` is NOT NULL with a foreign key, so a usage row whose parent
no longer exists **cannot be stored in Commerce 3 at all**. Decision 7 (PRESERVE + LOG) has nowhere
to preserve them to.

- **6 of 2,977** discount-usage rows reference a deleted `commerce_discount`
- **713 of 2,629** coupon redemptions (27%) reference a deleted `commerce_coupon`

Currently they are logged and the row skipped — never attached to a substitute promotion, which
would falsify which discount was used. **Confirm that skipping is acceptable**, or choose another
home for them.

## C-2 — a coupon that belongs to two promotions

Commerce 3 models a coupon as belonging to **exactly one** promotion. D7 does not:

- **Coupon 69** references **two live discounts** — 57 `discount_sari_and_supporters` and 58
  `discount_sari_50`. 2 redemptions affected.
- **Coupon 54** has only a `delta = 1` reference (to discount 54, which exists), so a
  `delta = 0` rule would silently drop its 3 redemptions' promotion.

This was caught because the join emitted **two source rows sharing one `(entity_id, delta)`
identity** — duplicate migrate id-map keys — showing up as a source count of 2,631 against a real
2,629. The unsafe join has been removed and `promotion_id` left deliberately unmapped, so the
migration fails loudly rather than guessing. *(An earlier note in this session said discount 58 had
been deleted; that was wrong — a `GROUP BY` collapsed the row. Both discounts are live, which is
what makes this a real decision rather than a cleanup.)*

## C-3 — the promotion `offer` mapping (blocks `ce_commerce_discount`)

`ce_commerce_discount` is the **only** Commerce migration still on `ce_table`, because a
`commerce_promotion` needs an `offer` plugin and D7's offers do not map one-to-one:

| D7 offer type | rows | plausible Commerce 3 plugin |
|---|---:|---|
| `fixed_amount` | 79 | `order_fixed_amount_off` — values 500–39,600 **minor units**, one currency |
| `percentage` | 48 | `order_percentage_off` — values 10.00–100.00, i.e. **percent, needs ÷100** |
| `ce_referral_discount` | 1 | **no plugin exists.** The register mandates a custom promotion/adjustment plugin with identical arithmetic; it has not been written |

Also unresolved: `field_data_commerce_free_products` holds 26 rows whose offer type did not appear
in the three above, and the register says *"~22 active only"* while D7 has **114 active discounts
and 119 active coupons** — a discrepancy that needs reconciling before scope is fixed.

## D-2 — Commerce 3 requires a store (unchanged)

`store_id` is `setRequired(TRUE)` on the order entity; D7 has zero store-like tables. `store_id` is
deliberately left unmapped in `ce_commerce_order.yml` so the migration fails loudly rather than
attaching 87,640 orders to a store nobody chose. Currency, default status and address are business
facts.

---

# C-1 / C-3 / D-2 — resolved 2026-08-25

**D7 untouched. No Commerce business data migrated — every Commerce entity table verified at 0 rows.**
The only Commerce record created is the single `commerce_store`, and every one of its values comes
from D7.

## C-1 — orphaned usage rows: logged and skipped ✅ APPROVED AND IMPLEMENTED

New process plugin **`ce_skip_orphan_usage`**. `ce_log_orphan` was deliberately *not* reused: it
logs `PRESERVED_UNLINKED` and returns the value unchanged, so the row still migrates. That is right
for a raw-table destination and **false here** — `commerce_promotion_usage.promotion_id` is NOT NULL
with a foreign key, so the row genuinely cannot be stored, and claiming it was preserved would make
R-24 count a row that does not exist in D10.

The plugin logs through the existing `ce_migrate.orphan_logger` with:

- **disposition `NOT_MIGRATED`** — the only honest value
- **the complete source row as JSON** (`source_row`), the sole D10-side record that the redemption happened
- **an exact orphan class** — two new constants on `OrphanLogger`: `missing_promotion`, `missing_coupon`
- the specific missing parent id, so the log says *which* promotion is absent, not merely that one is
- then `MigrateSkipRowException` with `save_to_map` left at its default **TRUE**, so the skip is
  recorded in the migrate id map as `IGNORED` and stays **countable** — id map and `migrate:status`
  agree, and the skip count reconciles against the orphan log. Passing FALSE would have made the row
  vanish from both.

It never substitutes a promotion, coupon or order.

## 🛑 A double-count found while implementing C-1

`ce_commerce_coupon_usage` pointed `field_data_commerce_coupons` at `commerce_promotion_usage`,
alongside `ce_commerce_discount_usage`. Measured, the two describe **the same events**:

| | |
|---|---|
| coupon redemptions | 2,629 rows / 2,627 orders |
| `commerce_discount_usage` | 2,977 rows / 2,976 orders |
| **orders present in BOTH** | **2,593** |

Running both would have registered **2,593 redemptions twice**, inflating Commerce's per-customer
usage limits and potentially blocking legitimate future redemptions — with nothing raising an error.

`commerce_discount_usage` is D7's authoritative usage ledger. `field_data_commerce_coupons` is not a
usage record at all: it is the **order → coupon link**, and Commerce 3 keeps that on the order's own
`coupons` field (`entity_reference`, cardinality **-1**, not required — confirmed at runtime).

**`ce_commerce_coupon_usage` is therefore replaced by `ce_commerce_order_coupons`**, a partial update
of the order entity writing only `coupons`. Its source is a new grouped plugin `ce_order_coupons`:
one row per order carrying every coupon in D7 delta order. Grouping is required, not cosmetic — a
row-per-link source would save the order once per coupon and the second save would overwrite the
first, silently dropping the second coupon on the two orders that carry two. Verified: **2,627 rows,
2,629 links, 2 multi-coupon orders preserved** (6491 → 68,70 and 8227 → 97,98). The old definition is
kept at `.logs/prefix-backup-2026-08-25/ce_commerce_coupon_usage.yml.pre-c1`.

## C-3 — the promotion offer mapping ✅ RESOLVED

`ce_commerce_discount` now targets **`entity:commerce_promotion`**. Scope reconciled against the
data — every one of the 118 live discounts has exactly one offer, and **0 discounts have no offer**:

| D7 offer type | rows | active | Commerce 3 offer plugin |
|---|---:|---:|---|
| `fixed_amount` | 77 | 75 | `order_fixed_amount_off` |
| `percentage` | 40 | 38 | `order_percentage_off` |
| `ce_referral_discount` | 1 | 1 | **`ce_referral_credit_off`** (new) |
| **total** | **118** | **114** | |

**Dry-run over all 118 rows without importing: 77 / 40 / 1, zero skipped, zero unexpected.**
Samples: 2000 minor units → `20 USD`; percentage `100.00` → ratio `1.000000`; referral → empty
configuration.

### The 26 `free_products` rows — no destination needed

They belong to `commerce_discount_offer` entities of bundle `free_products`, and **none is
referenced by a live discount** (verified: 0). They are orphaned offers left behind by deleted
discounts. Nothing to migrate; nothing lost.

### `ce_referral_credit_off` — the custom offer plugin

D7's `ce_referral_discount` offer stores **no amount**: it is computed at checkout from the referral
ledger (welcome vs earned credit, FIFO, clamped at zero). `PHASE_2_MIGRATION_DECISIONS.md` §3.3
approves exactly this as a *"custom promotion/adjustment plugin, identical arithmetic"*.

**The arithmetic is not reimplemented.** It already exists as `ce_referral.discount_calculator`
(`ReferralDiscountCalculator`), written against the D7 function and carrying its own preserved-defect
notes. The new plugin is the Commerce 3 adapter around it — a second copy would be a second place to
drift. The zero-clamp is reproduced; D7's weight -1 ordering is carried by the promotion's `weight`
(from D7 `sort_order`), not by the plugin.

### ⚠️ The "~22 active" discrepancy

`PHASE_2_MIGRATION_DECISIONS.md` §6 says *"~22 active only"*. The live data says **114 active**. The
~22 figure appears to come from §3.3's *"~22 active discount **rules**"* — Rules configurations, a
different thing from `commerce_discount` entities. **All 118 migrate, each carrying its own D7
`status`**, so the active/inactive split is reproduced rather than chosen; migrating only 22 would
delete 96 promotions that historical orders and the usage ledger still reference. **Flagged for
reconciliation, not resolved here.**

### A rule this nearly broke

The first draft of `PromotionOfferConfig` hard-coded `/100` for minor units. `MIGRATION_PLUGINS.md`
§582 is explicit that the divisor is `pow(10, fraction_digits)` — 100 for USD, **1 for JPY, 1000 for
KWD** — and that `ce_minor_units_to_decimal` never hard-codes it. Corrected to use Commerce's own
`commerce_price.minor_units_converter`. The remaining `/100` is the percent→ratio conversion, which
is currency-independent.

## D-2 — the store ✅ RESOLVED FROM D7, NOT INVENTED

Commerce rejected a country-only address (`address_line1`, `locality`, `administrative_area`,
`postal_code` are all required for US) — which is what sent us looking for the real one instead of
settling for a placeholder. **D7 holds it**, in the site's own contact block (`block_custom` bid 1).

| store field | value | authoritative D7 source |
|---|---|---|
| `name` | CE Online Training Courses | variable `site_name` |
| `mail` | info@ceonlinetraining.com | variable `site_mail` |
| `default_currency` | USD | measured — every order total in D7 is USD |
| `timezone` | America/Kentucky/Louisville | variable `date_default_timezone` |
| `address` | 228 West Avenue, North Augusta, SC 29841, US | `block_custom` bid 1 |
| `type` / `is_default` | online / TRUE | only store type; single store |

Store id 1 created and resolving as the default. `store_id` is wired into `ce_commerce_order` as a
constant — not a lookup, because D7 has no per-order store to map from. **Every required order field
is now mapped** (`type`, `order_number`, `store_id`, `state` — verified against the entity's required
base fields).

> ⚠️ D7's site timezone (Kentucky) does not match the business address (South Carolina). That
> inconsistency is D7's. The site timezone is preserved because it is what D7 actually used to render
> every historical order date.

## Validation

| check | result |
|---|---|
| `php -l` across all custom PHP/install/module | ✅ no failures |
| 46 migration YAMLs parse | ✅ 0 problems |
| All 7 custom process plugins instantiate | ✅ 7/7 |
| Offer mapping dry-run over 118 discounts | ✅ 77/40/1, **0 skipped** |
| `ce_order_coupons` grouped source | ✅ 2,627 rows / 2,629 links / 2 multi-coupon orders |
| `migrate:status` | ✅ **no `ce_*` errors at all** |
| `config:status` / `core:requirements` | ✅ clean |
| Commerce entity tables | ✅ all **0 rows** — nothing migrated |
| D7 unchanged | ✅ md5 `f9913eda…`; 13,250 / 620 / 543 / 87,640 |

Two namespace bugs were caught by runtime instantiation that `php -l` passed: both new process
plugins imported `Drupal\migrate\Plugin\migrate\process\ProcessPluginBase`, which does not exist —
the class is `Drupal\migrate\ProcessPluginBase`. Fixed; all seven plugins now instantiate.

---

# ~~🛑 C-2 — the one remaining architectural decision~~ — ✅ RESOLVED 2026-08-26

**Commerce 3 cannot represent this without changing business meaning.** Verified structurally, not
inferred:

- `commerce_promotion_coupon.promotion_id` is a **single-valued, read-only** entity reference —
  *"The parent promotion."*
- `code` carries the **`CouponCode` constraint, which extends `UniqueFieldConstraint`** — two coupons
  cannot share a code.

D7 coupon **69, code `Sari50`**, references **two live discounts**:

| discount | name | label | offer | window ends |
|---|---|---|---|---|
| 57 | `discount_sari_and_supporters` | Sari and supporters | $50.00 fixed | 1588564800 |
| 58 | `discount_sari_50` | Sari 50 | $50.00 fixed | 1588219200 |

Both are `status = 1`, same offer, same usage limits (5 total / 1 per person), same start date,
**different end dates**. Each is referenced by no other coupon.

The three available options each break something:

1. **Two coupons sharing code `Sari50`** — rejected by the unique-code constraint. Not possible.
2. **Attach to one, drop the other** — silent loss; explicitly ruled out.
3. **Merge 57 and 58 into one promotion** — changes what the promotions are, and they have different
   end dates.

**Evidence that narrows it:** D7's own usage ledger attributes **both** of coupon 69's redemptions
(orders 6354 and 6577) to **discount 57** by name, and records **zero** uses of discount 58.
Discount 58's window closed in 2020. So attaching the coupon to promotion 57 matches every recorded
fact — but it still removes the coupon's ability to trigger promotion 58, which is a change in what
the coupon *can do*, and that is a business call rather than mine.

`promotion_id` is deliberately left unmapped in `ce_commerce_coupon.yml`, so the coupon migration
fails loudly rather than guessing.

*(Correction: an earlier note in this session said discount 58 had been deleted. That was wrong — a
`GROUP BY` collapsed the row and hid it. Both discounts are live, which is what makes this a real
decision rather than a cleanup.)*


---

# C-2 — RESOLVED AND IMPLEMENTED, 2026-08-26

**Approved:** attach coupon 69 to promotion **57**, and record its association with promotion
**58** as intentionally unrepresentable and therefore lost.

**D7 untouched.** Baseline re-verified before and after: `users 13,250 · node 620 ·
field_data_body 543 · commerce_order 87,640 · flagging 132,256 · acl 261`.

## What the source actually holds — re-measured, not taken from the earlier note

| measurement | result |
|---|---|
| rows in `field_data_commerce_discount_reference` | **119** over **118** coupons, all `deleted = 0` |
| coupons referencing **more than one** discount | **exactly one — coupon 69** (delta 0 → 57, delta 1 → 58) |
| coupons whose only reference sits at **delta > 0** | **coupon 54** (delta 1 → discount 54) |
| coupons with **no** reference at all | **2** — coupon 9 `STATEBOARD`, coupon 13 `laurenshultz` |
| references pointing at a **deleted** discount | **0** |
| duplicate coupon **codes** in D7 | **0** — the `CouponCode` uniqueness constraint cannot be violated |
| coupons total / active | 120 / 119 |
| discounts 57 and 58 | both `status = 1`, both carry a real date window |
| usage ledger for the two | `discount_sari_and_supporters` **2 uses** (orders 6354, 6577) · `discount_sari_50` **0 uses** |
| orders carrying coupon 69 | 6354 and 6577 — the same two |

## The rule implemented — lowest delta present, not delta zero

`promotion_id` now resolves to **the reference with the lowest delta**. That is not a stylistic
choice; a `delta = 0` rule would have silently detached **coupon 54** from discount 54, which
exists and is live. Coupon 54's only reference sits at delta 1.

Across the whole source the rule changes exactly one coupon's outcome — coupon 69 — and it
resolves to **57**, the approved answer, corroborated by D7's own ledger rather than merely
permitted by it.

New source plugin **`ce_coupon`** (`Coupons.php`) groups the references per coupon. Grouping is
required, not cosmetic: a plain join emits **two rows for coupon 69 sharing one id-map key**, the
same duplicate-key defect already documented for the usage migration. Verified after the change —
`migrate:status` reports `ce_commerce_coupon` at **120**, not 121.

## What is lost, precisely, and where it is recorded

New process plugin **`ce_log_unrepresentable_link`** writes one orphan row per dropped reference
**before** the coupon migrates, then returns the kept value unchanged so the coupon still migrates.

- `orphan_class` **`unrepresentable_link`** — a new class, deliberately distinct from
  `missing_promotion` / `missing_coupon`. **Nothing is missing.** Both discounts exist and are
  live; the *destination* has no column for a second link.
- `disposition` **`NOT_MIGRATED`** — the only honest value. Not `PRESERVED`: R-24 must not count a
  relationship that does not exist in D10.
- the **complete source row as JSON**, the sole D10-side record that the second link existed.
- the write is **deduplicated** (`OrphanLogger::hasLogged`). An unrepresentable link is a fact
  about the schema, not about a run — a rollback and re-import must not inflate R-24.

**Exactly one row is expected across the entire migration: coupon 69 → promotion 58.**

### What "lost" does and does not mean

Lost: coupon 69's ability to **trigger promotion 58** in future.

Not lost: no redemption, no price, no order, and not promotion 58 itself. **Promotion 58 still
migrates in full** — its own status, window, offer and usage limits intact. It simply has no coupon
attached, which is exactly how it will appear in Commerce 3. D7 records **zero** uses of it and its
window closed **2020-04-30**.

### The two coupons with no promotion are faithful, not a gap

Coupons 9 and 13 keep `promotion_id` NULL. `promotion_id` is **not a required base field**
(verified at runtime), and in D7 those coupons referenced no discount either — so they triggered
nothing then and trigger nothing now. Nothing is invented to fill them.

> ⚠️ D7 also has a *discount* named `STATEBOARD` (id 9) and one named `laurenshultz` (id 13),
> matching those two coupon codes. **No reference row links them**, so no link is created. The
> resemblance is a naming coincidence in the source, and inventing the relationship it suggests
> would be fabricating a business fact.

## Constraint checks

| check | result |
|---|---|
| duplicate coupon code introduced | **none possible** — 0 duplicates in D7, codes copied verbatim |
| `promotion_id` cardinality | 1 — one value written per coupon |
| promotion 57 available as a target | yes — it carries a real window and migrates in the first 91 |
| id-map keys unique | yes — 120 source rows for 120 coupons |
