# Zoho integration: Books first, CRM as the hub for customer apps

Agreed design, 2026-11-07, on branch `feat/zoho-books-sync-2026-11-07`. The accountant approved the brief on
2026-11-09. **Built so far:** phase 0 (the client, the chart / tags set-up, §3a) and phase 1 (the sync and the
reference data, §3b). **No money posts to Books yet** (phase 2).

- Decisions: [ADR 0009](../../adr/0009-net-accounting-in-zoho-books.md).
- The accountant's posting matrix, chart of accounts and worked example (Arabic, signed before any code is
  written): [`zoho-accounting-brief.md`](zoho-accounting-brief.md).

## 1. Zoho apps and their data

- **Finance family: one organization database.** Books, Inventory, Expense and Commerce share the same
  organization id, contacts, items and settings. The `kapitano` organization (935558326) has all four
  joined. Never switch on a Commerce storefront: it would share Books' items.
- **Everything else has its own database:** CRM, Desk, SalesIQ, Analytics, Campaigns / Marketing
  Automation, Cliq, Mail and Connect. Zoho One shares only the staff directory between them.

**Data flow:**

```
store ──(every customer and seller, key kapitano_user_id / kapitano_shop_id)──► CRM ──► Desk, SalesIQ, campaigns, Analytics
store ──(money + the customer of each invoice + sellers as vendors + items)──► Books
store MySQL views (kapitano_reports) ──Databridge──► Analytics
```

| Data | Books | CRM (later) | Analytics (later) |
|---|---|---|---|
| Currencies | SYP base, USD and every seller currency in use, plus dated rates from `exchange_rate_changes` | Home currency SYP, set before any data | MySQL |
| Categories | Reporting tag "top-level category" (19 values) on invoice lines, and the item's category | Picklist for segments | Full tree from MySQL |
| Governorates | Reporting tag on the invoice, and the contact address | Field | MySQL |
| Customers | Contact created at the first invoice, with a unique `kapitano_user_id` | Every customer, with phone normalized | — |
| Sellers | Every seller, as a vendor in their currency, with `kapitano_shop_id` | Account or vendor | — |
| Products | One item per `product_stocks` row (SKU, product id, stock id), created at its first sale, prices kept in sync | Later | MySQL |

## 2. Rules

- **The store is the only source.** Edits happen in the store. Changes made by hand in Zoho to a synced
  field are overwritten.
