# Uber Eats Menu Integration — POS (poscloud) Category & Product Access

|                  |                                                                                                                                             |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **Created**      | 2026-09-11                                                                                                                                  |
| **Status**       | Draft — awaiting discussion (no code written yet)                                                                                           |
| **Scope**        | `delivery.apper` (code + config). **No schema changes** — poscloud is a remote **production** DB, read-only.                                |
| **Related docs** | Uber Eats Menu guide (`developer.uber.com/docs/eats/guides/menu-integration`), `database/README.md`, `tasks/paket-servis-pos-eslestirme.md` |
| **Sibling plan** | `paket-servis-pos-eslestirme.md` (branch↔integration mapping — this plan builds on its `selected_pos_service_id` model)                     |

---

## 1. Goal

Read the selected POS branch's **menu data** (`categories` + `products`) from the remote
**poscloud** production database, so it can later be transformed and pushed to Uber Eats
through the **Menu API** (`GET /menus`, `PUT /menus`, `POST /menus/items`).

This document is a **discussion draft**. The committed near-term scope is only:

1. Correctly configure the remote poscloud DB connection (`.env` lines 18–26 + `config/database.php`).
2. Add **read-only** Eloquent models for poscloud `categories` and `products`, scoped to the selected branch.

The Uber Eats menu **mapping + push** (Phases 4–5) is the eventual goal but carries open
decisions and an external dependency (Uber written approval), so it is drafted as a roadmap.

---

## 2. Uber Eats Menu API — summary of the guide

Every store location has its own configurable menu, built from four entity types:

| Uber Eats entity   | Meaning                                                                                                           |
| ------------------ | ----------------------------------------------------------------------------------------------------------------- |
| **Menu**           | Groups categories + `service_availability` (menu hours). One per fulfillment type (delivery / pick-up) or shared. |
| **Category**       | A logical menu section (e.g. "Appetizers", "Soft Drinks").                                                        |
| **Item**           | Anything a user selects (dish, drink, topping, condiment).                                                        |
| **Modifier Group** | Groups items selectable as a customization under a parent item.                                                   |

**Endpoints (paths to be confirmed against the Menu API reference / Postman collection):**

- `GET  /menus` — retrieve a store's menu.
- `PUT  /menus` — upload/overwrite the whole menu (destructive: replaces existing menus).
- `POST /menus/items` — update individual items (out-of-stock / back-in-stock / price). Only works if the menu was originally uploaded via the API.

**Key constraints:**

- **Auth:** app access-token via **client_credentials** grant with at least the `eats.store` scope. Our `.env` already sets `UBER_EATS_CLIENT_CREDENTIAL_SCOPES="eats.store eats.order"` ✅.
- **Access to the Menu API "may require written approval from Uber"** — an external blocker to confirm with our Uber partner manager (see Open Question Q5).
- **Store hours** = union of `service_availability` across all menus.
- **Images:** < 25 MB; JPG/WEBP/PNG; 320px ≤ width/height ≤ 6000px. Images can take hours to process.
- **Alcohol:** if offered, must populate `dish_info.classifications.can_serve_alone` and `alcoholic_items`.
- **Availability hours** are set at the **item** level via `visibility_info` (not per category); modifiers inherit the parent item's visibility.
- **Do not** edit API-managed menus manually in Menu Maker (sync conflicts).

---

## 3. Data source — poscloud schema (verified from `poscloud/database/migrations`)

The remote DB is `PRODUCTION_poscloud` on `mysql-remote`. Relevant tables:

### `categories`

| Column                      | Type                           | Notes                                                      |
| --------------------------- | ------------------------------ | ---------------------------------------------------------- |
| `id`                        | int unsigned                   | PK                                                         |
| `company_id`                | int unsigned                   | FK → `companies.id` (**branch link**)                      |
| `name`                      | string                         |                                                            |
| `invoice_title`             | string nullable                |                                                            |
| `app_enabled`               | bool nullable (default 1)      |                                                            |
| `menu_enabled`              | bool nullable (default 1)      | filter: only `menu_enabled = 1` belongs on a delivery menu |
| `order_number`              | tinyint unsigned (default 255) | sort order                                                 |
| `data`                      | json nullable                  | added 2026-05-24                                           |
| `deleted_at`                | soft deletes                   |                                                            |
| `created_at` / `updated_at` | timestamps                     |                                                            |

### `products`

