> ## Documentation Index
> Fetch the complete documentation index at: https://docs.laportenard.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Odoo 18 migration plan

> How and when we move the nu_restaurant_pos addon from Odoo 12 to Odoo 18 without changing the POS app or the sync hub.

<Info>
  Status: draft for the owner to review. Written 2026-09-29 from branch `claude/hub-edges-odoo-exit`.
  Every count below comes from `grep` over this repo, with tests excluded unless the row says otherwise.
  Anything marked **Assumption** is about Odoo 18 or third-party modules and is not verified from this repo.
</Info>

## Why and target

* We run the backend addon `nu_restaurant_pos` (manifest version `12.0.1.35.0`) on Odoo 12.
* Odoo 12 gets no security fixes. **Assumption:** Odoo supports only the three latest major versions, so Odoo 12 lost support around late 2021.
* CI tests the addon on Python 3.8 (`.github/workflows/backend.yml:17`). **Assumption:** Odoo 18 needs Python 3.10 or newer.
* Target: **Odoo 18**, Community or Enterprise (see [open questions](#open-questions-for-the-owner)).

## Strategy

1. **Keep the `/pos-api/v1/*` contract as it is.** We own it. The POS app and the hub talk to it, not to Odoo models. If the routes, request shapes and response shapes stay the same, the app and the hub do not need a release for the migration.
2. **Port the addon behind that contract.** Only the Python code that turns API calls into Odoo records changes.
3. **The Odoo POS JavaScript rewrite does not affect us.** Odoo moved its POS screen to OWL. Our POS screen is our own React app, so we skip the largest part of a normal POS upgrade. The addon has one small backend widget (`static/src/js/pos_colorpicker.js`) to port.

### Who calls Odoo today

| Caller | What it calls | Stays stable? |
| - | - | - |
| POS app (`nu_pos_react/src`) | Only `/pos-api/v1/*` (58 distinct paths in the source). No `/web/*`, `/jsonrpc` or bus calls. | Yes, if we keep the contract. |
| Hub, default mode | `execute_kw` on `/jsonrpc` (or `/xmlrpc/2/object` in `xmlrpc` mode) → `pos.order.hub_sync_snapshot` and `pos.order.hub_finalize_snapshot` (`upstream/jsonrpcAdapter.ts:58-112`, `upstream/xmlrpcAdapter.ts:98-146`). Both methods are ours (`models/pos_order.py:1195`, `:1665`). | Yes. The method names are ours. The transport is Odoo's (see risk 5). |
| Hub, floor plan | `execute_kw` → `search_read` on core `restaurant.floor` (`id`, `name`) and `restaurant.table` (`id`, `name`, `seats`, `floor_id`) (`upstream/floorPlanFetcher.ts:205-211`). | **No.** It reads core fields directly. |
| Hub, auth | `/web/session/authenticate` (`index.ts:219`, dev fallback in `auth/authValidator.ts:27`); our `/pos-hub/validate-session` (`auth/authValidator.ts:82`) and `/pos-hub/events-flush` (`index.ts:276`). | Our routes: yes. The core login route: **Assumption:** still present in 18. |
| Hub, `http` mode | `/pos-react/api/orders/create` and `/update` (`upstream/odooAdapter.ts:159-160`). | These routes no longer exist in the addon. The mode is already dead. Remove it. |

The addon registers 64 routes: 61 in `controllers/api_v1.py` (55 `type="json"`, 6 `type="http"`) and 3 in `controllers/api_hub.py`.

## Blockers and risks (ranked)

1. **Tax localization for Odoo 18.** The addon depends on `dgii`, `dgii_pos` and `dgii_encf` (NCF and e-CF). They are not in this repo. It calls their models directly: `pos.order.ncf.seq.get_ncf` (`controllers/api_orders.py:465`), `ncf.sequence` (`models/pos_bootstrap_data.py:190`), and it works around their `_process_order` and `create_picking_job` behaviour (`models/pos_order.py:280`, `:303`). **Open question:** does the vendor ship these for Odoo 18? If not, we must choose another Dominican localization and rewrite the NCF flow. This decides whether the project is possible on our timeline. The same applies to `marcos_stock`, `marcos_pos_ui`, `pos_backend_sync` and `simple_menu` in `__manifest__.py`.
2. **Payments model change.** Odoo 12 stores POS payments as bank statement lines (`statement_ids`). **Assumption:** Odoo 13+ uses `pos.payment`, and session cash control no longer uses `account.bank.statement` cashboxes. We use `statement_ids` 64 times in 5 Python files, and build cashboxes by hand for session open and close (`controllers/api.py:366`, 39 hits in that file). Payments, credit, reservation advances and session close all need a rewrite.
3. **Invoices become `account.move`.** **Assumption:** `account.invoice` merged into `account.move` in 13.0. We use `account.invoice` 17 times in 6 files, including the credit-note flow (`account.invoice.refund`, `models/pos_order.py:1035`) and the picking-sale invoice wizard (`wizards/picking_sale_invoice_wizard.py:152`). NCF numbers live on invoices, so this is tied to risk 1.
4. **Historic data.** Two paths:
   * **OpenUpgrade** 12 → 13 → 14 → 15 → 16 → 17 → 18. Six hops. Every non-core module needs a migration script per hop. **Assumption:** OpenUpgrade coverage for `point_of_sale` and the Dominican modules at each hop is unknown.
   * **Fresh Odoo 18 database** with master data (products, partners, taxes, floors) and opening balances. Old orders stay read-only in the Odoo 12 database. Much less work. Needs the owner to accept that DGII reports for past periods come from the old system.
5. **Hub transport.** The hub calls Odoo's generic `execute_kw` with a password. **Assumption:** `/jsonrpc` and `/xmlrpc/2` still work in 18 but are deprecated in later versions. The floor-plan read touches core `restaurant.table.name`. **Assumption:** that field changed in 18. Fix: move both behind our own `/pos-hub/*` routes before the port.

## Inventory of Odoo 12-only code

Scope: `nu_restaurant_pos/` (about 19,500 lines of non-test Python, 27 test files with about 8,700 lines). Constructs that are not present are left out (no `@api.one`, `track_visibility` or `oldname`).

| Construct | Count (non-test) | Example | Replacement (Assumption where noted) |
| - | - | - | - |
| `@api.multi` | 25 in 10 files | `models/pos_session.py:25` | Delete (13.0). |
| `@api.model_cr` | 1 | `report/discount_report.py:167` | Delete; keep plain `init()`. |
| `account.invoice` / `.line` / `.refund` | 17 in 6 files | `controllers/api_orders.py:353` | `account.move` / `account.move.line`, reversal wizard. |
| `action_invoice_open()` | 2 | `models/pos_order.py:1093` | `action_post()`. |
| `move.post()` | 3 in 2 files | `models/pos_order.py:2574` | `action_post()`. |
| `out_invoice` / `out_refund` domains, `residual` | 6 in 4 files; 14 in 3 files | `controllers/api_customers.py:633` | `move_type`, `amount_residual`. |
| `statement_ids` (payments) | 64 in 5 files (95 in 14 with tests) | `controllers/api_orders.py:231` | `payment_ids` / `pos.payment`. |
| `add_payment` / `_payment_fields` | 7 in 2 files | `controllers/api_orders.py:810` | `pos.payment` create. **Assumption.** |
| `account.bank.statement` + cashbox | 9 in 6 files; `cashbox` 39 in `controllers/api.py` | `controllers/api.py:366` | 18 cash-control API. **Assumption.** |
| `cash_register_id` | 6 in `models/pos_session.py` | `models/pos_session.py:30` | Removed. **Assumption.** |
| `journal_ids` on `pos.config` | 12 in 4 files | `models/pos_bootstrap_data.py` | `payment_method_ids`. **Assumption.** |
| `create_from_ui` / `_process_order` / `_order_fields` | 29 in 4 files / 6 in 4 files / 7 in 2 files | `models/pos_order.py:190` | 18 order-sync entry point and signature. **Assumption:** renamed/re-shaped. |
| `pos.order.picking_id` | 7 in 1 file | `models/pos_order.py:1098` | `picking_ids`. **Assumption.** |
| `selection_add` on `pos.order.state` | 1 (3 custom states) | `models/pos_order.py:144` | Needs `ondelete=` per value (13.0+). |
| `sudo(user)` | 5 in 3 files | `models/account_invoice.py:35` | `with_user(user)`. |
| `request.uid = …`, `request._env`, `request._cr` | 10 in 2 files | `controllers/api_v1.py:183`, `controllers/api_orders.py:53` | `request.update_env(user=…)`. **Assumption:** 16.0 HTTP rewrite. |
| `ir.http._dispatch(cls)` override | 1 | `models/ir_http.py:12` | New `_dispatch(cls, endpoint)` / `_post_dispatch`. **Assumption.** |
| `odoo.registry(db).cursor()` | 4 in 2 files | `models/pos_order.py:459` | `Registry(db).cursor()`. **Assumption.** |
| `request.jsonrequest` | 5, all in `lib/http_compat.py` | `lib/http_compat.py` | Rewrite `_raw_json_body()` only. Done in advance. |
| `type="json"` routes, `cors=` | 58 routes; `cors=` 31 in `api_v1.py` | `controllers/api_v1.py` | Keep; recheck the preflight and CORS handling. **Assumption:** `http_compat.py` says `type="jsonrpc"` from 18.0; confirm in the spike. |
| `name_get` | 2 | `models/side_dish_group.py:46` | `_compute_display_name` (17.0). |
| XML `attrs=` | 30 in 13 view files | `views/nu_pos_config_views.xml:37` | Inline `invisible=` / `required=` / `readonly=` (17.0). |
| `<tree>` views, `view_mode` tree | 27 in 19 files; 15 in 15 files | `views/nu_pos_config_views.xml:7` | `<list>` / `list` (18.0). |
| Cron `numbercall` | 2 | `data/ncf_sweep_cron.xml:18` | Remove field (17.0). |
| Manifest `qweb` key, `web.assets_backend` inherit, `odoo.define` JS | 1 each | `__manifest__.py:62`, `views/pos_category_views.xml:28` | Manifest `assets` (15.0), ES module/OWL widget. |
| Migration scripts | 6 folders `12.0.*` | `migrations/` | Drop; start `18.0.1.0.0`. |
| `ir.config_parameter` | 9 in 6 files | `controllers/api_v1.py` | No change expected. **Assumption.** |

Every row above sits behind the `/pos-api/v1` contract, so none of it is visible to the app.

## Schedule

**Assumptions:** one developer full time on this; the tax localization exists for 18; start on 2026-10-05. Ranges are developer-weeks (dw) of work, then calendar time.

| Phase | Work | Effort | Calendar (1 dev) |
| - | - | - | - |
| 0. Prepare in Odoo 12 | See the list below. | 2–3 dw | Oct 2026 |
| 1. Spike on Odoo 18 | Odoo 18 + localization + our auth, bootstrap, and one order with NCF and payment. **Go / no-go gate.** | 2–3 dw | Nov 2026 |
| 2. Port the addon | All inventory rows; rewrite payments, invoices and session close; port the 27 test files; run the Phase 0 contract tests green on 18. | 8–12 dw | Dec 2026 – Feb 2027 |
| 3. Data migration rehearsal | Fresh DB + opening balances: 2–4 dw. OpenUpgrade 6 hops: 8–14 dw. Rehearse at least twice on a copy of production. | 2–14 dw | Feb – Apr 2027 |
| 4. Parallel run and cutover | One site on 18 with a rollback path; compare daily totals and NCF sequences; then cut over in the freeze window. | 2–3 dw + 2–4 weeks of watching | Mar – May 2027 |

Total: about **16–35 dw**, or **4–8 months** for one developer. The low end needs a fresh database. The high end is OpenUpgrade. A no-go at Phase 1 stops the plan until the localization question is solved.

### Phase 0: what we can do in Odoo 12 now

Odoo 12 still needs `@api.multi`, `account.invoice`, `statement_ids` and `attrs`, so we cannot remove them yet. We can make the port smaller and safer:

* **Contract snapshot tests for `/pos-api/v1`.** Record the request and response shape of each route (bootstrap, orders create/update, payments, sessions open/close, credit note, reserve NCF). Run the same tests on 18 in Phase 2. This is our definition of "done".
* **Small helpers like `http_compat.py`:** put payment reads/writes (`statement_ids`, `add_payment`, cashbox) in `lib/payments_compat.py` and invoice access in `lib/invoice_compat.py`. The port then changes a few functions, not 64 call sites.
* **Wrap `request.uid` / `request._env` and `odoo.registry()`** in one helper each.
* **Move the hub off core models.** Add `/pos-hub/floor-plan` and `/pos-hub/order-snapshot` routes in the addon, and point the hub at them. Delete the dead `http` adapter mode.
* **Freeze new direct dependencies** on `dgii_*` and `marcos_*` models. Add them only behind helpers.

## Open questions for the owner

1. **Localization vendor.** Who maintains `dgii`, `dgii_pos`, `dgii_encf` and the `marcos_*` modules? Do they have Odoo 18 versions, and at what cost? If not, which Dominican localization do we use?
2. **Historic data.** Do we need past POS orders and invoices inside the new Odoo, or is a read-only Odoo 12 archive enough for audits and DGII reports?
3. **Edition and hosting.** Community or Enterprise? Self-hosted or Odoo.sh? This affects OpenUpgrade and the Python/PostgreSQL versions.
4. **Freeze window.** When can we stop changes to the Odoo 12 addon, and which low-traffic weeks allow cutover?
5. **Version choice.** **Assumption:** Odoo 19 is already out, so 18 has a shorter support window than a newer version. Confirm 18 is still the target.
6. **Other addons.** `nu_recipe_management` (`12.0.1.2.0`) is also an Odoo 12 addon in this repo. Is it in scope?

## Done already

* **`lib/http_compat.py`** is the only reader of `request.jsonrequest` (5 reads, all in that file). The port changes `_raw_json_body()` and leaves the seven controllers that use it alone.
* **`lib/order_line_codec.py`** is the single wire → Odoo line mapper, so order-line field changes happen in one place.
* **The app already talks only to `/pos-api/v1/*`.** No app change is needed if the contract holds.