- **The store links the records**, by its own key, held as a unique custom field (Books' "Prevent Duplicate
  Values"; CRM unique fields). Never rely on Zoho's native CRM↔Books customer matching: it matches by name.
- **Money is in SYP at the order's rate.** Use `convert_order_price()`, `combined_order_payable_total()`,
  `order_customer_amount()` and `order_coupon_share()`. Seller documents are in `shops.price_currency`,
  with an explicit `exchange_rate`.
- **The invoice line is what the customer paid** (`order_details.price`), never the item's rate.
- **A posted document is never edited**; changes are posted as difference documents. A date inside a
  locked period moves to the first open date, with a note.

## 3a. Built (2026-11-09)

| Where | What |
|---|---|
| `config/zoho.php` | `enabled`, `dc`, OAuth client + refresh token, `organization_id`, `live_organization_id` (935558326), `allow_live_writes` (false), `timeout`, `daily_budget` (8000) |
| `App\Services\Zoho\ZohoClient` | The one door. `books($method, $path, $query, $body)` / `booksAll()` answer a `ZohoResponse` and never throw. Token cached, refreshed under `Cache::lock`; 401 → one retry; 429 → `retryAfter`; daily budget per organization. **Any write to the live organization is refused** unless `ZOHO_ALLOW_LIVE_WRITES=true`: the same token reaches it |
| `App\Services\Zoho\ZohoResponse` | `ok`, `http`, `code` (Books' own, 0 = success, 1001 = duplicate number), `message`, `data`, `refused` (never asked Zoho), `isPermanent()` (4xx except 401 / 408 / 429: the outbox parks it) |
| `App\Services\Zoho\Books\BooksChart` | The accounts the integration posts to, by **role** → **code** (`ACCOUNTS`). `id('seller_clearing')` resolves by code, cached a day per organization. A system account is matched by its code, else by the English name Zoho gave it in a new organization |
| `App\Services\Zoho\Books\BooksTags` | The 5 reporting tags (`TAGS`). Category and governorate options are read from the store's own tables when the command runs. `option('category', $name)` → `tag_id` + `tag_option_id` |
| `php artisan zoho:books-setup [--org=] [--apply]` | Compares and, with `--apply`, creates / renames accounts, creates tags, adds options and switches tags on. Never deletes. Never changes an account's type or currency: Books cannot, so a mismatch is reported. Lists the accounts it does not manage, for the accountant |
| `php artisan zoho:oauth-exchange {code}` | Grant code → refresh token in `.env`, never printed |

**Learnt from Books (tried on Kapitano-test, 2026-11-09).** The details are in the brief, §9.

- **Accounts and currencies:**
  - A foreign-currency box must be of type `bank`: `cash` takes SYP only.
  - Payables accounts are in SYP. The vendor carries the currency.
  - A bill picks its payables account with a document-level `account_id`.
- **Invoices:**
  - An invoice's `adjustment` needs an income or other-income account, so 7090 is `other_income`.
  - Line tags: `tags: [{tag_id, tag_option_id}]` on the line or on the document.
  - The invoice `discount` posts to the system Discount account (4600). `shipping_charge` posts to Shipping Charge (4210).
  - A repeated `invoice_number` is refused with code 1001.
- **Payments:** A customer payment with no invoice credits Unearned Revenue (4400). Applying it later moves it to receivables.
- **Reporting tags:**
  - A new tag is a draft and inactive until `POST reportingtags/{id}/active`.
  - Its options are read from `GET reportingtags/{id}/options/all` → `results`, which includes a pseudo option `untagged`.
- **Not settled yet:**
  - The cost of an inventory item sold on an invoice was not posted (stock fell 10 → 9; 5100 / 1210 untouched).
  - Period locking has no API.

**Kapitano-test (945827730) now holds:**

- the full chart (`BooksChart::ACCOUNTS`) and the 5 active tags;
- the brief's §5 example as `TEST-*` documents.

**The live organization is untouched.**

**Tests:** `tests/Feature/Zoho/` (`ZohoClientTest`, `BooksSetupTest`, `OAuthExchangeCommandTest`). Every Zoho test calls `Http::preventStrayRequests()`: a fake whose pattern misses the query string otherwise lets the request reach the real Zoho. It happened once, refused only because the token was fake.

## 3b. Phase 1: the sync and the reference data (2026-11-09)

```
store change ──listener──► ZohoSync::queue() ──► zoho_outbox (one row per thing, re-armed)
                                                   │ zoho:send, every minute
                                                   ▼
                                    ZohoOutboxSender ──► handler build() / push() ──► Zoho
                                                   │
                                                   ▼
                                    zoho_links (store id ↔ Zoho id, last body's hash)
```

| Where | What |
|---|---|
| `zoho_outbox` | One row per thing, keyed `<app>:<entity>:<local id>` (unique), **re-armed** at every change. The body is built when sent, from the store as it is then. A change while sending (`requeued_at`) sends again. Copy of `logistic_outbox` (migration `2026_11_09_000001`) |
| `zoho_links` | Store record ↔ Zoho id **per app and organization** (`org`), so the test and the live organization never mix. `payload_hash` = the last body Zoho accepted: the same body is not sent again |
| `App\Services\Zoho\ZohoSync` | The map and entry point: `HANDLERS`, `enabled()`, `mode()`, `queue()`, which never throws |
| `App\Services\Zoho\ZohoOutboxSender` | Claim with one UPDATE. Then per row: skip → done; data problem → parked; dry run / not on the allowlist → done `dry_run` (the body kept on the row); same body → `unchanged`; sent → link. Not configured / budget / live guard / 401 / 403 → **paused**, the try not counted and the batch stopped. Network / 408 / 429 / 5xx → `BACKOFF` (1 min … 12 h), then parked. Other 4xx → parked, and Cliq is told |
| `App\Services\Zoho\ZohoAlerts` | Cliq channel webhook (`ZOHO_CLIQ_WEBHOOK`), at most once an hour per kind |
| `Sync\VendorSync` (`books:vendor`, shop id) | Every seller is a vendor **in the shop's currency**, named "Shop (S12-USD)" (Books needs unique names). **One vendor per shop and currency** (link `12:USD`): a seller switched to SYP gets a new vendor, because Books cannot change a vendor's currency. A name already in Books is adopted. The in-house shop is skipped. **Only an approved shop** (`verification_status = 1`) becomes a vendor; once made, it keeps following its shop through any later approve / stop (Books never deletes vendors) |
| `Sync\RateSync` (`books:rate`, Damascus date) | The store's rate, as SYP per unit of every currency Books has enabled, dated today. One row per day, so the last change of the day wins. Refused unless the Books base currency is SYP |
| `Sync\ItemSync` (`books:item`, product_stocks id) | One item per variant. Phase 2 creates it at the first sale. After that, the price, cost, name and SKU follow the store (`QueueZohoItem` re-queues only variants that are linked). In-house products are inventory items (4100 / 5100 / 1210). A seller item is sales-only on 4150, because Books links items to income accounts only; every invoice line names 2150 itself. An SKU already in Books is adopted |
| `Sync\CrmCustomerSync` (`crm:customer`, users.id) | Every active customer, upserted **100 per call** by the unique field `ZOHO_CRM_USER_KEY_FIELD` (users.id). The phone is normalized (+963…), with the governorate and city of the default address. Sandbox unless `ZOHO_CRM_SANDBOX=false`, and production also needs `ZOHO_ALLOW_LIVE_WRITES` |
| `App\Listeners\Zoho\*` (`EventServiceProvider`) | `Shop` saved (name / currency / owner / phone / approval) → vendor. `ExchangeRateChange` created → today's rate. `ProductStock` / `Product` updated (price, cost, SKU, name...) → item, linked ones only. `User` saved (customer fields) and `Address` saved / deleted → CRM customer |
| Commands | `zoho:send` (scheduled every minute), `zoho:queue {vendor\|customer\|item\|rate} {ids?*} {--all}` (also the first fill), `zoho:status`, `zoho:retry {ids?*} {--parked}` |
| Admin → Zoho sync | `admin.zoho.index`, `Admin\ZohoSyncController`. The state, rows by status (needs attention first), "Send again". `view_zoho_sync` looks, `manage_zoho_sync` resends; both are granted to "full access" |

**Modes** (`ZOHO_MODE`, used when a row is sent):

| Mode | What it does |
|---|---|
| `dry_run` (default) | Builds and keeps every body, sends nothing |
| `allowlist` | Sends only what concerns the users in `ZOHO_ALLOWLIST`, plus rates. The rest stays dry run |
| `live` | Sends everything |

`ZOHO_ENABLED=false` queues nothing at all.

**Learnt from the real run on Kapitano-test (2026-11-09):**

- 3 vendors (USD and SYP), today's rate (148) and 2 items (one in-house, one seller) were sent.
- **Books' automatic exchange-rate feed quotes the old Syrian pound** (12,987 per dollar against our ~148), and it is on by default for every foreign currency. `zoho:books-setup` reports it and `--apply` switches it off. **It must be off on the live organization before phase 2.**
- A second rate for the same date is accepted (no lookup needed). The rate list shows only the feed's rates.
- **The organization-wide feed must be off too** (Settings → Currencies → "Disable Exchange Rate Feeds", no API found). While it is on, Books ignores the dated rates the store sends: a USD document gets the old 12,987 rate, in the API and in the UI (tried 2026-11-09). Once it was off, the UI offered 148. Every foreign-currency document the store sends still states `exchange_rate` explicitly.
- Only an approved shop (`verification_status = 1`) becomes a vendor; a vendor once made follows its shop through any later approve / stop and is never deleted.

**Deploy:**

1. `php artisan migrate`: the two tables, the permissions, and the Arabic strings.
2. Keep `ZOHO_ENABLED=false` until the accountant is ready.
3. Then `ZOHO_ENABLED=true` with `ZOHO_MODE=dry_run`.
4. `php artisan zoho:queue vendor --all`, then read the bodies on Admin → Zoho sync.
5. Then `allowlist` / `live`.

**CRM needs, before `ZOHO_CRM_ENABLED=true`:**

- A new grant code (`zoho:oauth-exchange`: its scopes now include `ZohoCRM.modules.contacts.ALL`, `ZohoCRM.settings.fields.READ` and `ZohoCRM.settings.modules.READ`).
- The CRM field `Kapitano_User_ID`, single line and unique, in the sandbox first.
- The CRM home currency SYP.
- Then `zoho:queue customer --all`.

## 3c. Phase 2a: the sales invoice of a checkout (2026-11-10)

| Where | What |
|---|---|
| `Sync\InvoiceSync` (`books:invoice`, combined order id) | `KAP-<id>`, once the checkout is delivered (`combined_delivery_status()`), dated the last delivery (Damascus). Body in store terms (account roles, tag names, customer user id); `push()` resolves the organization's ids |
| Total | `combined_order_payable_total()` × the order's rate, rounded once like every order total (`PrepaidGateways::amountSyp()`, `order_total_rounding_unit()`: the nearest 10 since 2026-11-09 - what is shown, paid online and collected by the captain). Lines, coupon (`discount` → 4600) and shipping (`shipping_charge` → 4210) are converted unrounded; the rest is `adjustment` on 7090. A gap over half the rounding step + 5 SYP (10 at the nearest 10) is a store bug: parked. Books' own total must equal ours or the draft is never posted |
| Line kinds (`lineKind()`) | seller (frozen `purchase_price`) → 2150; in-house (`added_by = admin`) → 4100 with its inventory item (`ItemSync`, made on first sale), so Books posts the cost a few minutes later; partner store → 2150 like any seller (decided 2026-11-10: a partner store is a seller shop; its share leaves 2150 from `order_details.line_cost` in the seller bills, since it freezes no seller balance); a seller line with no frozen cost (orders placed before 2026-10-12) → parked |
| Tags | category (top-level) and seller type per line; governorate (shipping address `state`), channel (`orders.order_from`), payment (`payment_type`) on the invoice. An option Books lacks is left off |
| `Books\BooksCustomers` | One Books contact per account, "<name> (U<id>)", made with its first invoice, linked as `user`. Adopted by exact name when it exists |
| Push | contact → items → `POST invoices?ignore_auto_number_generation=true` (the flag only works in the query string: in the body Books answers 4097) → `status/sent` (an API invoice is a draft). Each step links as it succeeds; a number Books has (1001) is adopted, so a retry never posts twice |
| Never edited | A linked invoice whose body changed is parked ("a credit note is needed") until phase 2b builds difference documents |
| Gate | `MONEY` handlers take the stricter of `ZOHO_MODE` and `ZOHO_MONEY_MODE` (default `dry_run`), and nothing is queued without `ZOHO_START_AT`; a checkout delivered before it is skipped |
| Trigger | `QueueZohoInvoice` on `Order` updated (delivery_status). `zoho:queue invoice <ids> | --all` (since the start date) |

**Measured on the local copy (351 checkouts delivered since 2026-10-15):** 329 build (partner stores included), 21 have orders placed before 2026-10-12 (no frozen seller cost), 1 not fully delivered. The largest rounding was 5 SYP. Two real checkouts (one with an in-house product, one with coupon and shipping) were posted on Kapitano-test: totals 14,280 and 485 equal the store's, posted (not draft), rounding on 7090, tags on lines and invoice.

**Open:**

- Partner-store bills (phase 2b) come from `order_details.line_cost` (frozen since 2026-11-09, backfilled from the audit log), not `commission_histories`: the partner store has no seller balance in the store. Lines without `line_cost` wait for a person.
- Before the payments handler: what a captain collects per parcel (`order_customer_amount()`, rounded per parcel?) against the invoice total rounded once. A difference would drift 1130 by a few SYP per multi-seller checkout.
- In-house items made at first sale have no opening stock in Books: check the cost Books posts for them, and whether in-house items must exist with stock before cutover (brief §8).
- How often the tags find their option on the 237 buildable checkouts (a governorate spelled differently is left off silently).
- The admin page shows no money mode or start date yet (`zoho:status` does), and the dry-run bodies are readable only in the database: the accountant's two-week review needs a view.
- A linked invoice is judged on its money only (`meta['fingerprint']`): a renamed product or customer never makes it "differ". The Books contact carries no e-mail (Books could mail invoices or reminders; reminders are off on both organizations, checked 2026-11-10).

## 3d. Phase 2b: seller bills, commission, payments, payouts (2026-11-10)

Map: `App\Services\Zoho\Books\BooksMoney` (docblock). Every handler is in `ZohoSync::MONEY`.

| Handler (`books:<entity>`, local id) | Document | Rule |
|---|---|---|
| `SellerBillSync` (`seller_bill`, `commission_histories.id`) | bill `KAP-<c>-S<row>` / vendor credit `KAP-<c>-SC<row>` | One per row of the store's seller ledger (positive = bill 2150 → 2110, negative = credit), in the row's currency and amount at the order's rate, so Books' payable follows `shops.admin_to_pay`. Rows of the old rule (before `seller_balance_purchase_since`) are skipped |
| `PartnerBillSync` (`partner_bill`, `orders.id`) | bill `KAP-<c>-T<order>` | A partner store's paid seller order: Σ `order_details.line_cost` of its live lines (partner stores have no seller ledger). A line without `line_cost` waits for a person |
| `CommissionSync` (`commission`, `orders.id`) | journal, reference `KAP-<c>-C<order>` | 2150 → 4150 = what the invoice put into 2150 for the seller order (`zoho_links.snapshot.clearing`) − the SYP of its bills, net of credits. Never copied from `admin_commission`. Negative (below cost, Kapitano's part of a shared discount) debits 4150 (decided 2026-11-10). Waits for every bill of the order |
| `PaymentSync` (`payment`, combined order id) | customer payment `KAP-<c>-PAY` | Cash on delivery, once every live seller order is paid: the invoice's total into 1130, applied to the invoice. Online prepaid waits for a person (next slice: an advance into 1145 / 1146 applied at delivery) |
| `PayoutSync` (`payout`, `payments.id`) | vendor payment, reference `KAP-OUT-<id>` | A payout in its currency from the cash box of that currency (1121 pounds, 1122 dollars: decided 2026-11-10) at the payout day's rate, applied to the seller's open bills oldest first (decided 2026-11-10; tried: $6 at 150 on a $10 bill at 148 left it partly paid with 4 due and posted 12 to 6400 by itself; the payment's receipt in Books lists the bills), the rest an advance. "The seller paid us" waits for a person; before `ZOHO_START_AT` skipped |

- **Dependencies, never retries:** while money is really sent a document skips until what it needs is linked, and the prerequisite queues it on success (`SyncHandler::afterSent()`): the invoice → payment, bills, partner bills, commissions; a bill → its commission. In a dry run nothing is linked, so each is built from the store as if the invoice were there.
- **What the invoice said is kept on its link** (`zoho_links.snapshot`, migration `2026_11_10_000010`): its total, date and what it put into 2150 per seller order.
- **Duplicates (tried on Kapitano-test 2026-11-10):** bills (13011, per vendor), vendor credits and credit notes (12018), customer payments (1001, with `ignore_auto_number_generation` in the query) refuse a number twice, and the existing one is adopted. Journals and vendor payments do not, so they are looked up by reference before every post. A vendor credit goes to 2110 when its `account_id` says so; a vendor payment may be left unapplied.
- **Not edited:** a posted document whose money later differs waits for a person (credit notes and the commission difference for returns are the next slice).
- **Tried end to end on Kapitano-test (2026-11-10), checkout 5553 (4 seller orders):** invoice 2,480 paid by the COD payment (balance 0), 5 seller bills 2,534.28 and 4 commission journals 446.88 against the 2,981.16 the invoice put into 2150: **2150 = 0**.
- **Dry run on the local copy (351 checkouts delivered since 2026-10-15):** 329 invoices, 315 payments, 556 seller bills, 90 partner bills, 480 commission journals build; the rest wait (not paid yet, orders before 2026-10-12, one Paymera payment, one partner line without `line_cost`).

**Guards added after review (2026-11-10):**

- A dependent checks that the posted invoice still matches the store (a fresh build's fingerprint against the link's): a line cancelled or returned after the invoice parks the payment and the commission until credit notes exist, so 1130 never receives, and 4150 never keeps, money that is not there.
- An adopted document must carry our amount (`BooksMoney::find($expected)`): one Books holds under the same number with another amount waits for a person.
- A seller we owe is made a vendor whatever the shop's approval now (`VendorSync::$ignoreApproval`).
- Books' 100 requests a minute: a 429 pauses the run, the try not counted.
- An invoice that is already in Books (`unchanged`) queues its dependents again, so `zoho:queue invoice --all` re-drives everything.

**Open before the live cutover:**

- **Straddlers (decided 2026-11-10):** a checkout delivered before `ZOHO_START_AT` and paid before it is in the opening entry (skipped); one not paid when Books starts gets a real invoice dated the start date ("delivered on …" in its notes), and its payment, seller bills and commission follow as for any checkout. Partly paid before the start waits for a person. Seller ledger rows and partner orders paid before the start are in the opening balance (skipped), so the opening entry holds `admin_to_pay` as it is at the start. `zoho:queue invoice --all` takes the straddlers too.
- Vendor credits (returns) are not applied to bills yet: they reduce the seller's balance in Books but leave the bills open.
- `allowlist` splits one checkout's documents by owner (customer, sellers): use `live` on the test organization.

**Deploy:** `php artisan migrate` (`2026_11_10_000010`, the `zoho_links.snapshot` column), then the money settings of §3c.

**Still to build (2c):** credit notes and vendor credits for returns, the commission difference; online prepaid payments; the daily audit (`zoho:audit`: 2150 = 0 per completed order, payable = `admin_to_pay`).

## 3. Planned map (to build)

| Where | What |
|---|---|
| `config/zoho.php` | Read from `.env` only: `enabled`, `dc`, `organization_id`, OAuth client and refresh token, account and tag ids, `start_at`, `mode` (`off`, `dry_run`, `allowlist`, `live`), `cliq_webhook` |
| `App\Services\Zoho\ZohoClient` | OAuth access token cached about 55 min, refreshed under `Cache::lock` (shape of `EcashClient`). One retry on 401, backoff on 429, a daily call budget. One client for every Zoho app |
| `zoho_outbox` | Copy of `logistic_outbox`: operation, unique idempotency key, status, attempts, `next_attempt_at`, claim |
| `zoho_links` | `local_type`, `local_id`, `app`, `module`, `zoho_id`, `payload_hash`, `state`: one table for every app |
| `App\Listeners\Zoho\*` | On Eloquent events, no model edits: `Order` (delivery / payment status), `CommissionHistory` created, `Payment` created, `OnlinePayment` paid / refunded, `Shop` saved, `ExchangeRateChange` created, `Product` / `ProductStock` (prices). Each writes an outbox row inside try/catch |
| `App\Services\Zoho\Books\*Mapper` | Builds each payload at send time, from the current state |
| `ZohoOutboxSender` | Claim, retry schedule, park 4xx for a person. A fixed document number with `ignore_auto_number_generation`; on a duplicate, look the document up and adopt it (Logistic's 409 path) |
| Commands | `zoho:send` (every minute), `zoho:prices` (every 15 min), `zoho:reconcile` (every 10 min), `zoho:audit` (daily), `zoho:retry`, `zoho:status`, `zoho:cutover-report` |
| Admin | "Zoho sync" page (`view_zoho_sync`, `manage_zoho_sync`), plus "Open in Zoho" on the order page |

**`zoho:audit` checks every day**, and posts the result to Cliq and the admin page. Any money mismatch
sets `mode` to `off`.

- Delivered totals = invoice totals.
- Each seller's payable = `shops.admin_to_pay`.
- Clearing account 2150 = 0 per completed order.
- Commission = the sum of `commission_histories.admin_commission`.
- In-house item quantities match (a warning only).

## 4. Phases

0. **Set-up, no production code:**
   - ✅ The accountant signs the brief (2026-11-09).
   - The live Books organization is cleaned and the chart rebuilt. Two steps:
     1. `zoho:books-setup --org=935558326` without `--apply` shows the differences.
     2. With the user's approval and `ZOHO_ALLOW_LIVE_WRITES=true`, `--apply` creates and renames. Deletions are made by hand, by the accountant.
   - ✅ A test Books organization (Kapitano-test, 945827730) and an OAuth Self Client. Still to do: the integration user for production.
   - ✅ Zoho MCP for development: read-only Books.
   - ✅ The open points in the brief are tested on the test organization (brief §9). Two are left: the in-house item's cost, and period locking.
   - CRM's home currency is set to SYP.
1. ✅ **Foundation and reference data** (§3b, 2026-11-09). Built: client, tables, sender, admin page, Cliq alerts; currencies and
   rates, tags, sellers as vendors, items and their prices; customers to CRM (one way, keyed). Prices ride the
   re-armed outbox row instead of a separate `zoho:prices`. CRM is built but off, pending the scopes and field above.
2. **Transactions.** 2a ✅ the checkout's sales invoice (§3c), 2b ✅ seller bills, commission, COD payments, payouts (§3d), 2026-11-10. Next: 2c returns, prepaid, audit. Earlier plan: 2b seller bills and the commission
   journal (option (a) of brief §9), customer payments (COD and prepaid), credit notes and vendor credits for returns,
   seller payouts. Rollout: `dry_run` on production for two weeks (the accountant reviews), then the
   test organization, then `allowlist`, then `live`.
3. **Cutover** at the start of a month, from `zoho:cutover-report`.
4. **Later, each with its own plan:** Analytics, CRM in full, Desk, SalesIQ, campaigns.

## 5. Store gaps found while designing

- Setting a paid order back to unpaid does not reverse the commission (`commission_calculated` stays 1).
- Delivery staff remittances, Logistic COD remittances, the eCash 1% fee and refunds are recorded nowhere
  in the store. They are manual entries in Books.
- In-house order lines freeze no purchase price. Books computes their cost from inventory, and only 282 of
  the 1,014 in-house products have a purchase price.
- The `kapitano_reports` views lack `online_payments`, `return_requests`, `payments.currency` / `note` and
  `commission_histories.currency` / `seller_amount`. Add them before Analytics.
- Confirm that the cron for `schedule:run` exists on production (an open question in `00-platform.md`).