| Column                                  | Type                                                               | Notes                                                        |
| --------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ |
| `id`                                    | int unsigned                                                       | PK                                                           |
| `company_id`                            | int unsigned nullable                                              | FK → `companies.id` (**branch link**)                        |
| `barcode`                               | string(30) nullable                                                |                                                              |
| `name`                                  | string                                                             |                                                              |
| `description`                           | string nullable                                                    | maps to Uber item description                                |
| `category_id`                           | int unsigned nullable                                              | FK → `categories.id` (made nullable 2026-03-28)              |
| `order_number`                          | tinyint unsigned (default 255)                                     |                                                              |
| `is_active`                             | tinyint unsigned nullable                                          | filter: only active products                                 |
| `price`                                 | decimal(6,2)                                                       | base/net price                                               |
| `base_costs` / `profit` / `gross_price` | decimal(8,2) unsigned nullable                                     | `gross_price` is the sell price incl. VAT + service fee      |
| `status`                                | string                                                             |                                                              |
| `vat_pct`                               | enum('0','1','8','18') nullable                                    |                                                              |
| `service_fee_type_id`                   | int unsigned nullable                                              | FK → `service_fee_types`                                     |
| `tracking_section_id`                   | int unsigned nullable                                              | FK → `tracking_sections`                                     |
| `sabe_id`                               | int unsigned nullable                                              |                                                              |
| `is_stock_enabled`                      | tinyint unsigned nullable                                          |                                                              |
| `uom`                                   | enum(piece, kilogram, gram, liter, centiliter, milliliter, minute) | unit of measure                                              |
| `course`                                | string(50) nullable                                                | added 2026-06-26                                             |
| `getir_id`                              | (existing)                                                         | per-product Getir id — precedent for a future `uber_eats_id` |
| `deleted_at`                            | soft deletes                                                       |                                                              |
| `created_at` / `updated_at`             | timestamps                                                         |                                                              |

### Related tables (relevant to the full menu mapping, **not** in the committed scope)

| Table                                                                        | Role                                                   | Uber Eats analogue                      |
| ---------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------- |
| `category_product` (`category_id`, `product_id`, `is_primary`, `channel`)    | M2M category↔product with a primary flag + per-channel | item→category assignment                |
| `specs` (`product_id`, `name`, `multiselect`, `enabled`, `channel`)          | product specification groups                           | **Modifier Group**                      |
| `options` (`spec_id`, `name`, `price_diff`, `default`, `channel`)            | spec choices                                           | **Modifier option (Item)**              |
| `channel_products` (`product_id`, `channel`, `name`, `description`, `price`) | per-channel product override                           | platform-specific item text/price       |
| `channel_categories` (`category_id`, `channel`, `name`)                      | per-channel category override                          | platform-specific category name         |
| `product_translations` / `category_translations` (`locale`, `name`, …)       | i18n                                                   | localized item/category names           |
| `photos` (product `hasOne` photo)                                            | product images                                         | item image (must meet Uber image specs) |

**Existing channel mechanism:** poscloud already models multi-platform menus via
`ChannelTypes = { sabee, getir, yemeksepeti, menu }`. There is **no `uber_eats` channel yet**.
This is the natural precedent for storing Uber-specific item/category overrides (see Q3).

---

## 4. Entity mapping (poscloud → Uber Eats) — draft

| Uber Eats       | poscloud source                                                                             | Transform notes                                                                           |
| --------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Menu            | one branch's whole menu (`company_id = service_id`)                                         | decide single shared menu vs per-fulfillment; define `service_availability` (store hours) |
| Category        | `categories` where `menu_enabled = 1`, `company_id = service_id`, ordered by `order_number` | name from `channel_categories`/translations if present                                    |
| Item            | `products` where `is_active = 1`, `company_id = service_id`                                 | price: `gross_price` (VAT-inclusive) vs `price` — see Q4; description from `description`  |
| Modifier Group  | `specs` (`enabled = 1`) of the product                                                      | `multiselect` → Uber min/max selection                                                    |
| Modifier option | `options` of the spec                                                                       | `price_diff` → modifier price; `default` → preselected                                    |
| Item image      | product `photo`                                                                             | must satisfy Uber image constraints                                                       |

---

## 5. Remote DB configuration — the current problem

There is a **three-way naming mismatch** that must be resolved before use:

| Where                         | Value                                 | Reads/Uses                                                                                                 |
| ----------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `.env` line 20                | `DB_CONNECTION_REMOTE=mysql_poscloud` | **dead** — not referenced anywhere in `config/database.php`                                                |
| `config/database.php` line 67 | connection key `mysql-remote`         | reads `DB_HOST_REMOTE`, `DB_PORT_REMOTE`, `DB_DATABASE_REMOTE`, `DB_USERNAME_REMOTE`, `DB_PASSWORD_REMOTE` |
| orphaned tests/scripts        | `mysql-remote`                        | `tests/Unit/Models/{PosCategory,StockItem,…}Test.php`, `tests/test_qr_*.php`                               |

**Facts:**

- `DB_CONNECTION_REMOTE` is currently **unused** — the working connection key is hardcoded `mysql-remote`, and it already reads the correct `DB_*_REMOTE` values. So connectivity itself likely works today; only the _name_ is inconsistent.
- The orphaned `PosCategoryTest` etc. describe the **posmanager** schema (`parent_id`, `order`, `stockItems`) — **not** the real poscloud `categories` schema above. They are already flagged for cleanup as **T2** in the sibling plan and must be discarded/rewritten, not trusted.

### Decision D1 — connection name (recommendation: keep `mysql-remote`)

- **Option A (recommended, least churn):** keep the connection key `mysql-remote`; new models use `protected $connection = 'mysql-remote'`. Fix `.env` so `DB_CONNECTION_REMOTE` is not misleading — either remove it or set it to `mysql-remote` as documentation.
- **Option B (semantic rename):** rename the key to `poscloud`, update the 3 `tests/test_qr_*.php` scripts, and delete the orphaned posmanager tests (T2). Clearer, but more edits.

### Decision D2 — read-only enforcement (recommendation: belt-and-suspenders)

poscloud is a **production** database. `database/README.md` already mandates SELECT-only for the shared DB; the same must hold here.

1. **DB-level (real guarantee):** confirm the `posmanager_apper_user` MySQL account has **SELECT-only** grants on `PRODUCTION_poscloud`. (Cannot verify from here — needs a DBA/host check.)
2. **App-level guard:** an abstract base model (e.g. `App\Models\PosCloud\PosCloudModel`) pinned to the remote connection that **throws on any write** (`save`/`update`/`delete`/`create`), so a stray write fails loudly instead of touching production.
3. **Performance:** enable `Model::preventLazyLoading()` in local/dev to avoid accidental N+1 queries across the WAN, and always eager-load relations explicitly.

### Decision D3 — where menu data is read/cached

- Reading a remote production DB synchronously inside web requests can be slow. Consider caching the fetched menu in the **cache store** (`file`/`redis`) per branch — **not** in a new DB table, because creating tables requires an `admin.apper` migration (forbidden here).

---

## 6. Branch scoping — how a branch maps to poscloud rows

Per the sibling plan + project memory: `user_accesses` (`type='pos'`) rows represent branches;
`user_accesses.service_id` → **poscloud `companies.id`**, and `integrations.relation_id = service_id`.
poscloud `categories.company_id` / `products.company_id` therefore equal the selected branch's `service_id`.

So the branch's menu = `categories`/`products` where `company_id = session('selected_pos_service_id')`.

> **Assumption to verify (Q1):** confirm `service_id` really equals poscloud `companies.id` for a live branch before relying on it.

---

## 7. Task list (phased)

### Phase 1 — Remote connection config _(committed scope)_

- [ ] **1.1** Resolve Decision **D1** (connection name) and align `.env` + `config/database.php`.
- [ ] **1.2** Add/confirm the remote connection block reads all `DB_*_REMOTE` vars; add a `PDO` connect timeout suitable for a WAN link.
- [ ] **1.3** Verify connectivity **read-only** (e.g. `SELECT 1`, `SELECT COUNT(*) FROM categories LIMIT 1`) via tinker/artisan — no writes. Confirm the DB user's grants (D2.1).

### Phase 2 — Read-only models for `categories` & `products` _(committed scope)_

- [ ] **2.1** Create `App\Models\PosCloud\PosCloudModel` (abstract): `protected $connection = 'mysql-remote'`, write methods throw (D2.2).
- [ ] **2.2** `App\Models\PosCloud\Category` → table `categories`, casts (`data` array, `menu_enabled`/`app_enabled` bool, `order_number` int), `SoftDeletes`; relations `products()` (M2M via `category_product` and/or `hasMany` via `category_id`), `company()`.
- [ ] **2.3** `App\Models\PosCloud\Product` → table `products`, casts (`price`/`gross_price` decimal, `is_active` bool, `vat_pct` string), `SoftDeletes`; relations `category()`, `categories()`, `specs()`/`options()` (for later), `photo()`.
- [ ] **2.4** Branch scope helper: `scopeForCompany($companyId)` / `scopeForSelectedBranch()` reading `selected_pos_service_id`, **fail-closed** (no selection → empty result), mirroring the sibling plan's `scopeForSelectedPos()`.
- [ ] **2.5** Menu filters: categories `menu_enabled = 1`; products `is_active = 1`; order by `order_number`.
- [ ] **2.6** Delete/rewrite the orphaned posmanager tests (T2) so the suite reflects the real poscloud schema.

### Phase 3 — Menu repository / query layer _(bridge to Uber)_

- [ ] **3.1** A `PosCloudMenuRepository` that, for a branch, returns categories+products (eager-loaded, cached per D3) in a normalized structure.
- [ ] **3.2** A read-only screen/command to preview the branch menu pulled from poscloud (sanity check before any Uber push).

### Phase 4 — Uber Eats menu mapping + push _(roadmap; needs decisions + Uber approval)_

- [ ] **4.1** Confirm exact Menu API endpoint paths + payload schema (Menu API reference / Postman collection) — the guide only gives `/menus` shorthands.
- [ ] **4.2** `UberEatsMenuService`: transform the normalized branch menu → Uber payload (Menu → Category → Item → Modifier Group → option), resolving Q2/Q3/Q4.
- [ ] **4.3** Wire into `UberEatsApiService`: `getMenus()`, `putMenus()` (overwrite), `updateMenuItem()` using a **client_credentials** token (`eats.store`) from `UberEatsTokenService`.
- [ ] **4.4** Item-id strategy: stable external ids so `POST /menus/items` (stock/price) works after the initial `PUT`.
- [ ] **4.5** Image handling: validate/convert product photos to Uber's constraints (or skip images initially).

### Phase 5 — UI + verification _(roadmap)_

- [ ] **5.1** Branch-scoped "Menu → Push to Uber Eats" action + preview/diff.
- [ ] **5.2** Sandbox end-to-end: push menu to the test store, verify it renders in the Uber Eats test account, then test an item price/stock update.

---

## 8. Open questions (to discuss)

- **Q1 — Branch↔company link:** is `user_accesses.service_id` guaranteed to equal poscloud `companies.id`? Any branches where it differs?
- **Q2 — Menu shape:** one shared menu per branch, or separate delivery / pick-up menus? What are the store's `service_availability` hours (source of truth)?
- **Q3 — Channel overrides:** do we add an `uber_eats` value to poscloud `ChannelTypes` and use `channel_products`/`channel_categories` for Uber-specific names/prices (Getir precedent), **or** push the base `categories`/`products` as-is for now? (Adding a channel value = a poscloud schema/enum change, outside this repo.)
- **Q4 — Price semantics:** which price goes to Uber — `gross_price` (VAT + service-fee inclusive) or `price`/net? Uber marketplaces normally expect the consumer-facing (gross) price. Confirm against Turkish VAT rules.
- **Q5 — Uber approval:** has the Menu API been enabled/written-approved for our client id? Without it, `PUT /menus` will 403 regardless of our code.
- **Q6 — Modifiers:** include `specs`/`options` (modifier groups) in the first push, or start with flat categories+items and add modifiers later?
- **Q7 — Sync direction & trigger:** manual push from the UI, scheduled sync, or event-driven? And how do we avoid clobbering manual Menu Maker edits (guide warns against mixing)?
- **Q8 — Translations:** which locale(s) do we push (base `name` vs `product_translations`)? Uber marketplaces are locale-specific.

---

## 9. Acceptance criteria (committed scope: Phases 1–2)

1. The remote poscloud connection is configured with a single, consistent name and connects successfully **read-only**.
2. `Category` and `Product` models read from `mysql-remote`, match the **actual** poscloud schema, and any write attempt throws.
3. Given a selected branch (`selected_pos_service_id`), the models return only that branch's `menu_enabled` categories and `is_active` products; with no selection they return nothing (fail-closed).
4. No migration files are created and no write query is issued against poscloud (per `database/README.md`).
5. Secrets (DB password) stay in `.env` only — never committed to the plan, code, or memory.

---

## 10. Related files

| File                                             | Role                                                  |
| ------------------------------------------------ | ----------------------------------------------------- |
| `.env` (18–26)                                   | Remote poscloud credentials (`DB_*_REMOTE`)           |
| `config/database.php` (`mysql-remote`)           | Remote connection definition                          |
| `database/README.md`                             | Read-only / no-migration rule                         |
| `app/Models/PosCloud/*`                          | **New** — read-only poscloud models                   |
| `app/Models/UserAccess.php`                      | Branch list (`type='pos'`, `service_id`)              |
| `app/Models/Integration.php`                     | Branch↔platform mapping (`relation_id`, `account_id`) |
| `app/Services/UberEats/UberEatsApiService.php`   | Add Menu API calls (Phase 4)                          |
| `app/Services/UberEats/UberEatsTokenService.php` | client_credentials token (`eats.store`) for menu push |
| `config/uber_eats.php`                           | OAuth / API base / scopes                             |
| `tasks/paket-servis-pos-eslestirme.md`           | Branch-selection model this plan builds on            |
| `poscloud/database/migrations/*`                 | Authoritative poscloud schema (read-only reference)   |
