{"info":{"title":"WACCA Service","version":"0.1.0","description":"HTTP API untuk **WACCA** — platform SaaS multi-tenant untuk bisnis\nlaundry di ekosistem ikavia. Setiap **tenant** = 1 organisasi (di\naccount-service) → punya banyak **outlet** (cabang fisik) → outlet\npunya **items** (`service` / `product` / `driver`) + **harga per-outlet**\nuntuk masing-masing item + **jam operasional** + **galeri foto**.\n\nBackend Go (gin + GORM + PostgreSQL). Auth delegated ke **Account\nService** — JWT RS256 di-verify via JWKS endpoint `account.ikavia.com`.\n\nTwo top-level resources scoped per-organization: `tenant` (profil bisnis\nlaundry) dan `customer` (profil konsumen). Sisanya nested di bawah\n`tenant/outlet/*`.\n","base_urls":[{"label":"Production","url":"https://wacca.ikavia.com","default":true},{"label":"Local","url":"http://localhost:7281"}],"overview_cards":[{"icon":"🧺","title":"Domain model","description":"Tenant → Outlet → Items → Prices","content":"```\nTenant (1 per org_id)\n  └── Outlet (banyak)\n        ├── Media (galeri foto)\n        ├── TimeOperation (jam buka/tutup)\n        ├── Item (type: service | product | driver)\n        └── Price (per-outlet, polymorphic ke item)\n```\n\n- **Tenant** profil bisnis laundry; 1:1 dengan organization_id dari JWT.\n- **Outlet** cabang fisik; punya status `is_opening` (sedang buka/tutup), `driver_available`.\n- **Item** entitas yang ditawarkan outlet. Kolom `type` membedakan:\n  - `service` — layanan cuci (kering, setrika, dry-clean, dll)\n  - `product` — barang dagangan (deterjen, pewangi)\n  - `driver` — sumber daya antar-jemput\n- **Price** harga per-outlet per-item. Polymorphic: `service-prices`, `product-prices`, `driver-prices` semua ke tabel `price` yang sama tapi punya endpoint terpisah supaya UI bisa CRUD per-kategori dengan field nama domain.\n- **Customer** = sebuah **account** account-service (bukan tabel terpisah lagi). Identitasnya (`name`) di-mirror lokal di tabel `accounts` dan **read-only** di wacca; `customer_id` di seluruh sistem = `account_id` (JWT `sub`). Alamat konsumen disimpan di tabel `address` (default address = lokasi konsumen).\n"},{"icon":"🧾","title":"Response envelope","description":"snake_case JSON + UUIDv7 + request_id","content":"Success:\n```json\n{ \"success\": true, \"data\": { ... }, \"message\": \"...\", \"request_id\": \"uuid\" }\n```\n\nSuccess (paginated list):\n```json\n{\n  \"success\": true,\n  \"data\": [...],\n  \"pagination\": {\n    \"page\": 1, \"limit\": 20, \"total\": 123,\n    \"total_pages\": 7, \"has_next\": true, \"has_prev\": false\n  },\n  \"message\": \"...\",\n  \"request_id\": \"uuid\"\n}\n```\n\nError (4xx surfaced apa-adanya, 5xx generic):\n```json\n{ \"success\": false, \"message\": \"...\", \"request_id\": \"uuid\" }\n```\n\n- **ID**: `uuid v7` (lexicographic time-ordered)\n- **Tanggal**: ISO-8601 UTC\n- **URL**: kebab-case (`outlet-products`, `service-prices`)\n- **JSON keys**: snake_case (`organization_id`, `mobile_number`)\n- **Soft delete**: setiap entitas punya `deleted_at` (gorm.DeletedAt); list endpoints auto-filter row terhapus.\n- **Field shape — always-present, never-omitted (2026-06-03)**: response presenter di-strip `omitempty`-nya. Field zero (`\"\"`, `0`, `false`, `null`) **selalu hadir** di JSON, bukan absent. Konsumen perlu cek empty-value secara eksplisit, bukan \"key missing\". Pointer field (`*string`/`*int`) tetap kirim `null` kalau nil.\n"},{"icon":"🔐","title":"Permission model","description":"JWT permission claim + per-resource gate","content":"Tiap resource family digate via `RequirePermission` middleware:\n\n| Resource family | Permission required |\n|---|---|\n| `/api/v1/tenant` + semua child (`outlet`, `outlet-*`, `*-prices`) | `wacca-tenant:*` |\n| `/api/v1/customer` | `wacca-customer:*` |\n\nWildcard `*` di-honor sebagai owner/platform-admin override (tidak perlu daftar permission spesifik). Tidak ada bypass khusus untuk service principal — kalau bot ingin hit endpoint, JWT-nya harus mengandung permission yang sesuai.\n"},{"icon":"🏢","title":"Multi-tenancy","description":"Org boundary diturunkan dari JWT","content":"Setiap request membawa `org_id` (active organization) dan `sub` (account_id) di JWT. **Tenant** dipetakan 1:1 ke `org_id` (kolom `organization_id` di `tenant_detail`); **Customer** dipetakan ke `sub` (account_id). Outlet/Item/Price/Media/TimeOperation di-scope ke `tenant_id` yang turunan dari `organization_id`.\n\n**Tidak ada cross-org access.** Outlet dari org A tidak bisa di-fetch via JWT org B — query repository selalu filter via `tenant.organization_id = jwt.org_id`. Switch org via `account-service` endpoint `POST /organizations/{org-id}/switch` → terima JWT baru.\n"},{"icon":"🚦","title":"Outlet status","description":"Dua flag terpisah — is_active vs is_opening","content":"| Field | Arti | Cara set |\n|---|---|---|\n| `is_active` | Outlet ada (belum di-archive) | Saat `DELETE` → soft-delete. Tidak di-toggle manual. |\n| `is_opening` | Sedang buka secara real-time | `PATCH /tenant/outlet/{id}/open-outlet` ↔ `/close-outlet` |\n| `driver_available` | Driver standby (siap antar) | `PATCH /tenant/outlet/{id}/open-drivers` ↔ `/close-drivers` |\n\nFrontend kasir pakai `is_opening` untuk allow/disallow order; `driver_available` untuk toggle delivery option. Logika business \"boleh terima order kalau outlet `is_opening \u0026\u0026 is_active`\" diserahkan ke konsumen (order-service, future).\n"},{"icon":"🗂","title":"Items vs Prices","description":"Kenapa tiga endpoint terpisah untuk satu tabel","content":"Tabel `item` polymorphic via kolom `type`. Endpoint controller-nya dipecah tiga (`outlet-services`, `outlet-products`, `outlet-drivers`) untuk UX yang lebih nyaman di UI:\n\n- Frontend laundry punya screen \"Daftar Layanan\", \"Daftar Produk\", \"Daftar Driver\" terpisah → langsung pakai endpoint kategorinya.\n- Filter `type=service` selalu ter-apply otomatis oleh use_case — caller tidak perlu kirim manual.\n\nHal yang sama berlaku untuk `price`:\n- `service-prices` → field domain `outlet_service_id`\n- `product-prices` → field domain `outlet_product_id`\n- `driver-prices` → field domain `outlet_driver_id`\n\nDi storage layer ketiganya satu tabel `price` dengan kolom `item_id`. Use case resolve `outlet_*_id` → item lookup → outlet derivation → simpan dengan `tags=\"by-outlet\"`.\n"}]},"authentication":{"methods":[{"type":"JWT Bearer","header":"Authorization","format":"Bearer \u003caccess_token\u003e","source":"account-service","description":"RS256 JWT diterbitkan oleh **account-service** (`account.ikavia.com`). Wacca-service verify signature via JWKS endpoint dengan cache disk (`AUTH_KEYS_PATH=/app/data/auth_keys.json`) + auto-refresh 24h. Kalau no-keys mode aktif (upstream unreachable + cache kosong) semua endpoint authenticated balas `503 auth temporarily unavailable`.\n\n**Identitas = account-service.** Email \u0026 display_name di-mirror lokal ke tabel `accounts` lewat event RabbitMQ (exchange `account.events`, env `RABBITMQ_URL`). Karena itu endpoint identitas di wacca **read-only**: field `owner_name` (tenant) dan `name` (customer) di-resolve dari `accounts` dan diterima-tapi-no-op saat create/update. Mutasi profil dilakukan di account-service.\n","token_contains":["sub (account_id UUID)","sid (session_id, kosong untuk service principal)","org_id (active organization)","permissions[] (wacca-tenant:*, wacca-customer:*, *)","principal_type (human|service)","kid (JWKS key reference)","exp, iat, iss, jti"]}]},"flow_overview":{"methods":[{"type":"JWT Bearer","steps":[{"title":"Login","detail":"Human: `POST https://account.ikavia.com/api/v1/auth/login` → terima `access_token` + `refresh_token`.\nService principal: `POST /auth/token-exchange` dengan `X-API-Key` → terima 1h JWT.\n"},{"title":"Pastikan org aktif sesuai konteks","detail":"JWT cuma bawa **satu** `org_id`. Untuk pindah konteks:\n```\nPOST https://account.ikavia.com/api/v1/organizations/{org-id}/switch\nAuthorization: Bearer \u003ccurrent_jwt\u003e\n```\nResponse berisi JWT baru scoped ke org target.\n"},{"title":"Bootstrap tenant (sekali per organisasi)","detail":"`POST /api/v1/tenant` dengan `business_name` (`owner_name` diterima tapi read-only — di-resolve dari `accounts` via `owner_account_id` = JWT `sub`). 1 organisasi maksimal 1 tenant. Setelah ini outlet + item + price bisa di-CRUD.\n"},{"title":"Refresh saat 401","detail":"Access token TTL ~15 menit. Saat 401, panggil `POST /auth/refresh` di account-service untuk dapat token baru (atau pakai cookie `.ikavia.com`).\n"}]}]},"sections":[{"id":"chat","title":"Chat — Customer ↔ Outlet","description":"Endpoint shared customer ↔ tenant (owner/staff) untuk percakapan per `(outlet_id, customer_id)`. Satu chat room = satu `channel_id` di **chat-service** (microservice terpisah). Wacca menyimpan mapping `(outlet, customer, channel)`; pesan, history, dan unread state hidup di chat-service.\n\n**Deteksi role JWT** (RequirePermission temporarily off untuk chat — owner/customer pakai endpoint yang sama):\n- JWT permission punya `wacca-tenant:*` → caller dianggap **tenant** (owner/staff). `org_id` = tenant scope.\n- Selain itu → caller dianggap **customer**. `sub` = account_id.\n\n**Visibility**:\n- **Customer** hanya lihat chat miliknya sendiri (`customer_id = claim.sub`).\n- **Tenant** lihat chat semua customer untuk outlet milik org-nya (resolve `outlet_id` → tenant scope).\n","endpoints":[{"name":"List chats","method":"GET","path":"/api/v1/chats","auth":"JWT Bearer","description":"Daftar chat room — customer melihat chat miliknya, tenant melihat chat outlet-nya.\nFilter `outlet_id` opsional (umumnya FE customer pakai untuk filter chat per outlet aktif).\n","query_params":[{"name":"outlet_id","type":"string","description":"Filter ke outlet tertentu (UUID)."}],"example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"01923c8b-1234-7000-a000-000000000c01\",\n      \"channel_id\": \"f1a2b3c4-d5e6-7890-1234-567890abcdef\",\n      \"customer_id\": \"a2f32c84-639c-4778-8d30-2cc1ba3ca427\",\n      \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n      \"created_at\": \"2026-06-10T07:00:00Z\",\n      \"updated_at\": \"2026-06-10T07:00:00Z\"\n    }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"Get chat by ID","method":"GET","path":"/api/v1/chats/{id}","auth":"JWT Bearer","description":"Detail satu chat room. Server cek visibility — customer harus owner chat, tenant harus pemilik outlet.\n\n**404** kalau bukan visible scope. **`channel_id` dipakai FE** untuk join WebSocket / poll API chat-service untuk message list.\n"},{"name":"Create chat","method":"POST","path":"/api/v1/chats","auth":"JWT Bearer","description":"Bikin chat room baru (atau idempotent — kalau pair `(outlet, customer)` sudah ada, return chat existing). `customer_id` resolve otomatis dari JWT (`sub`).\n\nServer panggil chat-service untuk mint `channel_id` baru, simpan mapping di tabel `chat`.\n\nKhusus **customer** — wacca insert + delegate ke chat-service. Tenant biasanya tidak hit ini (customer yang mulai), tapi tidak diblok kalau caller punya wacca-tenant:* dengan `outlet_id` yang valid untuk org-nya.\n\n**400** kalau outlet_id invalid / bukan milik tenant ini (untuk tenant caller).\n","example_body":"{\n  \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-000000000c01\",\n    \"channel_id\": \"f1a2b3c4-d5e6-7890-1234-567890abcdef\",\n    \"customer_id\": \"a2f32c84-639c-4778-8d30-2cc1ba3ca427\",\n    \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n    \"created_at\": \"2026-06-10T07:00:00Z\",\n    \"updated_at\": \"2026-06-10T07:00:00Z\"\n  },\n  \"message\": \"chat created\"\n}\n"}]},{"id":"customer-complaint","title":"Customer — Complaint / Dispute","description":"Customer membuka komplain atas order (mis. hasil cuci kurang bersih, pesanan salah). State machine: `open` → `in_discussion` ↔ `awaiting_customer` → `resolved`/`rejected` → `confirmed`. Owner punya 24h SLA balas — kalau tidak, komplain naik ke `escalated` lalu auto-close.\n\n**Status valid**: `open`, `in_discussion`, `awaiting_customer`, `resolved`, `rejected`, `escalated`, `auto_closed`, `confirmed`.\n\n**R-CHAT.07 auto-close**: komplain di status `awaiting_customer` atau `escalated` \u003e 24h → diam-diam ditutup oleh admin worker. Customer bisa **reopen** sekali (kembali ke `in_discussion`).\n\n**R-CHAT.16**: order yang sudah `escalated` di salah satu komplain → blok komplain baru di order yang sama.\n","endpoints":[{"name":"File complaint","method":"POST","path":"/api/v1/customer/complaints","auth":"JWT Bearer","description":"Buka komplain baru untuk satu order milik customer. **400** kalau order bukan milik caller / sudah resolved. **409** kalau ada komplain `escalated` aktif (R-CHAT.16).\n","example_body":"{\n  \"order_id\": \"01923c8b-1234-7000-a000-0000000000aa\",\n  \"category\": \"result_quality\",\n  \"description\": \"Setelah dicuci masih ada bau apek di kemeja putih\"\n}\n"},{"name":"List my complaints","method":"GET","path":"/api/v1/customer/complaints","auth":"JWT Bearer","description":"Komplain milik customer (paginasi `page`/`limit`, filter `status`)."},{"name":"Get complaint detail","method":"GET","path":"/api/v1/customer/complaints/{id}","auth":"JWT Bearer","description":"Detail komplain + thread balasan. 404 kalau bukan milik customer."},{"name":"Attach photo to complaint","method":"POST","path":"/api/v1/customer/complaints/{id}/photos","auth":"JWT Bearer","description":"Lampirkan foto bukti pada komplain (mis. hasil cuci)."},{"name":"Escalate complaint","method":"POST","path":"/api/v1/customer/complaints/{id}/escalate","auth":"JWT Bearer","description":"Customer eskalasi ke admin platform — biasanya setelah owner reject. Status → `escalated`. **409** kalau state tidak izinkan transisi.\n"},{"name":"Confirm resolution","method":"POST","path":"/api/v1/customer/complaints/{id}/confirm","auth":"JWT Bearer","description":"Customer setujui resolusi owner (`resolved` → `confirmed`). Komplain ditutup."},{"name":"Reopen auto-closed complaint","method":"POST","path":"/api/v1/customer/complaints/{id}/reopen","auth":"JWT Bearer","description":"Buka kembali komplain yang `auto_closed` (R-CHAT.07 option A). Hanya satu kali. Status → `in_discussion`. **409** kalau status bukan `auto_closed`.\n"}]},{"id":"tenant-complaint","title":"Tenant — Complaint Handling","description":"Sisi owner outlet untuk handle komplain customer. Setiap action transition state machine; transisi ilegal → `409`. RBAC outlet-scope: staff hanya akses komplain dari order outlet-nya.\n","endpoints":[{"name":"List tenant complaints","method":"GET","path":"/api/v1/tenant/complaints","auth":"JWT Bearer","description":"Komplain di tenant saat ini (paginasi). Filter `status` (salah satu dari status valid), `outlet_id`. Status filter tidak dikenal → `400`.\n"},{"name":"Get complaint detail","method":"GET","path":"/api/v1/tenant/complaints/{id}","auth":"JWT Bearer","description":"Detail komplain + thread + foto + order context."},{"name":"Acknowledge complaint","method":"POST","path":"/api/v1/tenant/complaints/{id}/acknowledge","auth":"JWT Bearer","description":"Owner accept untuk handle (`open` → `in_discussion`)."},{"name":"Discuss / reply","method":"POST","path":"/api/v1/tenant/complaints/{id}/discuss","auth":"JWT Bearer","description":"Owner balas — pesan masuk ke thread. Set status → `awaiting_customer` (mulai countdown 24h auto-close).","example_body":"{\n  \"message\": \"Mohon maaf, kami akan jemput ulang gratis besok pagi\"\n}\n"},{"name":"Resolve complaint","method":"POST","path":"/api/v1/tenant/complaints/{id}/resolve","auth":"JWT Bearer","description":"Owner tandai komplain selesai (status → `resolved`). Customer perlu confirm untuk close final.","example_body":"{\n  \"resolution_type\": \"rewash\",\n  \"note\": \"Sudah di-cuci ulang, customer terima jemput hari ini\"\n}\n"},{"name":"Reject complaint","method":"POST","path":"/api/v1/tenant/complaints/{id}/reject","auth":"JWT Bearer","description":"Owner tolak komplain (`rejected`). Customer masih boleh `escalate` (R-CHAT.06).","example_body":"{\n  \"note\": \"Berdasarkan foto, kondisi sudah sesuai standar\"\n}\n"},{"name":"Escalate to admin","method":"POST","path":"/api/v1/tenant/complaints/{id}/escalate","auth":"JWT Bearer","description":"Owner escalate ke admin platform (mis. butuh dispute resolution dari pihak ketiga). Status → `escalated`."}]},{"id":"admin-complaint-ops","title":"Admin — Complaint Operations","description":"Background worker admin untuk maintain SLA komplain. Trigger oleh scheduler (cron/job runner) — bukan dipanggil dari UI.\n","endpoints":[{"name":"Auto-close stale complaints","method":"POST","path":"/api/v1/admin/complaints/auto-close","auth":"JWT Bearer","permission":"*","description":"Tutup komplain `awaiting_customer` atau `escalated` yang stale \u003e threshold jam (default 24h dari store setting `auto_close_complaint_hours`). Idempoten — komplain yang sudah final tidak terdampak.\n"}]},{"id":"customer-address","title":"Customer Address","description":"Kelola alamat customer (pickup/delivery + alamat kontak lainnya). Tiap row terikat ke customer via `account_id` (internal); response **tidak** mengexpose `reference_id`/`reference_type` — itu detail polymorphic internal.\n\nSalah satu alamat di-flag `is_default=true` sebagai alamat utama (otomatis untuk alamat pertama). Default address inilah yang dijadikan `location` di [Customer Profile](#customer-profile) dan default tujuan jemput di [Customer Order](#customer-order).\n","endpoints":[{"name":"List addresses","method":"GET","path":"/api/v1/customer/address","auth":"JWT Bearer","description":"Paginasi alamat milik customer (`page`/`limit`, default 1/20). Diurutkan `is_default DESC, created_at DESC` — default selalu pertama.\n","query_params":[{"name":"page","type":"integer","default":"1","description":"Halaman 1-based"},{"name":"limit","type":"integer","default":"20","description":"Jumlah per halaman"}],"example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"01923c8b-1234-7000-a000-000000000050\",\n      \"name\": \"Rumah\",\n      \"address\": \"Jl. Merdeka 10\",\n      \"location\": \"Jakarta Pusat\",\n      \"latitude\": \"-6.175392\",\n      \"longitude\": \"106.827153\",\n      \"category\": \"Rumah\",\n      \"mobile_phone\": \"+62811xxxxxxx\",\n      \"is_default\": true,\n      \"created_at\": \"2026-05-19T10:00:00Z\",\n      \"updated_at\": \"2026-05-29T08:00:00Z\"\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"total\": 1 },\n  \"message\": \"success\"\n}\n"},{"name":"Create address","method":"POST","path":"/api/v1/customer/address","auth":"JWT Bearer","description":"Tambah alamat baru. `name`, `address`, `latitude`, `longitude`, `category`, `mobile_phone` wajib (validasi non-empty). Kalau ini alamat pertama → otomatis `is_default=true` (override `is_default` body).\n\n`category` = label bebas (Rumah, Kantor, Kos, dll). `mobile_phone` = nomor kontak per-alamat (boleh beda dengan profil utama).\n","example_body":"{\n  \"name\": \"Kantor\",\n  \"address\": \"Jl. Sudirman 1, Lt. 12\",\n  \"location\": \"Jakarta Pusat\",\n  \"latitude\": \"-6.208763\",\n  \"longitude\": \"106.845599\",\n  \"category\": \"Kantor\",\n  \"mobile_phone\": \"+62812xxxxxxx\",\n  \"is_default\": false\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-000000000051\",\n    \"name\": \"Kantor\",\n    \"address\": \"Jl. Sudirman 1, Lt. 12\",\n    \"location\": \"Jakarta Pusat\",\n    \"latitude\": \"-6.208763\",\n    \"longitude\": \"106.845599\",\n    \"category\": \"Kantor\",\n    \"mobile_phone\": \"+62812xxxxxxx\",\n    \"is_default\": false,\n    \"created_at\": \"2026-05-30T08:00:00Z\",\n    \"updated_at\": \"2026-05-30T08:00:00Z\"\n  },\n  \"message\": \"address created successfully\"\n}\n"},{"name":"Get address by ID","method":"GET","path":"/api/v1/customer/address/{id}","auth":"JWT Bearer","description":"Detail satu alamat. **403** kalau alamat bukan milik customer pemanggil.\n"},{"name":"Update address","method":"PUT","path":"/api/v1/customer/address/{id}","auth":"JWT Bearer","description":"Partial update — kirim hanya field yang berubah. **403** kalau bukan milik customer. **400 'no fields to update'** kalau body kosong.\n","example_body":"{\n  \"category\": \"Rumah Baru\",\n  \"mobile_phone\": \"+62813xxxxxxx\"\n}\n"},{"name":"Delete address","method":"DELETE","path":"/api/v1/customer/address/{id}","auth":"JWT Bearer","description":"Soft delete. **403** kalau bukan milik customer.\n"},{"name":"Set default address","method":"PATCH","path":"/api/v1/customer/address/{id}/set-default","auth":"JWT Bearer","description":"Jadikan alamat ini default (sekaligus un-set default lain). Transactional. **403** kalau bukan milik customer.\n"}]},{"id":"customer-outlets","title":"Customer — Cari Outlet","description":"Penemuan outlet dari sisi customer (Flow \"Cari Outlet\"). List + detail outlet\nlengkap dengan layanan/produk + harga, rating, jarak ke customer, dan harga\ntermurah. Semua butuh JWT customer.\n","endpoints":[{"name":"List outlets","method":"GET","path":"/api/v1/customer/outlets","auth":"JWT Bearer","description":"Daftar outlet aktif. Query opsional:\n- `search` — cari berdasarkan nama outlet (case-insensitive ILIKE).\n- `driver_available` — `true`/`false`, filter outlet yang punya layanan antar-jemput.\n- `tags` — filter outlet yang punya item dengan tag tsb.\n- `min_rating` — float; hanya outlet dengan `rating \u003e= min_rating` yang muncul. Outlet baru tanpa review punya `rating=0`, jadi akan **tersembunyi** kalau `min_rating \u003e 0`. Nilai non-numeric → filter diabaikan.\n- `min_price` / `max_price` — float; outlet harus punya minimal 1 **service** dengan harga `By Platform` aktif yang masuk rentang. Diterapkan ke service saja (bukan product/driver), match pola gating `customer/item-services`. Bisa pakai keduanya (rentang), salah satu, atau dilewati. Non-numeric → diabaikan.\n- `page`, `limit` — paginasi (default 1/20).\n\nTiap outlet menyertakan: `rating` (agregat), `distance_km` (haversine ke\nkoordinat customer; null bila koordinat tak tersedia), `min_price` (layanan\ntermurah → \"Mulai Rp…\"), `delivery_fee_per_km` (tarif jemput per km), serta\ndaftar `services`/`products`/`drivers` + harga By-Platform.\n","example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"01923c8b-...-0004\",\n      \"name\": \"Clean Express Laundry\",\n      \"address\": \"Jl. Kaliurang Km 5.5, Sleman\",\n      \"latitude\": \"-7.7560\", \"longitude\": \"110.4080\",\n      \"is_opening\": true,\n      \"driver_available\": true,\n      \"delivery_fee_per_km\": 2000,\n      \"is_active\": true,\n      \"rating\": 4.8,\n      \"distance_km\": 0.8,\n      \"min_price\": 7000,\n      \"services\": [\n        { \"id\": \"...\", \"type\": \"service\", \"name\": \"Cuci Reguler\",\n          \"price\": { \"unit_of_measure\": \"kg\", \"rate\": 7000 } }\n      ],\n      \"products\": [],\n      \"drivers\": []\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"total\": 12 }\n}\n"},{"name":"Get outlet detail","method":"GET","path":"/api/v1/customer/outlets/{id}","auth":"JWT Bearer","description":"Detail satu outlet (struktur sama dgn item list — incl. rating, distance_km, min_price, services/products/drivers).","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-...-0004\",\n    \"name\": \"Clean Express Laundry\",\n    \"rating\": 4.8,\n    \"distance_km\": 0.8,\n    \"min_price\": 7000,\n    \"delivery_fee_per_km\": 2000,\n    \"services\": [ { \"name\": \"Cuci Reguler\", \"price\": { \"unit_of_measure\": \"kg\", \"rate\": 7000 } } ]\n  },\n  \"message\": \"success\"\n}\n"},{"name":"List active promos for an outlet","method":"GET","path":"/api/v1/customer/outlets/{id}/promo","auth":"JWT Bearer","description":"**Pindah dokumentasi** ke [Customer — Promo \u0026 Klaim Voucher](#customer-promo). Path lama `/promos` (plural) sudah 404 — pakai singular `/promo`. Response shape diperluas dengan `promo_type`, `mechanism`, `claimed`, dst.\n"}]},{"id":"customer-favorites","title":"Customer — Favorit Outlet","description":"Outlet favorit milik customer (❤️). Idempoten — memfavoritkan outlet yang sudah\ndifavoritkan tidak menggandakan. Unfavorite = hapus; bisa difavoritkan ulang.\n","endpoints":[{"name":"List favorites","method":"GET","path":"/api/v1/customer/favorites","auth":"JWT Bearer","description":"Daftar outlet yang difavoritkan customer (1 query JOIN, ringkasan outlet).","example_response":"{\n  \"success\": true,\n  \"data\": [\n    { \"outlet_id\": \"01923c8b-...-0004\", \"name\": \"Clean Express Laundry\",\n      \"address\": \"Jl. Kaliurang Km 5.5\", \"is_opening\": true,\n      \"driver_available\": true, \"delivery_fee_per_km\": 2000 }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"Add favorite","method":"POST","path":"/api/v1/customer/favorites","auth":"JWT Bearer","description":"Favoritkan outlet. Idempoten. 404 bila outlet tidak ada.","example_body":"{ \"outlet_id\": \"01923c8b-...-0004\" }\n","example_response":"{ \"success\": true, \"message\": \"outlet favorited\" }\n"},{"name":"Remove favorite","method":"DELETE","path":"/api/v1/customer/favorites/{outlet_id}","auth":"JWT Bearer","description":"Hapus favorit (soft delete). 404 bila belum difavoritkan.","example_response":"{ \"success\": true, \"message\": \"outlet unfavorited\" }\n"}]},{"id":"customer-items","title":"Customer — Browse Items (Service / Product / Driver)","description":"Endpoint browse item-item yang ditawarkan outlet — dipakai customer untuk pilih sebelum membuat order. Item polymorphic via `type`; tiga endpoint terpisah untuk UI yang spesifik per kategori.\n\nSemua list endpoint cross-outlet (bukan filter ke 1 outlet) — customer browse katalog. Filter `outlet_id` opsional. Harga = **By Platform** rate.\n\n**Gating customer-side (services only):** `GET /customer/item-services` hanya menampilkan service yang outlet-nya **aktif \u0026 belum dihapus** DAN punya harga `By Platform` **aktif**. Service tanpa salah satunya **tidak akan muncul** di pencarian — meskipun `item.is_active=true`. Tenant yang ingin service-nya tampil di customer wajib publish minimal satu harga `By Platform` aktif (lihat [Pricing](#pricing)). Products \u0026 drivers belum kena guard ini.\n","endpoints":[{"name":"List services","method":"GET","path":"/api/v1/customer/item-services","auth":"JWT Bearer","description":"Layanan laundry (cuci, setrika, dry-clean, dll) cross-outlet. **Auto-gated**: outlet harus aktif \u0026 belum di-soft-delete, dan service harus punya minimal 1 harga `By Platform` aktif. Service tanpa salah satunya tidak akan muncul.\n","query_params":[{"name":"page","type":"int","default":"1"},{"name":"limit","type":"int","default":"20"},{"name":"outlet_id","type":"string","description":"Scope ke 1 outlet"},{"name":"search","type":"string","description":"Search nama item"}]},{"name":"Get service detail","method":"GET","path":"/api/v1/customer/item-services/{id}","auth":"JWT Bearer","description":"Detail satu service + harga + outlet info."},{"name":"List products","method":"GET","path":"/api/v1/customer/item-products","auth":"JWT Bearer","description":"Barang dagang (deterjen, pewangi, dll) cross-outlet."},{"name":"Get product detail","method":"GET","path":"/api/v1/customer/item-products/{id}","auth":"JWT Bearer","description":"Detail satu product."},{"name":"List drivers","method":"GET","path":"/api/v1/customer/item-drivers","auth":"JWT Bearer","description":"Resource antar-jemput (kurir) yang ditawarkan outlet."},{"name":"Get driver detail","method":"GET","path":"/api/v1/customer/item-drivers/{id}","auth":"JWT Bearer","description":"Detail satu driver resource."}]},{"id":"customer-order","title":"Customer — Order Online","description":"Pembuatan \u0026 pelacakan order dari sisi customer (Flow \"Order Online\"). Order dibuat\ndengan **estimasi** (berat/pcs perkiraan); harga final dikonfirmasi owner setelah\nverifikasi berat aktual. Respons revenue/pembayaran riil di payment-service.\n\nLihat juga section *Order Flow — Customer* untuk approve/reject harga final.\n\n**Field baru di response order** (semua endpoint):\n- `payment_method_id` (UUID) — referensi ke [Payment Methods](#payment-methods). Selalu diisi server-side. **Field legacy `payment_method` (string) sudah dihapus** dari schema; client wajib pakai `payment_method_id` + lookup label via `/payment-methods`.\n- `cancelled_by` (`\"\" | \"customer\" | \"tenant\" | \"system\"`) — kalau status `cancelled`, menyebut pihak yang membatalkan. Empty di order non-cancel. Bisa dipakai FE untuk filter tab \"Dibatalkan oleh saya / Dibatalkan tenant\".\n- `pickup_status` (`\"\" | \"dispatched\" | \"arrived\"`), `pickup_dispatched_at`, `pickup_arrived_at` — tracking state pickup untuk delivery_method=pickup. Owner update lewat `POST /tenant/orders/{id}/pickup-dispatch` \u0026 `pickup-arrived`.\n- `verified_at` (timestamp) — diisi saat owner panggil `verify-complete` untuk lock harga final.\n- `outlet_name` + `pickup_address` (object) — enriched in response, FE tak perlu lookup terpisah.\n\n**Validasi server baru saat Create**:\n- Tolak order kalau outlet sedang **tutup** (`is_opening=false`).\n- Tolak order `delivery_method=pickup` kalau outlet tidak punya **driver aktif** (`driver_available=false` atau tidak ada item driver tersedia).\n- Item `is_active=false` atau item tanpa harga aktif → ditolak.\n\n**Optional di Create**:\n- `addons[]` — addon items (charge tambahan, tidak menambah service line). Dipakai untuk express, biaya khusus, dll.\n- `photo_media_ids[]` — UUID media (lihat [Outlet Media](#outlet-media)) untuk attach foto pakaian/kondisi dari customer di POST awal.\n","endpoints":[{"name":"Create order","method":"POST","path":"/api/v1/customer/orders","auth":"JWT Bearer","description":"Buat order baru. **Outlet diturunkan otomatis dari `items`** (tiap item\nsudah terikat ke satu outlet); semua item wajib dari outlet yang sama\n(kalau campur → `400`). Field:\n- `items[]` — layanan/produk dipilih; `quantity` = estimasi berat/qty, `estimated_pcs` = perkiraan jumlah potong. Menentukan outlet order.\n- `outlet_id` — **opsional**. Bila dikirim harus cocok dengan outlet dari `items` (cross-check; mismatch → `400`). Tidak perlu dikirim.\n- `delivery_method` — `pickup` (kurir jemput) atau `self_dropoff` (antar sendiri; default).\n- `pickup_address_id` — **opsional** bila `pickup`: kalau dikirim, alamat harus milik customer; kalau dikosongkan, server fallback ke **default address** customer (dari [Customer Address](#customer-address)). 400 kalau dikosongkan tapi customer belum punya default address. Ongkir = jarak(outlet↔alamat) × tarif per-km outlet.\n- `payment_method` — `qris|gopay|ovo|shopeepay|bank_transfer|cod` (charge dilakukan payment-service belakangan). Deprecated soft — masih diterima dan resolve ke `payment_method_id` di server (lihat di bawah).\n- `payment_method_id` — UUID dari [Payment Methods](#payment-methods). Bila dikirim, **lebih diutamakan** dari `payment_method` string. Mode rekomendasi FE baru: panggil `GET /payment-methods`, pilih satu, kirim id-nya.\n- `coupon_code` — opsional; divalidasi (masa berlaku, kuota, min order) → diskon dihitung atas subtotal.\n\nServer menetapkan `order_number` (WCC-YYMMDD-NNN), `estimated_total`\n(= subtotal − diskon + ongkir), dan status `pending`.\n","example_body":"{\n  \"outlet_id\": \"01923c8b-...-0004\",\n  \"delivery_method\": \"pickup\",\n  \"pickup_address_id\": \"01923c8b-...-0050\",\n  \"payment_method\": \"qris\",\n  \"coupon_code\": \"NEWUSER20\",\n  \"note\": \"Pisahkan pakaian putih\",\n  \"items\": [\n    { \"item_id\": \"01923c8b-...-svc1\", \"quantity\": 3, \"estimated_pcs\": 15 },\n    { \"item_id\": \"01923c8b-...-svc2\", \"quantity\": 2 }\n  ]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-...-00aa\",\n    \"order_number\": \"WCC-260528-001\",\n    \"outlet_id\": \"01923c8b-...-0004\",\n    \"delivery_method\": \"pickup\",\n    \"pickup_fee\": 5000,\n    \"payment_method\": \"qris\",\n    \"payment_method_id\": \"01923c8b-...-pm-qris\",\n    \"coupon_code\": \"NEWUSER20\",\n    \"discount_amount\": 18200,\n    \"estimated_total\": 77800,\n    \"final_total\": null,\n    \"tax_amount\": 0,\n    \"status\": \"pending\",\n    \"cancelled_by\": \"\",\n    \"order_list\": [\n      { \"item_id\": \"01923c8b-...-svc1\", \"quantity\": 3, \"amount\": 21000, \"status\": \"pending\" }\n    ]\n  },\n  \"message\": \"order created successfully\"\n}\n"},{"name":"List my orders","method":"GET","path":"/api/v1/customer/orders","auth":"JWT Bearer","description":"Order milik customer (paginasi `page`/`limit`). Status mencerminkan posisi di state machine.","example_response":"{\n  \"success\": true,\n  \"data\": [\n    { \"id\": \"...\", \"order_number\": \"WCC-260528-001\", \"status\": \"price_proposed\",\n      \"estimated_total\": 77800, \"final_total\": 81000, \"tax_amount\": 8000 }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"total\": 3 }\n}\n"},{"name":"Quote pickup fee","method":"GET","path":"/api/v1/customer/orders/pickup-quote","auth":"JWT Bearer","description":"**Preview** ongkos jemput sebelum POST order — hitung jarak(outlet ↔ pickup_address) × tarif outlet, tanpa create order. UI pakai untuk tampilkan biaya jemput live sebelum customer checkout.\n","query_params":[{"name":"outlet_id","type":"string","required":true,"description":"UUID outlet asal jemput"},{"name":"pickup_address_id","type":"string","description":"UUID alamat customer. Kalau kosong → fallback ke default address customer."}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"distance_km\": 2.5,\n    \"rate_per_km\": 2000,\n    \"pickup_fee\": 5000\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Preview coupon discount","method":"POST","path":"/api/v1/customer/orders/coupon-preview","auth":"JWT Bearer","description":"**Preview** diskon tanpa create order — server hitung subtotal estimasi, validasi kupon (masa berlaku, kuota, min order, **min_weight_kg**, **max_use_per_customer**), lalu return diskon + final-total perkiraan. FE pakai sebelum customer commit checkout.\n\n**400** kalau kupon invalid (kadaluarsa, kuota habis, min_amount/min_weight tak terpenuhi, sudah dipakai customer sampai limit). Body sama persis dengan items + outlet_id seperti create order.\n\n**`is_free_shipping`** di response = `true` kalau type kupon `free_shipping` — UI pakai untuk flip label dari \"Diskon\" ke \"Gratis ongkir\" dan zero-kan pickup_fee saat preview total.\n","example_body":"{\n  \"outlet_id\": \"01923c8b-...-0004\",\n  \"coupon_code\": \"NEWUSER20\",\n  \"items\": [\n    { \"item_id\": \"01923c8b-...-svc1\", \"quantity\": 3 }\n  ]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"subtotal\": 21000,\n    \"discount_amount\": 4200,\n    \"final_total\": 16800,\n    \"is_free_shipping\": false\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Get order timeline","method":"GET","path":"/api/v1/customer/orders/{id}/timeline","auth":"JWT Bearer","description":"Timeline lifecycle order — array `steps[]` urut sesuai state machine, masing-masing punya `at` (timestamp kalau step sudah jadi) atau null kalau belum. UI customer pakai untuk render progress bar / langkah-langkah.\n\nSumber data: `order_status_history` (audit trail per transisi, ditulis tiap state change). Untuk order pra-history (dibuat sebelum fitur ini), `at` di-infer dari `order.Status` saat ini (best-effort).\n\n**Urutan step (8 total)**: `created` → `accepted` → `pickup_dispatched` → `pickup_arrived` → `price_proposed` → `processing` → `handover` → `completed`. Pickup steps muncul **setelah** accepted dan **hanya** untuk `delivery_method=pickup` — untuk `self_dropoff`, dua step itu `status=skipped`.\n\n**`status` enum**: `done` (sudah lewat, `at` terisi), `pending` (belum lewat), `skipped` (tidak relevan, mis. pickup steps di self_dropoff). Untuk order `cancelled`, semua step pending di-mark `skipped` + ada step `cancelled` ditambahkan.\n\n**`variant`** hanya muncul di step `handover` — nilainya `ready_pickup` (customer ambil di outlet) atau `delivering` (driver antar) tergantung status order.\n\n**404** kalau order bukan milik customer.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"steps\": [\n      { \"key\": \"created\",           \"status\": \"done\",    \"at\": \"2026-06-10T08:00:00Z\" },\n      { \"key\": \"accepted\",          \"status\": \"done\",    \"at\": \"2026-06-10T08:35:00Z\" },\n      { \"key\": \"pickup_dispatched\", \"status\": \"done\",    \"at\": \"2026-06-10T09:00:00Z\" },\n      { \"key\": \"pickup_arrived\",    \"status\": \"done\",    \"at\": \"2026-06-10T09:30:00Z\" },\n      { \"key\": \"price_proposed\",    \"status\": \"pending\", \"at\": null },\n      { \"key\": \"processing\",        \"status\": \"pending\", \"at\": null },\n      { \"key\": \"handover\",          \"status\": \"pending\", \"at\": null, \"variant\": \"delivering\" },\n      { \"key\": \"completed\",         \"status\": \"pending\", \"at\": null }\n    ]\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Cancel my order","method":"POST","path":"/api/v1/customer/orders/{id}/cancel","auth":"JWT Bearer","description":"Customer batalkan order miliknya. Hanya valid pada state tertentu (mis. `pending`/`price_proposed`). 409 kalau order sudah masuk fase `processing`/`completed`.\n"},{"name":"Confirm receipt","method":"POST","path":"/api/v1/customer/orders/{id}/confirm-receipt","auth":"JWT Bearer","description":"Customer konfirmasi sudah menerima pesanan (close lifecycle). Beda dengan `approve-price` (lihat [Order Flow — Customer](#order-flow-customer)).\n"},{"name":"Get my order","method":"GET","path":"/api/v1/customer/orders/{id}","auth":"JWT Bearer","description":"Detail satu order + item. 404 bila bukan milik customer.\n\n**Estimasi vs aktual.** Tiap line (di `items[]` dan `order_list[]`) bawa dua angka kuantitas:\n- `estimated_quantity` / `estimated_pcs` — perkiraan customer **saat order dibuat** (immutable; mis. \"3 kg · 15 pcs\").\n- `quantity` — nilai **terkini**: sama dengan `estimated_quantity` sebelum outlet timbang; di-replace dengan hasil aktual setelah `PATCH .../final-service-quantity` (lihat [Order Flow — Owner](#order-flow-owner)).\n\nUI bisa render badge \"diperkirakan X kg → ditimbang Y kg\" tanpa lookup tambahan.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"...\", \"order_number\": \"WCC-260528-001\", \"status\": \"processing\",\n    \"delivery_method\": \"pickup\", \"pickup_fee\": 5000,\n    \"estimated_total\": 77800, \"final_total\": 81000, \"discount_amount\": 18200, \"tax_amount\": 8000,\n    \"order_list\": [\n      {\n        \"name\": \"Cuci Reguler\",\n        \"estimated_quantity\": 3, \"estimated_pcs\": 15,\n        \"quantity\": 3.2,\n        \"amount\": 22400\n      }\n    ]\n  },\n  \"message\": \"success\"\n}\n"}]},{"id":"customer-promo","title":"Customer — Promo \u0026 Klaim Voucher","description":"Endpoint customer untuk lihat promo aktif outlet, klaim promo `mechanism=claim` jadi \"Promo Saya\", dan pakai saat checkout. Selengkapnya tentang konsep promo lihat [Tenant — Promo Management](#tenant-promo).\n\n**Cara customer pakai promo** — tergantung `mechanism`:\n- `code` → ketik kode di field coupon saat checkout → [`POST /customer/orders/coupon-preview`](#customer-orders) + apply di POST order.\n- `claim` → klaim dulu lewat `POST /customer/promo/{id}/claim` (muncul di tab \"Promo Saya\") → checkout dengan `customer_coupon_id`.\n- `automatic` → tidak butuh aksi customer; server pilih promo terbaik otomatis saat order create / coupon-preview.\n\n**`customer_coupon`** — baris klaim per (customer, promo). Status `claimed` → `used` saat dipakai di order yang sukses. Partial unique `(customer_id, promotional_id) WHERE deleted_at IS NULL` → tidak bisa klaim ulang promo yang sama.\n\n**Computed status** di list (\"Promo Saya\") — diturunkan dari valid_from/valid_end + status promo + customer_coupon.status:\n- `active` — masih bisa dipakai.\n- `upcoming` — promo `scheduled`, belum valid_from.\n- `expired` — sudah lewat valid_end atau promo `ended`/`paused`/customer_coupon `used`.\n","endpoints":[{"name":"List active promos for an outlet","method":"GET","path":"/api/v1/customer/outlets/{id}/promo","auth":"JWT Bearer","description":"Promo aktif (status `active`, dalam masa berlaku + kuota tersisa + budget tersisa) milik outlet, untuk dipilih saat checkout.\n\nTiap row include `claimed` (`true` kalau customer ini sudah klaim — untuk mechanism `claim`), `is_global`, dan label scope (`scope_label`, mis. `\"Semua outlet\"` atau `\"3 outlet terpilih\"`).\n\n**Path note**: lama `/outlets/{id}/promos` (plural). Sekarang singular `/promo` (PR #56). FE lama → 404.\n","example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"01923c8b-...-pm-1\",\n      \"coupon_code\": \"NEWUSER20\",\n      \"name\": \"Diskon 20% Pengguna Baru\",\n      \"type\": \"percentage\",\n      \"promo_type\": \"percentage\",\n      \"mechanism\": \"code\",\n      \"value\": 20,\n      \"max_discount\": 30000,\n      \"min_amount\": 50000,\n      \"min_weight_kg\": 0,\n      \"maximum_use\": 500,\n      \"usage\": 12,\n      \"max_use_per_customer\": 1,\n      \"valid_from\": \"2026-06-01T00:00:00Z\",\n      \"valid_end\": \"2026-12-31T23:59:59Z\",\n      \"service_requirement_mode\": \"all_services\",\n      \"customer_criteria\": \"new_user\",\n      \"outlet_scope_type\": \"all_outlets\",\n      \"scope_label\": \"Semua outlet\",\n      \"terms\": [\"Berlaku untuk pengguna baru ≤30 hari\"],\n      \"claimed\": false,\n      \"is_global\": false\n    }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"List my claimed promos (Promo Saya)","method":"GET","path":"/api/v1/customer/promo","auth":"JWT Bearer","description":"Daftar `customer_coupon` milik customer ini (paginasi). Pisah per-tab via query `status`:\n- `active` (default) — masih bisa dipakai\n- `upcoming` — promo belum valid_from\n- `expired` — sudah lewat / promo end / sudah dipakai\n- `all` — gabungan\n\nTiap row include `computed_status` (resolved server-side), `claimed_at`, dan ringkasan promo (name, type, value, max_discount, min_amount, valid period, is_global).\n","example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"01923c8b-...-cc-1\",\n      \"promotional_id\": \"01923c8b-...-pm-1\",\n      \"coupon_code\": \"NEWUSER20\",\n      \"name\": \"Diskon 20% Pengguna Baru\",\n      \"promo_type\": \"percentage\",\n      \"value\": 20,\n      \"max_discount\": 30000,\n      \"min_amount\": 50000,\n      \"valid_from\": \"2026-06-01T00:00:00Z\",\n      \"valid_end\": \"2026-12-31T23:59:59Z\",\n      \"status\": \"claimed\",\n      \"computed_status\": \"active\",\n      \"claimed_at\": \"2026-06-10T08:15:00Z\",\n      \"is_global\": false\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"total\": 5 },\n  \"message\": \"success\"\n}\n"},{"name":"Get claimed promo detail","method":"GET","path":"/api/v1/customer/promo/{id}","auth":"JWT Bearer","description":"Detail satu `customer_coupon` (bukan `promotional_id` — pakai `customer_coupon.id` dari list). Include term \u0026 condition (`terms[]`), source description, plus quota info.\n\n**404** kalau bukan milik customer ini.\n"},{"name":"Claim a promo","method":"POST","path":"/api/v1/customer/promo/{id}/claim","auth":"JWT Bearer","description":"Klaim promo `mechanism=claim` (kirim `promotional_id` di path). Setelah sukses, kupon muncul di \"Promo Saya\" (status `claimed`) dan siap dipakai di checkout (kirim `customer_coupon_id` di body order).\n\n**Tolakan**:\n- 400 — promo `mechanism != claim` (kode/auto tidak perlu klaim).\n- 409 — sudah pernah klaim (partial unique).\n- 409 — promo bukan status `active` / sudah expired.\n- 409 — `customer_criteria=new_user` \u0026 customer tidak memenuhi.\n- 409 — `max_use_per_customer` sudah penuh.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-...-cc-1\",\n    \"promotional_id\": \"01923c8b-...-pm-1\",\n    \"coupon_code\": \"NEWUSER20\",\n    \"name\": \"Diskon 20% Pengguna Baru\",\n    \"status\": \"claimed\",\n    \"claimed_at\": \"2026-06-15T07:00:00Z\",\n    \"valid_from\": \"2026-06-01T00:00:00Z\",\n    \"valid_end\": \"2026-12-31T23:59:59Z\"\n  },\n  \"message\": \"promo claimed\"\n}\n"}]},{"id":"customer-rating","title":"Customer Rating","description":"Rating customer ke outlet (overall) + ke service (per-item dalam order). Setiap rating terikat ke `order_id` — customer wajib punya order **completed** di outlet/service tersebut (eligibility check via `GetFinishedByCustomerOutlet`).\n\nOwner outlet **tidak boleh** memberi rating ke outlet-nya sendiri (self-rating guard) — diidentifikasi via `tenant_detail.owner_account_id` = JWT `sub`.\n\n**Rating immutable** — customer **tidak bisa** edit atau delete review setelah submit. FE Form harus konfirmasi sebelum submit (\"Pastikan ulasan kamu sudah sesuai — tidak bisa diedit setelah dikirim\").\n\n**One rating per (outlet, customer)** — server enforce 409 conflict kalau coba rating outlet yang sama lagi.\n\n**`likes` whitelist (8 nilai case-sensitive — capital case)**:\n```\nBersih · Cepat · Wangi · Rapi · Ramah · Berkualitas · Sesuai Harga · Tepat Waktu\n```\nServer normalize (trim, deduplicate, urutkan canonical) + reject value tidak dikenal → `400 invalid rating aspect: X`.\n\n**Enrichment**: list endpoint return `outlet_name`, `service_names` (dari order_list), `helpful_count` (aggregate dari `rating_helpful`), `photos` (array of media_id) — batched untuk hindari N+1.\n\n## ⚠️ Known gaps (tracked di [PR #59](https://github.com/Ikavia/wacca-service/pull/59))\n- **Photo upload saat create**: `POST /outlet-ratings` belum terima `photo_media_ids[]`. Photo support hanya di list/detail (read). Phase 2 di PR #59.\n- **Points reward**: `Present` response create belum return `points_earned`/`total_points`. Backend belum punya loyalty wallet. Phase 2 hardcode `points_earned: 50` dulu.\n- **Detail endpoint belum enriched**: `GET /:id` return Present **tanpa** `outlet_name`/`service_names`/`helpful_count`/`photos`. Phase 1 fix.\n- **Scale validation `\u003c 1`**: backend saat ini accept `scale=0` (bug, akan di-fix Phase 1 → `1..5` strict). FE wajib enforce ≥1 di client.\n","endpoints":[{"name":"Create outlet rating","method":"POST","path":"/api/v1/customer/outlet-ratings","auth":"JWT Bearer","description":"Beri rating ke outlet. Server cari order **completed** customer di outlet tsb sebagai bukti eligibility (any completed order, server auto-pick — bukan per-order spesifik).\n\n**400 errors**:\n- `outlet_id is required` / `invalid outlet_id`\n- `scale must be between 0 and 5` (current behavior — FE wajib enforce ≥1)\n- `review must be 255 characters or fewer`\n- `invalid rating aspect: X` (likes mengandung value di luar 8-whitelist case-sensitive)\n\n**403 'you cannot rate your own outlet'** — caller adalah owner tenant pemilik outlet ini (self-rating guard).\n\n**403 'you can only rate an outlet after a finished order'** — belum ada order `completed` di outlet ini.\n\n**409 'you have already rated this outlet'** — sudah pernah rating outlet ini (unique `(outlet_id, customer_id)`).\n","example_body":"{\n  \"outlet_id\": \"01923c8b-1234-7000-a000-000000000004\",\n  \"scale\": 5,\n  \"review\": \"Kerjanya rapi dan cepat\",\n  \"likes\": [\"Bersih\", \"Cepat\", \"Ramah\"]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-000000000090\",\n    \"tenant_id\": \"01923c8b-1234-7000-a000-000000000001\",\n    \"outlet_id\": \"01923c8b-1234-7000-a000-000000000004\",\n    \"outlet_name\": \"\",\n    \"customer_id\": \"01923c8b-1234-7000-a000-000000000010\",\n    \"order_id\": \"01923c8b-1234-7000-a000-0000000000aa\",\n    \"scale\": 5,\n    \"review\": \"Kerjanya rapi dan cepat\",\n    \"likes\": [\"Bersih\", \"Cepat\", \"Ramah\"],\n    \"service_names\": [],\n    \"helpful_count\": 0,\n    \"photos\": [],\n    \"created_at\": \"2026-05-29T10:00:00Z\",\n    \"updated_at\": \"2026-05-29T10:00:00Z\"\n  },\n  \"message\": \"rating created successfully\"\n}\n\n**Catatan**: enrichment field (`outlet_name`, `service_names`, `helpful_count`, `photos`) di-return kosong dari create endpoint — hanya list endpoint yang populated. Untuk dapat data lengkap, GET list setelahnya.\n"},{"name":"List my outlet ratings","method":"GET","path":"/api/v1/customer/outlet-ratings","auth":"JWT Bearer","description":"Outlet rating yang dibuat customer (paginasi `page`/`limit`).","query_params":[{"name":"page","type":"integer","default":"1"},{"name":"limit","type":"integer","default":"20"}]},{"name":"Get my outlet rating","method":"GET","path":"/api/v1/customer/outlet-ratings/{id}","auth":"JWT Bearer","description":"Detail satu rating. **404** kalau bukan milik customer."},{"name":"Create service rating","method":"POST","path":"/api/v1/customer/service-ratings","auth":"JWT Bearer","description":"Rating per-service (item) — bukan ke outlet, tapi ke pekerjaan service spesifik di sebuah order. Server cari order Finish customer yang memuat `item_id` ini.\n\n**400** — `item_id` kosong/invalid, `scale` di luar 1..5, `review` \u003e 255 char.\n\n**403 'you can only rate a service after a finished order'** — belum ada order Finish yang memuat service ini.\n\n**409** — sudah pernah rating service ini.\n","example_body":"{\n  \"item_id\": \"01923c8b-1234-7000-a000-000000000020\",\n  \"scale\": 5,\n  \"review\": \"Hasil setrikaan licin\",\n  \"likes\": [\"wangi\", \"licin\"]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-0000000000a1\",\n    \"tenant_id\": \"01923c8b-1234-7000-a000-000000000001\",\n    \"outlet_id\": \"01923c8b-1234-7000-a000-000000000004\",\n    \"item_id\": \"01923c8b-1234-7000-a000-000000000020\",\n    \"customer_id\": \"01923c8b-1234-7000-a000-000000000010\",\n    \"order_id\": \"01923c8b-1234-7000-a000-0000000000aa\",\n    \"scale\": 5,\n    \"review\": \"Hasil setrikaan licin\",\n    \"likes\": [\"wangi\", \"licin\"],\n    \"created_at\": \"2026-05-29T10:00:00Z\",\n    \"updated_at\": \"2026-05-29T10:00:00Z\"\n  },\n  \"message\": \"rating created successfully\"\n}\n"},{"name":"List my service ratings","method":"GET","path":"/api/v1/customer/service-ratings","auth":"JWT Bearer","description":"Service rating yang dibuat customer (paginasi `page`/`limit`)."},{"name":"Get my service rating","method":"GET","path":"/api/v1/customer/service-ratings/{id}","auth":"JWT Bearer","description":"Detail satu service rating. **404** kalau bukan milik customer."},{"name":"Pending ratings (Belum Ulas)","method":"GET","path":"/api/v1/customer/outlet-ratings/pending","auth":"JWT Bearer","description":"Daftar order Finish yang **belum** di-rating oleh customer (UI \"Belum Ulas\"). Tiap entry enriched dengan `order_number`, `outlet_name`, `service_names` (batched lookup, no N+1) — supaya UI bisa render kartu lengkap tanpa fetch tambahan. Sumber utama notifikasi reminder review.\n","example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"order_id\": \"01923c8b-1234-7000-a000-0000000000aa\",\n      \"order_number\": \"WCC-260528-001\",\n      \"outlet_id\": \"01923c8b-1234-7000-a000-000000000004\",\n      \"outlet_name\": \"Bersih Cepat — Cabang Pusat\",\n      \"service_names\": [\"Cuci Reguler\", \"Setrika\"],\n      \"created_at\": \"2026-05-28T08:00:00Z\"\n    }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"Rating summary (Riwayat)","method":"GET","path":"/api/v1/customer/outlet-ratings/summary","auth":"JWT Bearer","description":"Ringkasan rating yang sudah customer submit, untuk header tab \"Riwayat\". Return 3 field:\n- `average` — skor rata-rata yang customer kasih (0 kalau belum ada rating)\n- `count` — total review yang customer sudah submit\n- `helpful_received` — total helpful vote yang DITERIMA customer ini dari customer lain (agregat semua review-nya)\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"average\": 4.7,\n    \"count\": 12,\n    \"helpful_received\": 25\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Mark outlet rating helpful (vote)","method":"POST","path":"/api/v1/customer/outlet-ratings/{id}/helpful","auth":"JWT Bearer","description":"Tandai review orang lain \"merasa terbantu\". **Idempoten** — kalau sudah pernah vote, no-op. Counter `helpful_count` di rating ter-update; aggregate untuk author di-batch saat list.\n"},{"name":"Unvote outlet rating helpful","method":"DELETE","path":"/api/v1/customer/outlet-ratings/{id}/helpful","auth":"JWT Bearer","description":"Tarik kembali vote \"merasa terbantu\". Idempoten — kalau belum pernah vote, no-op. Counter `helpful_count` berkurang.\n"}]},{"id":"finance-banks","title":"Finance — Master Banks","description":"Tabel referensi bank Indonesia yang dipakai dropdown saat tenant tambah rekening (lihat [Finance — Bank Accounts](#finance-bank-accounts)). Data **bersumber dari seed migration** (BCA, BRI, BNI, Mandiri, BTN, CIMB Niaga, Danamon = 7 baris awal). Tambah/ubah/hapus master bank = **platform-admin only**.\n\n**Gating permission**:\n- **Read** (`GET /finance/banks` + `GET /finance/banks/{id}`) — caller dengan permission `wacca-tenant:*` bisa list aktif untuk dropdown. Detail by id juga read tapi gate admin (lihat tabel di bawah).\n- **Write** (POST / PUT / DELETE / GET-by-id) — gated `auth.RequirePermission(\"*\")` artinya **HANYA JWT dengan permission literal `\"*\"`** (platform-admin / owner ikavia) yang lolos. JWT tenant biasa (`[\"wacca-tenant:*\"]`) **ditolak 403** karena tidak mengandung literal `*`.\n\n**Bentuk data `code`**: lowercase alphanumeric + dash/underscore, 2–20 char, harus mulai dari alphanumeric. Regex `^[a-z0-9][a-z0-9_-]{1,19}$`. Server normalize ke lowercase otomatis (kirim `\"BSI\"` → tersimpan `\"bsi\"`).\n\n**Unique constraint**: `code` unique partial `WHERE deleted_at IS NULL` — soft-delete bank → buat ulang dengan code sama bisa. Sebelumnya UNIQUE global (migration `20260608140000_partial_unique_banks_code` mengoreksi ini).\n\n**Delete = soft-delete**. Server **menolak `409`** kalau ada `bank_account` aktif yang masih reference master bank — pencegahan dangling FK karena GORM soft-delete bypass FK level DB.\n","endpoints":[{"name":"List banks (dropdown + admin overview)","method":"GET","path":"/api/v1/finance/banks","auth":"JWT Bearer","description":"Default: active-only (untuk dropdown tenant). Pass `?include_inactive=true` untuk admin lihat seluruh non-soft-deleted (active + inactive) — biasanya untuk reaktivasi.\n","query_params":[{"name":"include_inactive","type":"bool","description":"Default false. true → admin overview (semua non-deleted)."}],"example_response":"{\n  \"success\": true,\n  \"data\": [\n    { \"id\": \"00300000-0000-8fff-8fff-000000000001\", \"code\": \"bca\", \"name\": \"Bank BCA\", \"is_active\": true },\n    { \"id\": \"00300000-0000-8fff-8fff-000000000002\", \"code\": \"bri\", \"name\": \"Bank BRI\", \"is_active\": true }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"Get bank by id (admin)","method":"GET","path":"/api/v1/finance/banks/{id}","auth":"JWT Bearer","permission":"*","description":"Detail satu master bank. **Admin-gated** (`*`).\n**404** bila id tidak ada atau sudah soft-deleted.\n"},{"name":"Create bank (admin)","method":"POST","path":"/api/v1/finance/banks","auth":"JWT Bearer","permission":"*","description":"Tambah master bank baru. **Admin-gated** (`*`).\n\n- `code` (required) — lowercase alphanumeric/dash/underscore, 2-20 char. Server normalize lowercase.\n- `name` (required) — display name (≤50 char).\n- **`is_active` tidak diterima** — bank baru selalu aktif. Untuk publish-disabled, panggil PUT setelahnya.\n\nStatus:\n- **400** kalau code/name kosong, code tidak match regex, atau name \u003e50 char.\n- **409** kalau code sudah dipakai (termasuk oleh bank inactive — partial unique tetap aktif).\n","example_body":"{\n  \"code\": \"bsi\",\n  \"name\": \"Bank Syariah Indonesia\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-...-0001\",\n    \"code\": \"bsi\",\n    \"name\": \"Bank Syariah Indonesia\",\n    \"is_active\": true\n  },\n  \"message\": \"bank created\"\n}\n"},{"name":"Update bank (admin, partial)","method":"PUT","path":"/api/v1/finance/banks/{id}","auth":"JWT Bearer","permission":"*","description":"Partial update — kirim hanya field yang berubah.\n\n- `code` re-key boleh; sama validasi format + collision check (409) seperti Create.\n- `name` ≤50 char.\n- `is_active` = `false` untuk deactivate (dropdown tenant tidak lihat tapi bank_account existing tetap reference); `true` untuk reaktivasi.\n\n**404** kalau id tidak ada. **409** kalau code conflict.\n","example_body":"# deactivate\n{ \"is_active\": false }\n\n# rename\n{ \"name\": \"Bank Syariah Indonesia (BSI)\" }\n\n# rekey\n{ \"code\": \"bsi-baru\" }\n"},{"name":"Delete bank (admin, soft-delete)","method":"DELETE","path":"/api/v1/finance/banks/{id}","auth":"JWT Bearer","permission":"*","description":"Soft-delete bank (set `deleted_at`). Karena partial unique `WHERE deleted_at IS NULL`, code-nya bebas dipakai ulang setelah delete.\n\n**409** kalau masih ada `bank_account` aktif (non-soft-deleted) yang reference. Pesan: `cannot delete: N bank account(s) still reference this bank`. Resolusinya: hapus / pindahkan bank_account ke bank lain dulu.\n\n**404** kalau id tidak ada / sudah deleted sebelumnya.\n"}]},{"id":"finance-bank-accounts","title":"Finance — Bank Accounts (Tenant)","description":"Rekening tujuan **penarikan saldo tenant**. Setiap tenant bisa punya banyak rekening; salah satunya ditandai `is_default=true`. Memilih bank dari [Master Banks](#finance-banks) by `bank_id`.\n\n**Gating**: semua endpoint di grup `/finance/*` butuh permission `wacca-tenant:*` (JWT tenant aktif). `*` (admin) tetap lolos lewat wildcard.\n\n**Uniqueness** (partial `WHERE deleted_at IS NULL`):\n- Max **1 rekening default per tenant** (`uq_ba_tenant_default`).\n- Tidak boleh duplikat `(tenant, bank, account_number)` (`uq_ba_tenant_bank_number`).\n\nSoft-delete (set `deleted_at`) — row tetap di DB untuk audit, tapi tidak ikut list/dropdown.\n","endpoints":[{"name":"List bank accounts","method":"GET","path":"/api/v1/finance/bank-accounts","auth":"JWT Bearer","description":"List rekening milik tenant aktif (scoped via JWT `org_id` → `tenant_id`). Paginated. Bank info (`bank_code` + `bank_name`) di-enrich di use case lewat **batched lookup** ke `bank_repository.GetByIDs(uniqueBankIDs)` — 2 query total (List + batch enrich), bukan N+1.\n\nResponse field `tenant_id` **tidak** ikut di-expose — scoping otomatis dari JWT.\n","query_params":[{"name":"page","type":"int","default":"1","description":"Halaman 1-based"},{"name":"limit","type":"int","default":"20","description":"Jumlah per halaman"}],"example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"01923c8b-...-acc1\",\n      \"bank_id\": \"00300000-0000-8fff-8fff-000000000001\",\n      \"bank_code\": \"bca\",\n      \"bank_name\": \"Bank BCA\",\n      \"account_number\": \"1234567890\",\n      \"account_holder\": \"Budi Santoso\",\n      \"is_default\": true,\n      \"is_active\": true,\n      \"created_at\": \"2026-06-08T10:00:00Z\",\n      \"updated_at\": \"2026-06-08T10:00:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1, \"limit\": 20, \"total\": 1,\n    \"total_pages\": 1, \"has_next\": false, \"has_prev\": false\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Get bank account by id","method":"GET","path":"/api/v1/finance/bank-accounts/{id}","auth":"JWT Bearer","description":"Detail satu rekening + bank info ter-enrich. **404** kalau bukan milik tenant ini (cek ownership di UC)."},{"name":"Create bank account","method":"POST","path":"/api/v1/finance/bank-accounts","auth":"JWT Bearer","description":"Tambah rekening tenant. `bank_id` wajib UUID master bank aktif.\n\n**Default rule**: rekening jadi default kalau (a) `is_default: true` dikirim di body, ATAU (b) ini rekening pertama tenant (count == 1 setelah insert). Default di-set lewat `SetDefaultByTenantID` (transaksional, unset yang lain dulu) supaya invariant 1-default per tenant terjaga.\n\n**Error**:\n- **400** kalau `bank_id` kosong/format invalid, atau bank found tapi `is_active=false`, atau `account_number`/`account_holder` kosong/terlalu panjang (\u003e50 / \u003e100).\n- **404** kalau bank dengan id tsb tidak ada di tabel `banks` (UC bubble status dari `bank_repository.GetByID`).\n- **409** kalau `(tenant, bank, account_number)` triplet sudah ada (partial unique `uq_ba_tenant_bank_number`).\n\n**Response** = `IDPresent` (cuma `{id}`). Untuk tampilkan field lengkap di FE, panggil `GET /finance/bank-accounts/{id}` setelahnya.\n","example_body":"{\n  \"bank_id\": \"00300000-0000-8fff-8fff-000000000001\",\n  \"account_number\": \"1234567890\",\n  \"account_holder\": \"Budi Santoso\",\n  \"is_default\": true\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"01923c8b-...-acc1\" },\n  \"message\": \"bank account created\"\n}\n"},{"name":"Update bank account","method":"PUT","path":"/api/v1/finance/bank-accounts/{id}","auth":"JWT Bearer","description":"Partial update — kirim hanya field yang berubah. Editable: `bank_id`/`account_number`/`account_holder`/`is_default`/`is_active`.\n\nSet `is_default=true` di rekening lain otomatis flip default lama jadi false (atomic — `SetDefaultByTenantID` unset semua lalu set satu, dalam 1 transaksi DB, dilindungi partial unique `uq_ba_tenant_default`).\n\n**Response** = `IDPresent` (cuma `{id}`). Tarik via GET kalau butuh full payload.\n\n**404** kalau bukan milik tenant. **409** kalau update bikin `(tenant, bank, account_number)` collide.\n","example_body":"# ganti default ke rekening ini\n{ \"is_default\": true }\n\n# rename holder\n{ \"account_holder\": \"Budi Santoso S.E.\" }\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"01923c8b-...-acc1\" },\n  \"message\": \"bank account updated\"\n}\n"},{"name":"Delete bank account","method":"DELETE","path":"/api/v1/finance/bank-accounts/{id}","auth":"JWT Bearer","description":"Soft-delete rekening (set `deleted_at`). Karena partial unique `WHERE deleted_at IS NULL`, slot `(tenant, bank, account_number)` jadi bebas dipakai ulang setelah delete.\n\n**`409` hanya kalau rekening ini default DAN tenant masih punya rekening lain** — biar tidak berakhir tanpa default. Pesan: `cannot delete default bank account; set another as default first`. Resolusinya: panggil `POST .../select` ke rekening lain dulu, baru delete.\n\nKalau ini satu-satunya rekening (default, count=1), delete diperbolehkan — tenant balik ke state tanpa rekening.\n\n**404** kalau bukan milik tenant. Response body kosong (`data: null`).\n"},{"name":"Select default","method":"POST","path":"/api/v1/finance/bank-accounts/{id}/select","auth":"JWT Bearer","description":"Jadikan rekening ini default tenant. Atomic: di dalam 1 transaksi, semua rekening tenant set `is_default=false`, lalu rekening ini set `true`. Partial unique `uq_ba_tenant_default` memastikan tidak pernah ada 2 default sekaligus walau under contention.\n\n**404** kalau bukan milik tenant. **Response** = full `Present` dengan bank info ter-enrich (rekening yang baru jadi default).\n"}]},{"id":"outlet-services","title":"Outlet Services (Item type=service)","description":"Layanan laundry yang ditawarkan outlet — cuci kering, setrika, dry-clean, dll. Tabel `item` polymorphic; endpoint ini auto-filter `type=\"service\"`.\n\nUntuk pricing per-outlet, lihat section **Service Prices**.\n","endpoints":[{"name":"List services","method":"GET","path":"/api/v1/tenant/outlet/item-services","auth":"JWT Bearer","description":"List service items milik tenant.","query_params":[{"name":"page","type":"int","description":"Default 1."},{"name":"limit","type":"int","description":"Default 20."},{"name":"outlet_id","type":"string","description":"Filter by outlet ID."},{"name":"tags","type":"string","description":"Substring match terhadap `tags`."},{"name":"is_active","type":"boolean"}]},{"name":"Create service","method":"POST","path":"/api/v1/tenant/outlet/item-services","auth":"JWT Bearer","description":"Buat layanan baru. `type` auto-set `\"service\"`.\n","example_body":"{\n  \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n  \"name\": \"Cuci Kering\",\n  \"description\": \"Reguler 2 hari\",\n  \"unit_of_measure\": \"kg\",\n  \"tags\": \"standard,reguler\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-000000000400\",\n    \"tenant_id\": \"01923c8b-1234-7000-a000-000000000001\",\n    \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n    \"type\": \"service\",\n    \"name\": \"Cuci Kering\",\n    \"description\": \"Reguler 2 hari\",\n    \"unit_of_measure\": \"kg\",\n    \"tags\": \"standard,reguler\",\n    \"is_active\": true,\n    \"created_at\": \"2026-05-19T10:00:00Z\",\n    \"updated_at\": \"2026-05-19T10:00:00Z\"\n  },\n  \"message\": \"created successfully\"\n}\n"},{"name":"Get service","method":"GET","path":"/api/v1/tenant/outlet/item-services/{service-id}","auth":"JWT Bearer","description":"Detail item layanan."},{"name":"Update service","method":"PUT","path":"/api/v1/tenant/outlet/item-services/{service-id}","auth":"JWT Bearer","description":"Partial update.","example_body":"{\n  \"description\": \"Reguler 1 hari\",\n  \"unit_of_measure\": \"kg\"\n}\n"},{"name":"Delete service","method":"DELETE","path":"/api/v1/tenant/outlet/item-services/{service-id}","auth":"JWT Bearer","description":"Soft delete. Harga terkait (di tabel `price`) tidak ikut ter-cascade — bersihkan manual via `DELETE /service-prices/{id}`."}]},{"id":"outlet-products","title":"Outlet Products (Item type=product)","description":"Barang dagangan tambahan yang dijual outlet — deterjen, pewangi, parfum, dll. Tabel `item` polymorphic; endpoint auto-filter `type=\"product\"`.\n\nUntuk pricing per-outlet, lihat section **Product Prices**.\n","endpoints":[{"name":"List products","method":"GET","path":"/api/v1/tenant/outlet/item-products","auth":"JWT Bearer","description":"List product items milik tenant.","query_params":[{"name":"page","type":"int"},{"name":"limit","type":"int"},{"name":"outlet_id","type":"string"},{"name":"tags","type":"string"},{"name":"is_active","type":"boolean"}]},{"name":"Create product","method":"POST","path":"/api/v1/tenant/outlet/item-products","auth":"JWT Bearer","description":"Buat produk baru. `type` auto-set `\"product\"`.","example_body":"{\n  \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n  \"name\": \"Pewangi Lavender 500ml\",\n  \"description\": \"Botol semprot\",\n  \"unit_of_measure\": \"botol\",\n  \"tags\": \"fragrance,laundry-add-on\"\n}\n"},{"name":"Get product","method":"GET","path":"/api/v1/tenant/outlet/item-products/{product-id}","auth":"JWT Bearer","description":"Detail item produk."},{"name":"Update product","method":"PUT","path":"/api/v1/tenant/outlet/item-products/{product-id}","auth":"JWT Bearer","description":"Partial update."},{"name":"Delete product","method":"DELETE","path":"/api/v1/tenant/outlet/item-products/{product-id}","auth":"JWT Bearer","description":"Soft delete."}]},{"id":"outlet-drivers","title":"Outlet Drivers (Item type=driver)","description":"Sumber daya driver antar-jemput yang dimiliki outlet. Tabel `item` polymorphic; endpoint auto-filter `type=\"driver\"`.\n\nUntuk pricing per-driver (mis. ongkir per-km), lihat section **Driver Prices**.\n\nCatatan: status real-time driver (sedang antar / standby) **belum** ada di service ini. Saat ini hanya katalog. Future enhancement → tabel `driver_session` + endpoint dispatch.\n","endpoints":[{"name":"List drivers","method":"GET","path":"/api/v1/tenant/outlet/item-drivers","auth":"JWT Bearer","description":"List driver items milik tenant.","query_params":[{"name":"page","type":"int"},{"name":"limit","type":"int"},{"name":"outlet_id","type":"string"},{"name":"tags","type":"string"},{"name":"is_active","type":"boolean"}]},{"name":"Create driver","method":"POST","path":"/api/v1/tenant/outlet/item-drivers","auth":"JWT Bearer","description":"Buat driver baru. `type` auto-set `\"driver\"`.","example_body":"{\n  \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n  \"name\": \"Driver Motor 1\",\n  \"description\": \"Motor matic, jangkauan 5km\",\n  \"unit_of_measure\": \"trip\",\n  \"tags\": \"motor,city\"\n}\n"},{"name":"Get driver","method":"GET","path":"/api/v1/tenant/outlet/item-drivers/{driver-id}","auth":"JWT Bearer","description":"Detail item driver."},{"name":"Update driver","method":"PUT","path":"/api/v1/tenant/outlet/item-drivers/{driver-id}","auth":"JWT Bearer","description":"Partial update."},{"name":"Delete driver","method":"DELETE","path":"/api/v1/tenant/outlet/item-drivers/{driver-id}","auth":"JWT Bearer","description":"Soft delete."}]},{"id":"notifications","title":"Notifications (Push \u0026 Inbox)","description":"wacca-service tidak deliver notifikasi sendiri — ia publish ke **notification-service** lewat RabbitMQ (exchange `notification.requests`). notification-service yang kirim push (FCM) ke device + store inbox per account.\n\n**Penting — list notifikasi (inbox) di-baca dari notification-service**, bukan dari wacca. Endpoint daftar/baca/tandai-read = `GET /notifications` (dan turunannya) **di notification-service** (`https://notif.ikavia.com` atau alamat sesuai deploy). wacca **tidak** expose endpoint `/notifications` — yang ada di sini hanya register/unregister push token + accept/reject invitation (action button).\n\n**Dua arah komunikasi yang dipakai**:\n1. **Push (FCM)** — wacca simpan token device per account di tabel lokal `device_tokens`, dan **menyertakan token di payload publish** ke notification-service (notif-service tidak menyimpan token, hanya forward ke FCM).\n2. **Inbox** — notif-service simpan history notifikasi + state (read, action taken). Frontend tarik list inbox langsung dari notif-service `GET /notifications`. Dipakai mis. untuk flow invite member — invitee terima entry inbox dengan 2 action button (Accept / Reject) yang resolve ke endpoint wacca `POST /api/v1/tenant/invitations/{id}/accept|reject`.\n\n**Owner notif = Group Inbox.** Notif yang ditujukan ke tenant/owner (mis. `order_new`, `order_cancelled`, `member_invite_accepted`) di-publish dengan `target_type=group` + `target_id=tenant_id`. notification-service expand ke **semua tenant_member** (owner + staff) di roster tenant tsb — **shared read-state**: kalau owner baca, staff lain juga tertandai read; satu staff tap accept = state berlaku untuk semua. Roster di-sync otomatis oleh wacca lewat exchange `notification.membership` (op `group.add`/`group.remove`/`group.replace`) saat tenant_member ditambah/dihapus, plus full replay saat wacca-service boot. Customer-side notif tetap `target_type=user` (per akun, read-state per individu).\n\n**Mandatory flag.** Notif kritis di-set `mandatory: true` → **bypass preferensi mute user** (lihat [Notification Preferences API](https://docs.ikavia.com/docs?p=notification#preferences) di notification-service). Yang mandatory: `order_price_proposed` (deadline 24h auto-approve), `order_rejected` (refund expectation), `order_new` (SLA owner accept/reject), `order_price_rejected` (cancellation), `order_cancelled` (revenue loss), `member_invited` (access control). Selain itu (rating prompt, ready/completed/receipt) user boleh mute lewat preferences. **Notif `target_type=group` selalu bypass preferences** (semua owner notif sudah otomatis mandatory secara design — `mandatory` flag jadi redundant tapi tetap di-set untuk konsistensi).\n\n**Notifikasi interaktif di-update IN-PLACE saat di-aksi.** Notif yang punya tombol aksi — invite member (`member_invited`: Terima/Tolak) dan harga pesanan (`order_price_proposed`: Setujui/Tolak) — **tidak** bikin entry baru saat aksi diambil. wacca re-publish notif dengan **id sama** (notification-service menimpa payload by `(platform, id)`), sehingga:\n- `payload.actions` jadi **kosong/hilang** → FE sembunyikan tombol.\n- `payload.body` berubah ke teks settled (mis. \"Kamu telah menerima undangan…\", \"Kamu telah menyetujui harga…\").\n- `payload.metadata.state` di-set: invite → `accepted`/`rejected`; harga → `approved`/`rejected`.\n- **Tidak** kirim ulang email/push; `read`/status notif tetap; `category` tetap sama (notif tetap di thread/filter yang sama).\n\n**Aturan render FE (berlaku untuk semua notif interaktif):** kalau `payload.actions` ada → tampilkan tombol (state pending); kalau `actions` kosong **dan** `payload.metadata.state` ada → render badge settled (accepted/rejected/approved). Id notif **stabil** lintas update, jadi FE bisa mencocokkan entry yang sama.\n\n## Push Notification (FCM) — alur lengkap\n\nPush muncul **walau app ditutup penuh** (ala WhatsApp) karena yang menampilkan adalah OS, bukan app. Firebase project: **`wacca-customer`** (di-set server-side: `FCM_CREDENTIALS_FILE` + `FCM_PROJECT_ID` di notification-service). wacca cuma **menyimpan token device** + **menyertakannya di payload publish**; notification-service yang kirim ke FCM.\n\n**Alur end-to-end:**\n1. **App ambil token** — Firebase SDK `getToken()` di device (Android/iOS/web). Token bisa berubah; app harus re-register saat SDK kasih token baru.\n2. **App daftar token ke wacca** — `POST /api/v1/customer/device-tokens` (customer) atau `POST /api/v1/tenant/device-tokens` (owner/staff), body `{ \"token\": \"\u003cfcm token\u003e\", \"platform\": \"android|ios|web\" }`. Disimpan di tabel lokal `device_tokens` per account.\n3. **wacca fan-out** — tiap notif user (invite, harga pesanan, order ready, dll) yang wacca publish ke notification-service **otomatis menyertakan semua token FCM** account itu sebagai channel `{ type: \"fcm\", recipient: { fcm_token } }` (di samping in-app + email).\n4. **notification-service kirim ke FCM** — `content.subject` → judul, `content.body` → isi; `metadata` diteruskan sebagai FCM `data`. Hasil per-token kelihatan di inbox: `channels[].type==\"fcm\"` → `status` `sent`/`failed`.\n5. **Logout** — app panggil `DELETE /api/v1/{customer|tenant}/device-tokens` (body `{ \"token\": \"\u003cfcm token\u003e\" }`) supaya device itu berhenti dapat push.\n\n**⚠️ Wajib token ASLI.** Push hanya berhasil kalau token yang didaftarkan adalah registration token sungguhan dari Firebase SDK di device. Token placeholder/contoh (mis. `REPLACE_WITH_REAL_FCM_TOKEN`) akan **selalu `failed`** di FCM.\n\n**Troubleshooting (baca `channels[]` di inbox notif-service):**\n| gejala | arti | aksi |\n|---|---|---|\n| `fcm.status = sent` | push terkirim ke FCM | OK |\n| `fcm` error **400 `INVALID_ARGUMENT` / \"registration token is not a valid FCM registration token\"** | **pipeline \u0026 auth FCM SEHAT**; token device salah/kedaluwarsa/palsu | app refresh token \u0026 re-register; jangan pakai token contoh |\n| `fcm` error **401/403 / `UNAUTHENTICATED`** | kredensial server bermasalah (service account / project) | ops: cek `FCM_CREDENTIALS_FILE` + `FCM_PROJECT_ID` di notification-service |\n| tidak ada channel `fcm` sama sekali | account belum punya device token terdaftar | app harus register token dulu (langkah 2) |\n\nChannel `fcm` yang gagal di-retry otomatis sampai `NOTIFICATION_RETRY_MAX_ATTEMPTS` (default 3). Kegagalan FCM **tidak** menggagalkan channel lain (in-app/email tetap terkirim); status agregat notif jadi `partial`.\n\n**Publisher gating** — kontrol via env wacca-service (set di `/opt/wacca-service/.env-runtime`):\n- `NOTIFICATION_PLATFORM` — id platform wacca di notification-service. **Kosong = publisher no-op** (semua trigger di-skip silently, wacca tetap jalan tanpa notif).\n- `NOTIFICATION_EXCHANGE` — default `notification.requests`.\n- `NOTIFICATION_SOURCE_SERVICE` — default `wacca-service` (label di payload).\n- `RABBITMQ_URL` — reused (sama dengan consumer `account.events`).\n\nPublisher fire-and-forget — broker outage tidak break domain operation.\n\n**Kategori (`category`) notif yang dipublish wacca:**\n- **Customer-side** (`target_type=user`):\n  - `order_price_proposed` — owner ajukan harga (**interaktif** Setujui/Tolak, **mandatory**)\n  - `order_accepted` — owner konfirmasi pesanan\n  - `order_rejected` — owner tolak (**mandatory**)\n  - `order_ready` — siap diambil / sedang diantar\n  - `order_completed` — selesai\n  - `order_pickup_dispatched` / `order_pickup_arrived` — status kurir jemput\n  - `order_rating_prompt` — ajakan rating (opt-in, boleh di-mute)\n- **Owner-side** (`target_type=group`, group_id=tenant_id — shared read-state semua tenant_member):\n  - `order_new` — pesanan baru masuk (**mandatory**, SLA owner)\n  - `order_price_approved` — customer setuju harga\n  - `order_price_rejected` — customer tolak harga (**mandatory**, cancellation cascade)\n  - `order_cancelled` — customer batalkan (**mandatory**, revenue loss)\n  - `order_receipt_confirmed` — customer konfirmasi terima pesanan\n  - `member_invite_accepted` / `member_invite_rejected` — staff respon undangan\n- **Invite-flow** (`target_type=user` ke invitee, bukan owner):\n  - `member_invited` — undangan join (**interaktif** Terima/Tolak, **mandatory**)\n\n## Contoh response `GET /api/v1/notifications`\n\n\u003e Dibaca dari **notification-service** (`https://notification.ikavia.com`, header `x-platform: wacca`). Bentuk umum: `{ \"success\": true, \"data\": [ \u003crecord\u003e ], \"meta\": { \"total\", \"limit\", \"offset\" } }`. Tiap record punya spine (`id`, `target_id`, `category`, `status`, `read`, …) + `channels[]` (status kirim per-channel) + `payload` (pesan asli verbatim) + `responses[]`.\n\n**1) `member_invited` — PENDING** (masih ada tombol Terima/Tolak; FCM `failed` karena token device palsu/kedaluwarsa, email tetap terkirim → status agregat `partial`):\n```json\n{\n  \"id\": \"member_invited:019e93e1-c407-772e-aada-3ebf708a2968:1780597507102761874\",\n  \"platform\": \"wacca\", \"target_id\": \"40519b23-80c1-4491-a5d4-ee3055b58bed\",\n  \"target_type\": \"user\", \"category\": \"member_invited\", \"status\": \"partial\", \"read\": false,\n  \"channels\": [\n    { \"type\": \"inapp\", \"status\": \"sent\", \"attempts\": 0 },\n    { \"type\": \"email\", \"status\": \"sent\", \"attempts\": 1, \"sent_at\": \"2026-06-04T18:25:09Z\" },\n    { \"type\": \"fcm\", \"status\": \"failed\", \"attempts\": 3, \"error\": \"fcm API error (status 400): INVALID_ARGUMENT — registration token is not a valid FCM registration token\" }\n  ],\n  \"payload\": {\n    \"title\": \"Undangan bergabung\",\n    \"body\": \"Kamu diundang bergabung di Bersih Cepat Laundry sebagai staff. Terima untuk mulai.\",\n    \"actions\": [ { \"key\": \"member_accept\", \"label\": \"Terima\" }, { \"key\": \"member_reject\", \"label\": \"Tolak\" } ],\n    \"metadata\": { \"invitation_id\": \"019e93e1-c407-772e-aada-3ebf708a2968\", \"role_name\": \"staff\", \"tenant_name\": \"Bersih Cepat Laundry\" }\n  }\n}\n```\n\n**2) `member_invited` — SETTLED (accepted)** (notif yang SAMA setelah invitee terima → `actions` hilang, `body` berubah, `metadata.state=\"accepted\"`):\n```json\n{\n  \"id\": \"member_invited:019e93e1-c407-772e-aada-3ebf708a2968:1780597507102761874\",\n  \"platform\": \"wacca\", \"target_id\": \"40519b23-80c1-4491-a5d4-ee3055b58bed\",\n  \"target_type\": \"user\", \"category\": \"member_invited\", \"status\": \"sent\", \"read\": true,\n  \"channels\": [ { \"type\": \"inapp\", \"status\": \"sent\" }, { \"type\": \"email\", \"status\": \"sent\" } ],\n  \"payload\": {\n    \"title\": \"Undangan diterima\",\n    \"body\": \"Kamu telah menerima undangan bergabung di Bersih Cepat Laundry sebagai staff.\",\n    \"metadata\": { \"invitation_id\": \"019e93e1-c407-772e-aada-3ebf708a2968\", \"role_name\": \"staff\", \"tenant_name\": \"Bersih Cepat Laundry\", \"state\": \"accepted\" }\n  }\n}\n```\n(Rejected: sama tapi `title`=\"Undangan ditolak\", `body`=\"Kamu menolak undangan…\", `metadata.state=\"rejected\"`.)\n\n**3) `order_price_proposed` — PENDING** (tombol Setujui/Tolak; FCM `sent` karena token device valid → status agregat `sent`):\n```json\n{\n  \"id\": \"order_price_proposed:7e1f0a2b-1111-2222-3333-444455556666\",\n  \"platform\": \"wacca\", \"target_id\": \"22222222-2222-2222-2222-222222222222\",\n  \"target_type\": \"user\", \"category\": \"order_price_proposed\", \"status\": \"sent\", \"read\": false,\n  \"channels\": [\n    { \"type\": \"inapp\", \"status\": \"sent\", \"attempts\": 0 },\n    { \"type\": \"email\", \"status\": \"sent\", \"attempts\": 1 },\n    { \"type\": \"fcm\", \"status\": \"sent\", \"attempts\": 1, \"sent_at\": \"2026-06-04T19:24:35Z\" }\n  ],\n  \"payload\": {\n    \"title\": \"Harga pesanan sudah tersedia\",\n    \"body\": \"Pesanan WC-001 sudah ditimbang. Total Rp50.000. Setujui untuk lanjut diproses.\",\n    \"actions\": [ { \"key\": \"approve_price\", \"label\": \"Setujui\" }, { \"key\": \"reject_price\", \"label\": \"Tolak\" } ],\n    \"metadata\": { \"order_id\": \"7e1f0a2b-1111-2222-3333-444455556666\", \"order_number\": \"WC-001\" }\n  }\n}\n```\n\n**4) `order_price_proposed` — SETTLED (approved)** (setelah customer setujui → `actions` hilang, `metadata.state=\"approved\"`):\n```json\n{\n  \"id\": \"order_price_proposed:7e1f0a2b-1111-2222-3333-444455556666\",\n  \"platform\": \"wacca\", \"target_id\": \"22222222-2222-2222-2222-222222222222\",\n  \"target_type\": \"user\", \"category\": \"order_price_proposed\", \"status\": \"sent\", \"read\": false,\n  \"channels\": [ { \"type\": \"inapp\", \"status\": \"sent\" }, { \"type\": \"email\", \"status\": \"sent\" }, { \"type\": \"fcm\", \"status\": \"sent\" } ],\n  \"payload\": {\n    \"title\": \"Harga disetujui\",\n    \"body\": \"Kamu telah menyetujui harga pesanan WC-001. Pesanan sedang diproses.\",\n    \"metadata\": { \"order_id\": \"7e1f0a2b-1111-2222-3333-444455556666\", \"order_number\": \"WC-001\", \"state\": \"approved\" }\n  }\n}\n```\n(Rejected: `title`=\"Harga ditolak\", `body`=\"Kamu menolak harga pesanan…\", `metadata.state=\"rejected\"`.)\n\n**5) Inbox kosong** (`{ \"data\": [] }` — bukan `{}`; kalau dapat `{}`/timeout, base URL atau header `x-platform: wacca` belum di-set di FE):\n```json\n{ \"success\": true, \"data\": [], \"meta\": { \"total\": 0, \"limit\": 20, \"offset\": 0 } }\n```\n","endpoints":[{"name":"Register push token (tenant)","method":"POST","path":"/api/v1/tenant/device-tokens","auth":"JWT Bearer","description":"Daftarkan FCM token device milik tenant member (owner/staff). Token unik global (UNIQUE constraint).\n\n**⚠️ Kirim token MENTAH dari Firebase SDK `getToken()` — TANPA prefix apa pun** (mis. **jangan** `fcm:`). Token FCM tidak pernah diawali `fcm:`; menambah prefix → FCM tolak \"registration token is not a valid FCM registration token\" dan push gagal. Format benar: `\u003cinstanceID\u003e:APA91b...` (mis. `dkZ9x7Qe7mD8aP:APA91bF8...`).\n\n**Status code:**\n- **`200`** — token baru terdaftar (atau token milik akun lain dipindah ke akun ini, mis. shared device).\n- **`400`** — token jelas malformed: ada prefix `fcm:`, mengandung whitespace, berupa URL (`://`), atau terlalu pendek (\u003c100 char). Validasi sengaja konservatif — token FCM yang sah tidak akan ditolak.\n- **`409`** — token **sama** sudah terdaftar untuk **akun ini** (duplikat). App boleh perlakukan 409 sebagai \"sudah terdaftar, aman\" (tidak perlu retry).\n\nSama persis dengan `POST /api/v1/customer/device-tokens` — beda hanya gate auth permission. Mobile/web app yang multi-role bisa pakai endpoint yang sesuai dengan JWT yang sedang aktif.\n","example_body":"{\n  \"token\": \"dkZ9x7Qe7mD8aP:APA91bF8sN0wXr...\",\n  \"platform\": \"android\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": null,\n  \"message\": \"device token registered\"\n}\n"},{"name":"Unregister push token (tenant)","method":"DELETE","path":"/api/v1/tenant/device-tokens","auth":"JWT Bearer","description":"Hapus FCM token (mis. saat logout). Body berisi token yang mau dihapus. Idempoten — token tidak ada → tetap 200/204.\n","example_body":"{\n  \"token\": \"dkZ9x7Qe7mD8aP:APA91bF8sN0wXr...\"\n}\n"},{"name":"Register push token (customer)","method":"POST","path":"/api/v1/customer/device-tokens","auth":"JWT Bearer","description":"Sama persis dengan tenant register; lihat di atas.","example_body":"{\n  \"token\": \"dkZ9x7Qe7mD8aP:APA91bF8sN0wXr...\",\n  \"platform\": \"ios\"\n}\n"},{"name":"Unregister push token (customer)","method":"DELETE","path":"/api/v1/customer/device-tokens","auth":"JWT Bearer","description":"Sama persis dengan tenant unregister.","example_body":"{\n  \"token\": \"dkZ9x7Qe7mD8aP:APA91bF8sN0wXr...\"\n}\n"}]},{"id":"order-flow-owner","title":"Order Flow — Owner","description":"State machine pemrosesan order dari sisi owner outlet. Status order:\n\n```\npending → accepted → price_proposed → processing → ready_pickup|delivering → completed\n                                    (customer approve)         (owner advance)\n(cancelled bisa dari tahap mana pun via reject)\n```\n\n## 🧭 Happy path untuk Frontend (urutan endpoint)\n\n### A) Order pickup (driver jemput ke alamat customer)\n\n```\n[pending]\n  1. POST /api/v1/tenant/orders/{id}/accept                     → [accepted]\n  2. POST /api/v1/tenant/orders/{id}/pickup-dispatch            → pickup_status=dispatched\n  3. POST /api/v1/tenant/orders/{id}/pickup-arrived             → pickup_status=arrived\n     (saat ini di outlet, owner timbang setiap baris)\n  4. PATCH /api/v1/tenant/order-list/{id}/set-final-quantity     (ulang per order_list service)\n     → set order_list.verified_at otomatis\n  5. (verify-complete TIDAK perlu — pickup pakai pickup_status sebagai verification gate)\n  6. POST /api/v1/tenant/orders/{id}/final-price                → [price_proposed]\n  7. (tunggu customer approve-price)                            → [processing]\n  8. POST /api/v1/tenant/orders/{id}/advance {target: ready_pickup|delivering}\n  9. (kalau delivering) wait customer confirm-receipt           → [completed]\n     (kalau ready_pickup, owner advance {target: completed})    → [completed]\n 10. POST /api/v1/tenant/orders/{id}/confirm-cod (kalau payment=cod)\n```\n\n### B) Order self_dropoff (customer antar ke outlet)\n\n```\n[pending]\n  1. POST /api/v1/tenant/orders/{id}/accept                     → [accepted]\n  2. (customer datang antar; owner timbang setiap baris)\n  3. PATCH /api/v1/tenant/order-list/{id}/set-final-quantity     (ulang per service)\n  4. POST /api/v1/tenant/orders/{id}/verify-complete            → orders.verified_at terisi\n     **WAJIB** sebelum step 5 (Anomali #1 guard)\n  5. POST /api/v1/tenant/orders/{id}/final-price                → [price_proposed]\n  6–10. sama dengan A.\n```\n\n### Catatan verify gate (BEDA pickup vs self_dropoff):\n- **Pickup**: berat sudah diketahui saat `pickup-arrived` (driver bawa pulang); SetFinalPrice cuma butuh `pickup_status=arrived`. Memanggil `verify-complete` tidak salah, tapi tidak diperlukan.\n- **Self_dropoff**: tidak ada step `pickup-arrived`; SetFinalPrice butuh `orders.verified_at != null` — di-set lewat `verify-complete` setelah semua `order_list.verified_at` lengkap.\n\n**Guard transisi yang diperbarui (issue #40)**:\n- **Reject** owner hanya valid di status **`pending` saja**. Setelah `accepted`, batal lewat komplain/refund — bukan Reject.\n- **Advance ke `processing`** untuk order app (`customer_id != nil`): owner tidak boleh manual skip dari `accepted` → `processing` — wajib lewat customer `approve-price`.\n- **Advance ke `completed`** dari `delivering`: diblokir; transisi itu dipicu oleh customer `confirm-receipt` atau cron auto-advance. Dari `ready_pickup` → `completed` masih boleh (owner serah-terima langsung di outlet).\n- **SetFinalPrice** boleh re-propose dari `price_proposed` (revisi harga sebelum customer approve).\n\nTransisi divalidasi server (state machine); transisi ilegal → `409`.\n","endpoints":[{"name":"Accept order","method":"POST","path":"/api/v1/tenant/orders/{id}/accept","auth":"JWT Bearer","description":"Owner menerima order. `pending → accepted`. 409 kalau status tidak mengizinkan.","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-0000000000aa\",\n    \"status\": \"accepted\",\n    \"estimated_total\": 87000,\n    \"final_total\": null,\n    \"discount_amount\": 0,\n    \"pickup_fee\": 0,\n    \"tax_amount\": 0,\n    \"updated_at\": \"2026-05-28T10:05:00Z\"\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Reject order","method":"POST","path":"/api/v1/tenant/orders/{id}/reject","auth":"JWT Bearer","description":"Owner menolak order → `cancelled` + alasan. `reason` wajib.\n\n**`reason_code` (issue #43 GAP J, opsional)** — preset alasan untuk analitik. 4 nilai whitelist:\n- `capacity_full` — outlet penuh / antrian panjang\n- `service_unavailable` — service yang dipilih tidak tersedia\n- `out_of_radius` — alamat customer di luar jangkauan\n- `other` — alasan lain (gunakan bila tidak masuk 3 di atas)\n\nResponse include `reject_reason_code` (snake_case, kosong kalau tidak diisi). Dipakai owner mobile (Figma 01b) untuk dashboard alasan reject teragregasi.\n","example_body":"{\n  \"reason\": \"Outlet penuh, tidak bisa terima order hari ini\",\n  \"reason_code\": \"capacity_full\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"...\", \"status\": \"cancelled\", \"reject_reason\": \"Outlet penuh, ...\", \"reject_reason_code\": \"capacity_full\", \"updated_at\": \"...\" },\n  \"message\": \"success\"\n}\n"},{"name":"Set final price","method":"POST","path":"/api/v1/tenant/orders/{id}/final-price","auth":"JWT Bearer","description":"Owner menetapkan harga final setelah verifikasi berat aktual. Server menghitung **PPN gross-up** atas subtotal jasa (versi pajak aktif dari `tax_schemas`), menyimpan snapshot immutable di `order_tax`, dan men-set status `price_proposed`.\n\nSemua field body **opsional**:\n- `subtotal` — nilai jasa final; default = Σ amount order_list (termasuk addon di bawah).\n- `discount_amount` / `pickup_fee` — default = nilai existing di order.\n- `tax_code` — default `\"PPN\"`.\n- `addons[]` — baris tambahan owner (mis. Pewangi), referensi item product outlet: `{item_id, quantity, amount}`. `amount` 0 → dihitung dari harga By-Platform × quantity. Dibuat sbg order_list `is_addon=true`, ikut terhitung di subtotal.\n\nPerhitungan: `tax = round(subtotal × rate)`, `final_total = subtotal − discount + pickup_fee + tax`.\n","example_body":"{\n  \"subtotal\": 87000,\n  \"discount_amount\": 0,\n  \"pickup_fee\": 0,\n  \"tax_code\": \"PPN\",\n  \"addons\": [ { \"item_id\": \"01923c8b-...-pewangi\", \"quantity\": 1 } ]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"...\",\n    \"status\": \"price_proposed\",\n    \"estimated_total\": 87000,\n    \"final_total\": 96570,\n    \"discount_amount\": 0,\n    \"pickup_fee\": 0,\n    \"tax_amount\": 9570,\n    \"updated_at\": \"...\"\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Advance status","method":"POST","path":"/api/v1/tenant/orders/{id}/advance","auth":"JWT Bearer","description":"Memajukan status fulfilment. `target` salah satu: `processing`, `ready_pickup`, `delivering`, `completed`. Transisi divalidasi state machine (409 kalau ilegal). Saat `processing`, process line otomatis dibuat per order_list.\n","example_body":"{\n  \"target\": \"ready_pickup\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"...\", \"status\": \"ready_pickup\", \"final_total\": 96570, \"tax_amount\": 9570, \"updated_at\": \"...\" },\n  \"message\": \"success\"\n}\n"},{"name":"Pickup dispatched","method":"POST","path":"/api/v1/tenant/orders/{id}/pickup-dispatch","auth":"JWT Bearer","description":"Owner/staff catat driver sudah berangkat menuju alamat pickup customer. Set `pickup_status=dispatched` + `pickup_dispatched_at=now()`. Trigger notifikasi customer (FCM + inbox: \"Driver sudah berangkat\").\n\nTidak terikat ke `order.status` — boleh dipanggil kapan saja dalam state pickup, selama status pickup belum dimulai.\n\n**400** kalau `delivery_method` bukan `pickup`.\n**409** kalau `pickup_status` sudah ter-set (sudah `dispatched` atau `arrived`) — **tidak idempoten**, panggilan ke-2 ditolak.\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"...\", \"status\": \"accepted\", \"pickup_status\": \"dispatched\", \"pickup_dispatched_at\": \"2026-06-10T09:00:00Z\" },\n  \"message\": \"success\"\n}\n"},{"name":"Pickup arrived","method":"POST","path":"/api/v1/tenant/orders/{id}/pickup-arrived","auth":"JWT Bearer","description":"Driver tiba di lokasi customer, pesanan terambil. Set `pickup_status=arrived` + `pickup_arrived_at=now()`. Trigger notifikasi customer.\n\n**400** kalau `delivery_method` bukan `pickup`.\n**409** kalau `pickup_status` belum `dispatched` atau sudah `arrived` (idempotency-aware).\n"},{"name":"Verify complete","method":"POST","path":"/api/v1/tenant/orders/{id}/verify-complete","auth":"JWT Bearer","description":"Owner kunci hasil verifikasi tiap baris order. Set `orders.verified_at=now()`. Sebagai gate sebelum SetFinalPrice — **mencegah** owner ajukan harga final sebelum semua item ditandai verified (Anomali #1).\n\nBody tidak diperlukan. **409 `some items not verified`** kalau ada baris `order_list` dengan `verified_at = NULL` (artinya owner belum kunci timbangan/qty aktual untuk baris itu).\n\nFoto verifikasi (lihat [Order Verification](#order-verification)) **TIDAK** divalidasi di sini — owner punya kontrol terpisah lewat `verification-photos` endpoint. Yang dicek hanya `order_list.verified_at` per baris.\n"},{"name":"Verification (one-step actual verify)","method":"POST","path":"/api/v1/tenant/orders/{id}/verification","auth":"JWT Bearer","description":"**Issue #43 GAP F — endpoint super-set yang gabungkan SetFinalQuantity (per item) + AttachVerificationPhotos + (optional) finalize gate dalam satu request**, jadi owner cukup 1 call dari FE alih-alih N+1.\n\nMinimal salah satu field harus ada: `items[]`, `photo_media_ids[]`, atau `note`.\n\n**Field**:\n- `items[]` — set kuantitas final + (opsional) `actual_pcs` + `note` per baris order_list. Reuse logika `SetFinalQuantity` (validasi ownership, RBAC outlet-scope, recompute amount, set `verified_at`).\n- `photo_media_ids[]` — attach foto verifikasi (dari media-service upload). Reuse `verification-photos` POST.\n- `note` — catatan global verifikasi (disimpan di `order.verification_note`).\n\n**Response** `VerifyResultPresent` ringkas:\n- `verified_items` — jumlah baris yang quantity-nya di-update sukses.\n- `attached_photos` — jumlah foto yang berhasil di-attach.\n- `verification_note` — note final.\n- `completed` — `true` kalau setelah call ini SEMUA baris `verified_at != NULL` (artinya owner bisa lanjut SetFinalPrice tanpa panggil `verify-complete` lagi). `false` = masih ada baris belum diverifikasi, owner perlu call lagi atau pakai `verify-complete` setelah lengkap.\n\n**Beda dengan `verify-complete`**: `verification` = \"kerjain verifikasi + finalize otomatis kalau lengkap\" (1 call). `verify-complete` = hanya gate finalize tanpa update apapun.\n","example_body":"{\n  \"items\": [\n    { \"order_list_id\": \"01923c8b-...-ol1\", \"quantity\": 3.2, \"note\": \"berat aktual setelah timbang\" },\n    { \"order_list_id\": \"01923c8b-...-ol2\", \"quantity\": 1.0, \"actual_pcs\": 5 }\n  ],\n  \"photo_media_ids\": [\"01923c8b-...-photo1\", \"01923c8b-...-photo2\"],\n  \"note\": \"Cuci ekspress, kain putih dipisah\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"order_id\": \"01923c8b-...-order\",\n    \"verified_items\": 2,\n    \"attached_photos\": 2,\n    \"verification_note\": \"Cuci ekspress, kain putih dipisah\",\n    \"completed\": true\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Confirm COD payment","method":"POST","path":"/api/v1/tenant/orders/{id}/confirm-cod","auth":"JWT Bearer","description":"Owner mengonfirmasi uang tunai diterima → catat baris `outlet_revenue` (channel `cash`, langsung `settled`). Gross = `final_total` (atau total_amount bila final belum di-set), `net = gross − tax`. **Idempoten** per order (payment_ref `cod-\u003corder_id\u003e`) — panggil dua kali tidak menggandakan revenue. Tidak mengubah status fulfilment.\n\n**`received_amount` (issue #43 GAP G, opsional body field)** — nominal cash yang owner terima dari customer (untuk hitung kembalian di UI). Tidak mengubah revenue calc — gross tetap dari `final_total`. Field di-disimpan untuk audit. Body kosong / tidak ada body sama sekali tetap valid (EOF bukan error).\n","example_body":"{\n  \"received_amount\": 100000\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"...\", \"status\": \"completed\", \"final_total\": 96570, \"tax_amount\": 9570, \"updated_at\": \"...\" },\n  \"message\": \"success\"\n}\n"}]},{"id":"order-flow-customer","title":"Order Flow — Customer","description":"Aksi customer atas order miliknya. Sebagian besar gated by `order.status`.\n\n## 🧭 Happy path untuk Frontend (urutan endpoint customer-side)\n\n```\n1. POST   /api/v1/customer/orders                       → bikin order [pending]\n   (opsional sebelum POST: /coupon-preview untuk hitung diskon)\n2. GET    /api/v1/customer/orders/{id}                  → polling/refresh untuk dapat status terbaru\n   atau   /api/v1/customer/orders/{id}/timeline         → struktur step UI\n\n   [pending]   ←─ kalau mau batal: POST /cancel\n   ↓ (owner accept)\n   [accepted]  ←─ (kalau pickup: tunggu pickup-arrived event)\n   ↓ (owner pickup + timbang + final-price)\n   [price_proposed]\n   3a. POST /api/v1/customer/orders/{id}/approve-price  → [processing]\n       ATAU\n   3b. POST /api/v1/customer/orders/{id}/reject-price + body {reason}\n       → [cancelled] dengan cancelled_by=\"customer\"\n   ↓ (owner proses + advance ke ready_pickup atau delivering)\n   [ready_pickup]: customer ambil di outlet → owner advance ke completed\n   [delivering]:   driver antar → customer terima\n   4. POST /api/v1/customer/orders/{id}/confirm-receipt → [completed]\n      (atau jika owner di ready_pickup, owner yang advance — customer tidak perlu confirm)\n```\n\n### Catatan untuk FE:\n- **Pickup status real-time**: order dengan `delivery_method=pickup` punya field `pickup_status` (`\"\"`/`dispatched`/`arrived`). FE tampilkan timeline pickup (driver dispatch → arrived) di UI tracking. Push notif `order_pickup_dispatched` / `order_pickup_arrived` dikirim ke device customer.\n- **Cancel sebelum dikerjakan**: valid saat status `pending` atau `price_proposed`. Setelah `processing` ke atas → harus lewat komplain (lihat [Complaint](#complaint)).\n- **Reject-price ≠ Cancel**: reject-price khusus saat customer tidak setuju harga final di `price_proposed`. Cancel umum untuk batal di awal.\n- **Confirm-receipt** hanya valid saat `delivering`. Untuk `ready_pickup` (customer ambil di outlet), owner yang advance ke completed setelah serah-terima.\n- **Timeline**: GET `/customer/orders/{id}/timeline` lebih efisien daripada parse status sendiri — server memberi step array siap render.\n\n---\n\nEndpoint:\n","endpoints":[{"name":"Approve price","method":"POST","path":"/api/v1/customer/orders/{id}/approve-price","auth":"JWT Bearer","description":"Customer menyetujui harga final → order maju ke `processing`. 409 kalau status bukan `price_proposed`.\n\n**Notifikasi \"Harga pesanan\" di-update in-place.** Notif `order_price_proposed` di inbox customer (yang punya tombol Setujui/Tolak) di-re-publish dengan id sama → `actions: []` (tombol hilang), `body` \"Kamu telah menyetujui harga pesanan …\", `metadata.state: \"approved\"`. Tidak kirim ulang email/push. Owner juga dapat notif `order_price_approved`.\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"...\", \"status\": \"processing\", \"final_total\": 96570, \"tax_amount\": 9570, \"updated_at\": \"...\" },\n  \"message\": \"success\"\n}\n"},{"name":"Reject price","method":"POST","path":"/api/v1/customer/orders/{id}/reject-price","auth":"JWT Bearer","description":"Customer menolak harga final → order `cancelled` + alasan. `reason` wajib. 409 kalau status bukan `price_proposed`.\n\n**Notifikasi \"Harga pesanan\" di-update in-place** sama seperti approve: notif `order_price_proposed` di-re-publish dengan `actions: []`, `body` \"Kamu menolak harga pesanan …\", `metadata.state: \"rejected\"` (no re-send). Owner dapat notif `order_price_rejected`.\n","example_body":"{\n  \"reason\": \"Harga terlalu tinggi dari estimasi\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"...\", \"status\": \"cancelled\", \"reject_reason\": \"Harga terlalu tinggi ...\", \"updated_at\": \"...\" },\n  \"message\": \"success\"\n}\n"}]},{"id":"order-verification","title":"Order Verification Photos","description":"Bukti foto verifikasi atas order (mis. kondisi pakaian saat diterima, hasil cuci, paket siap kirim). Tenant upload, customer \u0026 tenant sama-sama bisa list. Dipakai sebagai evidence kalau ada komplain (lihat [Customer — Complaint](#customer-complaint)).\n","endpoints":[{"name":"Tenant — attach verification photos","method":"POST","path":"/api/v1/tenant/orders/{id}/verification-photos","auth":"JWT Bearer","description":"Owner/kasir lampirkan foto bukti (1+ media_id) ke order. 404 kalau order bukan milik tenant.\n","example_body":"{\n  \"media_ids\": [\n    \"01923c8b-1234-7000-a000-0000000000m1\",\n    \"01923c8b-1234-7000-a000-0000000000m2\"\n  ],\n  \"stage\": \"pickup\",\n  \"note\": \"Pakaian diterima — 1 noda di kerah kemeja\"\n}\n"},{"name":"Tenant — list verification photos","method":"GET","path":"/api/v1/tenant/orders/{id}/verification-photos","auth":"JWT Bearer","description":"Foto verifikasi pada order tenant (urutan kronologis)."},{"name":"Customer — list verification photos","method":"GET","path":"/api/v1/customer/orders/{id}/verification-photos","auth":"JWT Bearer","description":"Customer lihat foto verifikasi order miliknya. 404 kalau bukan milik customer."}]},{"id":"outlet-media","title":"Outlet Media","description":"Galeri foto outlet. `media_id` adalah referensi ke file di **media-service** (`media.ikavia.com`); wacca-service hanya simpan binding `outlet ↔ media_id`. Upload file aktual + share-link mint dilakukan terhadap media-service dulu, lalu attach `media_id` di sini.\n\nResponse field `reference_id` berisi outlet id dan `reference_type` selalu `\"outlet\"` (auto-set oleh use-case).\n","endpoints":[{"name":"List outlet media","method":"GET","path":"/api/v1/tenant/outlet/outlet-images","auth":"JWT Bearer","description":"List semua media milik tenant. Filter via query param.","query_params":[{"name":"page","type":"int","description":"Default 1."},{"name":"limit","type":"int","description":"Default 20."},{"name":"outlet_id","type":"string","description":"Filter by outlet ID (UUID)."}],"example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"01923c8b-1234-7000-a000-000000000200\",\n      \"tenant_id\": \"01923c8b-1234-7000-a000-000000000001\",\n      \"reference_id\": \"01923c8b-1234-7000-a000-000000000100\",\n      \"reference_type\": \"outlet\",\n      \"media_id\": \"media_8f3e9a2b\",\n      \"created_at\": \"2026-05-19T10:00:00Z\",\n      \"updated_at\": \"2026-05-19T10:00:00Z\"\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"total\": 1, \"total_pages\": 1, \"has_next\": false, \"has_prev\": false }\n}\n"},{"name":"Attach media to outlet","method":"POST","path":"/api/v1/tenant/outlet/outlet-images","auth":"JWT Bearer","description":"Bind sebuah `media_id` (sudah ada di media-service) ke outlet.\n\n**Pre-step**: upload file dulu ke `POST https://media.ikavia.com/api/upload` → terima `media_id`. Lalu mint share-link kalau perlu URL publik (`POST /media/{id}/share`).\n","example_body":"{\n  \"reference_id\": \"01923c8b-1234-7000-a000-000000000100\",\n  \"reference_type\": \"outlet\",\n  \"media_id\": \"media_8f3e9a2b\"\n}\n"},{"name":"Get outlet media","method":"GET","path":"/api/v1/tenant/outlet/outlet-images/{media-id}","auth":"JWT Bearer","description":"Detail single media binding."},{"name":"Update outlet media","method":"PUT","path":"/api/v1/tenant/outlet/outlet-images/{media-id}","auth":"JWT Bearer","description":"Swap `media_id` (rebind binding existing ke file media-service yang berbeda). Tidak menghapus file lama di media-service (hanya update reference).\n","example_body":"{\n  \"media_id\": \"media_new_id\"\n}\n"},{"name":"Detach outlet media","method":"DELETE","path":"/api/v1/tenant/outlet/outlet-images/{media-id}","auth":"JWT Bearer","description":"Soft delete binding (tidak menyentuh file di media-service). Caller bertanggung jawab cleanup file di media-service sendiri kalau memang ingin hapus.\n"}]},{"id":"outlet-operations","title":"Outlet Operations (Jam Buka)","description":"Jam operasional outlet. Disimpan sebagai polymorphic entry di tabel `time_operation` dengan `reference_type=\"outlet\"` dan `reference_id=outlet_id`.\n\nField `opening_hours` dan `closing_hours` adalah string bebas — saat ini server **tidak** mem-parsing format. Konvensi FE umumnya pakai `HH:MM` atau JSON-stringified rule (mis. `{\"mon\":\"08:00-22:00\",\"sat\":\"closed\"}`).\n","endpoints":[{"name":"List outlet operations","method":"GET","path":"/api/v1/tenant/outlet/outlet-operations","auth":"JWT Bearer","description":"List semua jam operasional milik tenant. Filter via query.","query_params":[{"name":"page","type":"int","description":"Default 1."},{"name":"limit","type":"int","description":"Default 20."},{"name":"outlet_id","type":"string","description":"Filter by outlet ID."},{"name":"is_active","type":"boolean","description":"Filter by `is_active`."}]},{"name":"Create outlet operation","method":"POST","path":"/api/v1/tenant/outlet/outlet-operations","auth":"JWT Bearer","description":"Tambah jam operasional. `reference_type` auto-set ke `\"outlet\"`.","example_body":"{\n  \"reference_id\": \"01923c8b-1234-7000-a000-000000000100\",\n  \"name\": \"Regular hours\",\n  \"description\": \"Senin-Jumat\",\n  \"opening_hours\": \"08:00\",\n  \"closing_hours\": \"22:00\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-000000000300\",\n    \"tenant_id\": \"01923c8b-1234-7000-a000-000000000001\",\n    \"reference_id\": \"01923c8b-1234-7000-a000-000000000100\",\n    \"reference_type\": \"outlet\",\n    \"name\": \"Regular hours\",\n    \"description\": \"Senin-Jumat\",\n    \"opening_hours\": \"08:00\",\n    \"closing_hours\": \"22:00\",\n    \"is_active\": true,\n    \"created_at\": \"2026-05-19T10:00:00Z\",\n    \"updated_at\": \"2026-05-19T10:00:00Z\"\n  },\n  \"message\": \"created successfully\"\n}\n"},{"name":"Get outlet operation","method":"GET","path":"/api/v1/tenant/outlet/outlet-operations/{operation-id}","auth":"JWT Bearer","description":"Detail satu jam operasional."},{"name":"Update outlet operation","method":"PUT","path":"/api/v1/tenant/outlet/outlet-operations/{operation-id}","auth":"JWT Bearer","description":"Partial update. Kosongkan field yang tidak berubah.","example_body":"{\n  \"closing_hours\": \"23:00\"\n}\n"},{"name":"Delete outlet operation","method":"DELETE","path":"/api/v1/tenant/outlet/outlet-operations/{operation-id}","auth":"JWT Bearer","description":"Soft delete jam operasional."},{"name":"Get outlet operation setups (per outlet)","method":"GET","path":"/api/v1/tenant/outlet/{id}/outlet-operation-setups","auth":"JWT Bearer","description":"Return seluruh 7 hari (MON–SUN) time-operation untuk satu outlet. Endpoint praktis untuk UI form setup mingguan — gak perlu paging atau filter.\n","example_response":"{\n  \"success\": true,\n  \"data\": [\n    { \"day\": \"MON\", \"opening_hours\": \"08:00\", \"closing_hours\": \"20:00\", \"is_active\": true },\n    { \"day\": \"TUE\", \"opening_hours\": \"08:00\", \"closing_hours\": \"20:00\", \"is_active\": true }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"Setup outlet operations (bulk MON–SUN, legacy)","method":"PUT","path":"/api/v1/tenant/outlet/{id}/outlet-operation-setups","auth":"JWT Bearer","description":"⚠ **Legacy** — pakai endpoint baru [`PUT /tenant/outlet/{id}/operating-hours`](#outlet-operating-hours) sebagai gantinya (declarative full-week + timezone + currently_open).\n\nCreate-or-update 7 hari sekaligus untuk satu outlet — match by `day`. Body array config per-hari. Idempoten.\n"}]},{"id":"outlet-operating-hours","title":"Outlet Operating Hours (Jam Buka Mingguan — Declarative)","description":"Redesign jam operasional jadi **PUT declarative full-week**. Frontend kirim seluruh 7 hari dalam satu request; server overwrite seluruhnya. Hari yang tidak dikirim dianggap **closed**.\n\n**Fields**:\n- `timezone` — IANA zone untuk interpretasi `open_time`/`close_time` (default `Asia/Jakarta`, disimpan di `outlet.timezone`).\n- `is_open` — apakah outlet operasi pada hari itu. `false` → `open_time`/`close_time` boleh omit.\n- `open_time` / `close_time` — string `HH:MM` (atau `null` saat tutup).\n- `currently_open` (response only) — apakah outlet **saat ini** open: kombinasi jam mingguan + operational-status override (lihat section bawah).\n","endpoints":[{"name":"Get weekly operating hours","method":"GET","path":"/api/v1/tenant/outlet/{id}/operating-hours","auth":"JWT Bearer","description":"Ambil 7-hari schedule + `currently_open` flag. 404 kalau outlet bukan milik tenant.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"outlet_id\": \"01923c8b-1234-7000-a000-000000000004\",\n    \"timezone\": \"Asia/Jakarta\",\n    \"currently_open\": true,\n    \"days\": [\n      { \"day\": \"MON\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n      { \"day\": \"TUE\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n      { \"day\": \"WED\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n      { \"day\": \"THU\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n      { \"day\": \"FRI\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n      { \"day\": \"SAT\", \"is_open\": true,  \"open_time\": \"09:00\", \"close_time\": \"18:00\" },\n      { \"day\": \"SUN\", \"is_open\": false, \"open_time\": null,    \"close_time\": null }\n    ]\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Replace weekly operating hours","method":"PUT","path":"/api/v1/tenant/outlet/{id}/operating-hours","auth":"JWT Bearer","permission":"wacca-tenant:*","description":"Replace seluruh 7 hari dalam satu call. Idempoten. Hari yang **tidak dikirim** otomatis di-treat sebagai `is_open=false`. Validasi: `timezone` IANA valid; tiap `open_time`/`close_time` format `HH:MM` (kalau `is_open=true`).\n\n**400** input invalid (timezone bukan IANA / format jam salah / day duplikat).\n**403** caller bukan owner/manager (capability `catalog:write` required).\n","example_body":"{\n  \"timezone\": \"Asia/Jakarta\",\n  \"days\": [\n    { \"day\": \"MON\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n    { \"day\": \"TUE\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n    { \"day\": \"WED\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n    { \"day\": \"THU\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n    { \"day\": \"FRI\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n    { \"day\": \"SAT\", \"is_open\": true,  \"open_time\": \"09:00\", \"close_time\": \"18:00\" },\n    { \"day\": \"SUN\", \"is_open\": false }\n  ]\n}\n"}]},{"id":"outlet-operational-status","title":"Outlet Operational Status (Buka / Tutup Sementara)","description":"Toggle status real-time outlet — owner bisa **tutup sementara** dengan alasan tanpa mengubah jadwal mingguan. Beda dari weekly hours: ini override darurat (mis. kebanjiran, libur khusus). `currently_open` di response weekly-hours mempertimbangkan override ini.\n\nField `status` valid: `\"open\"` atau `\"temporarily_closed\"`. Saat `temporarily_closed`, `reason` wajib + `closed_at` di-set ke timestamp pelaksanaan. Kembali ke `open` → `reason`/`closed_at` di-clear.\n","endpoints":[{"name":"Get operational status","method":"GET","path":"/api/v1/tenant/outlet/{id}/operational-status","auth":"JWT Bearer","description":"Status outlet sekarang.","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"outlet_id\": \"01923c8b-1234-7000-a000-000000000004\",\n    \"status\": \"temporarily_closed\",\n    \"reason\": \"Banjir, akses terhambat\",\n    \"closed_at\": \"2026-06-02T08:30:00Z\"\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Set operational status","method":"PUT","path":"/api/v1/tenant/outlet/{id}/operational-status","auth":"JWT Bearer","permission":"wacca-tenant:*","description":"Open atau tutup sementara. `status=\"temporarily_closed\"` → `reason` wajib non-empty. `status=\"open\"` → reason ignored, `closed_at` di-clear.\n\n**400** status tak dikenal atau reason kosong saat temporarily_closed.\n**403** caller tidak punya `outlet:manage`.\n","example_body":"{\n  \"status\": \"temporarily_closed\",\n  \"reason\": \"Banjir, akses terhambat\"\n}\n"}]},{"id":"outlet","title":"Outlet","description":"Cabang fisik milik tenant. Tenant bisa punya banyak outlet (cabang).\n\nSelain CRUD standar, ada dua action terpisah untuk **buka/tutup** outlet real-time (`is_opening` flag) dan **buka/tutup** driver (`driver_available` flag). Penggunaan tipikal: kasir hit `/open-outlet` saat mulai shift, `/close-outlet` saat tutup toko. Berbeda dari `DELETE` (soft archive `is_active=false`).\n","endpoints":[{"name":"List outlets","method":"GET","path":"/api/v1/tenant/outlet","auth":"JWT Bearer","description":"List outlet milik tenant saat ini (paginated).","query_params":[{"name":"page","type":"int","description":"Default 1."},{"name":"limit","type":"int","description":"Default 20."},{"name":"outlet_id","type":"string","description":"Filter ke outlet tertentu (tetap tenant-scoped — outlet milik tenant lain tidak akan muncul)."},{"name":"is_active","type":"boolean","description":"Filter by `is_active`."}],"example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"01923c8b-1234-7000-a000-000000000100\",\n      \"tenant_id\": \"01923c8b-1234-7000-a000-000000000001\",\n      \"name\": \"Bersih Cepat — Cabang Pusat\",\n      \"description\": \"Cabang flagship\",\n      \"profile_picture_id\": \"01923c8b-1234-7000-a000-000000000101\",\n      \"mobile_number\": \"+62812xxxxxxx\",\n      \"address\": \"Jl. Merdeka 10\",\n      \"location\": \"Depan Indomaret\",\n      \"latitude\": \"-6.175392\",\n      \"longitude\": \"106.827153\",\n      \"province_id\": 31,\n      \"city_id\": 3171,\n      \"subdistrict_id\": 317101,\n      \"is_opening\": true,\n      \"driver_available\": true,\n      \"delivery_fee_per_km\": 2000,\n      \"delivery_max_distance_km\": 5,\n      \"timezone\": \"Asia/Jakarta\",\n      \"is_active\": true,\n      \"created_at\": \"2026-05-19T10:00:00Z\",\n      \"updated_at\": \"2026-05-19T10:00:00Z\"\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"total\": 1, \"total_pages\": 1, \"has_next\": false, \"has_prev\": false },\n  \"message\": \"success\"\n}\n"},{"name":"Create outlet (composite — info dasar + jam + services + products)","method":"POST","path":"/api/v1/tenant/outlet","auth":"JWT Bearer","description":"Buat outlet baru sekaligus (opsional) **jam operasional mingguan**, **services**, dan **products** dalam satu call atomik. `tenant_id` resolve otomatis dari JWT `org_id`.\n\n**Alamat outlet**: `address` (jalan), `location` (catatan/landmark, opsional), `latitude`/`longitude` (dari map picker), plus `province_id` / `city_id` / `subdistrict_id` dari [Location Lookup](#location-lookup) (cascading). `delivery_fee_per_km` dipakai untuk hitung ongkos jemput per-order; `delivery_max_distance_km` = cap radius layanan (\"Maks 5 KM\"), `0` = tidak dibatasi.\n\n**Profile picture** — `profile_picture_id` (opsional) = `media_id` string dari media-service. Upload file dulu ke `POST https://media.ikavia.com/api/upload` → terima `media_id`, lalu kirim di field ini. Untuk **galeri** outlet (multi-foto), lihat [Outlet Media](#outlet-media).\n\n**Composite (semua optional)** — kalau di-omit, perilaku sama dengan create lama (cuma info dasar):\n- `operating_hours.days[]` — full-week schedule. Validasi: `day ∈ {MON,TUE,WED,THU,FRI,SAT,SUN}` unik, `open_time`/`close_time` format `HH:MM` (kalau `is_open=true`), `open_time \u003c close_time`. Hari yang tidak dikirim di-treat `is_open=false`. Timezone diambil dari field top-level `timezone`.\n- `services[]` — service yang dibuat sekaligus, `{name, description, unit_of_measure, tags, rate}`. `rate \u003e 0`.\n- `products[]` — product yang dibuat sekaligus, `{name, description, unit_of_measure, tags, estimation, rate}`. `estimation` = label turnaround bebas (mis. \"24 Jam\"). `rate \u003e 0`.\n\n**All-or-nothing**: server validasi seluruh payload lebih dulu (no DB writes). Setelah itu, kalau ada step gagal (mis. service ke-3 invalid), seluruh resource yang sudah dibuat **di-rollback** (compensating delete) — FE tidak perlu kelola partial cleanup.\n\n**400** payload invalid (per-step error me-mention path-nya, mis. `services[2].rate must be greater than 0`). 400 juga kalau tenant belum di-bootstrap.\n\n**Response shape berubah** — sekarang `{outlet, operating_hours, services, products}` (bukan flat outlet seperti dulu). Client lama yang baca `response.data.id` perlu update ke `response.data.outlet.id`.\n","example_body":"{\n  \"name\": \"Bersih Cepat — Cabang Pusat\",\n  \"description\": \"Cabang flagship\",\n  \"profile_picture_id\": \"media_5e8c1f2a\",\n  \"mobile_number\": \"+62812xxxxxxx\",\n  \"address\": \"Jl. Merdeka 10\",\n  \"location\": \"Depan Indomaret\",\n  \"latitude\": \"-6.175392\",\n  \"longitude\": \"106.827153\",\n  \"province_id\": 31,\n  \"city_id\": 3171,\n  \"subdistrict_id\": 317101,\n  \"timezone\": \"Asia/Jakarta\",\n  \"is_opening\": false,\n  \"driver_available\": true,\n  \"delivery_fee_per_km\": 2000,\n  \"delivery_max_distance_km\": 5,\n\n  \"operating_hours\": {\n    \"days\": [\n      { \"day\": \"MON\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n      { \"day\": \"TUE\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n      { \"day\": \"WED\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n      { \"day\": \"THU\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n      { \"day\": \"FRI\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n      { \"day\": \"SAT\", \"is_open\": true,  \"open_time\": \"09:00\", \"close_time\": \"18:00\" },\n      { \"day\": \"SUN\", \"is_open\": false }\n    ]\n  },\n\n  \"services\": [\n    {\n      \"name\": \"Cuci Reguler\",\n      \"description\": \"Cuci + setrika standar\",\n      \"unit_of_measure\": \"kg\",\n      \"tags\": \"reguler\",\n      \"rate\": 7000\n    }\n  ],\n\n  \"products\": [\n    {\n      \"name\": \"Selimut King\",\n      \"description\": \"Cuci khusus selimut besar\",\n      \"unit_of_measure\": \"pcs\",\n      \"tags\": \"khusus\",\n      \"estimation\": \"48 Jam\",\n      \"rate\": 35000\n    }\n  ]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"outlet\": {\n      \"id\": \"01923c8b-1234-7000-a000-000000000100\",\n      \"name\": \"Bersih Cepat — Cabang Pusat\",\n      \"timezone\": \"Asia/Jakarta\",\n      \"delivery_fee_per_km\": 2000,\n      \"delivery_max_distance_km\": 5,\n      \"is_opening\": false,\n      \"driver_available\": true,\n      \"is_active\": true,\n      \"summary\": { \"total_orders\": 0, \"total_income\": 0 },\n      \"created_at\": \"2026-06-02T07:50:00Z\",\n      \"updated_at\": \"2026-06-02T07:50:00Z\"\n    },\n    \"operating_hours\": {\n      \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n      \"timezone\": \"Asia/Jakarta\",\n      \"currently_open\": true,\n      \"days\": [\n        { \"day\": \"MON\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n        { \"day\": \"TUE\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n        { \"day\": \"WED\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n        { \"day\": \"THU\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n        { \"day\": \"FRI\", \"is_open\": true,  \"open_time\": \"08:00\", \"close_time\": \"20:00\" },\n        { \"day\": \"SAT\", \"is_open\": true,  \"open_time\": \"09:00\", \"close_time\": \"18:00\" },\n        { \"day\": \"SUN\", \"is_open\": false, \"open_time\": null,    \"close_time\": null }\n      ]\n    },\n    \"services\": [\n      {\n        \"id\": \"01923c8b-...-svc1\",\n        \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n        \"type\": \"service\",\n        \"name\": \"Cuci Reguler\",\n        \"description\": \"Cuci + setrika standar\",\n        \"tags\": \"reguler\",\n        \"is_active\": true,\n        \"price\": {\n          \"id\": \"01923c8b-...-prc1\",\n          \"unit_of_measure\": \"kg\",\n          \"rate\": 7000,\n          \"is_active\": true\n        },\n        \"created_at\": \"2026-06-02T07:50:00Z\",\n        \"updated_at\": \"2026-06-02T07:50:00Z\"\n      }\n    ],\n    \"products\": [\n      {\n        \"id\": \"01923c8b-...-prd1\",\n        \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n        \"type\": \"product\",\n        \"name\": \"Selimut King\",\n        \"description\": \"Cuci khusus selimut besar\",\n        \"tags\": \"khusus\",\n        \"estimation\": \"48 Jam\",\n        \"is_active\": true,\n        \"price\": {\n          \"id\": \"01923c8b-...-prc2\",\n          \"unit_of_measure\": \"pcs\",\n          \"rate\": 35000,\n          \"is_active\": true\n        },\n        \"created_at\": \"2026-06-02T07:50:00Z\",\n        \"updated_at\": \"2026-06-02T07:50:00Z\"\n      }\n    ]\n  },\n  \"message\": \"outlet created successfully\"\n}\n"},{"name":"Get outlet","method":"GET","path":"/api/v1/tenant/outlet/{outlet-id}","auth":"JWT Bearer","description":"Detail outlet by id. 404 kalau bukan milik tenant ini."},{"name":"Update outlet","method":"PUT","path":"/api/v1/tenant/outlet/{outlet-id}","auth":"JWT Bearer","description":"Partial update. Untuk toggle `is_opening` runtime gunakan `PATCH .../open-outlet` dan `PATCH .../close-outlet` (lebih intent-explicit untuk audit \u0026 FE). Untuk toggle `driver_available` gunakan `PATCH .../open-drivers` dan `PATCH .../close-drivers`.\n","example_body":"{\n  \"description\": \"Cabang flagship — buka 24 jam akhir pekan\",\n  \"driver_available\": false\n}\n"},{"name":"Archive outlet (soft delete)","method":"DELETE","path":"/api/v1/tenant/outlet/{outlet-id}","auth":"JWT Bearer","description":"Soft delete (`deleted_at` terisi). Outlet hilang dari list. Tidak ada endpoint restore via REST — kontak admin / SQL kalau perlu un-archive.\n"},{"name":"Open outlet","method":"PATCH","path":"/api/v1/tenant/outlet/{outlet-id}/open-outlet","auth":"JWT Bearer","description":"Set `is_opening = true`. Idempotent — kalau sudah open, balas 200 tanpa side effect.\n\nGunakan saat kasir mulai shift / outlet buka pintu. Tidak mengubah `is_active`.\n"},{"name":"Close outlet","method":"PATCH","path":"/api/v1/tenant/outlet/{outlet-id}/close-outlet","auth":"JWT Bearer","description":"Set `is_opening = false`. Idempotent.\n\nGunakan saat kasir akhir shift / outlet tutup pintu. Untuk archive permanen pakai `DELETE`.\n"},{"name":"Open drivers","method":"PATCH","path":"/api/v1/tenant/outlet/{outlet-id}/open-drivers","auth":"JWT Bearer","description":"Set `driver_available = true`. Hanya berhasil kalau outlet punya minimal satu driver aktif. Idempotent.\n"},{"name":"Close drivers","method":"PATCH","path":"/api/v1/tenant/outlet/{outlet-id}/close-drivers","auth":"JWT Bearer","description":"Set `driver_available = false`. Idempotent.\n"},{"name":"Outlet summary","method":"GET","path":"/api/v1/tenant/outlet/{outlet-id}/summary","auth":"JWT Bearer","description":"Ringkasan gabungan untuk satu outlet — orders + income + status outlet. 404 kalau outlet bukan milik tenant ini. Data orders/income ditarik dari view `v_outlet_metrics`.\n\n`outlet.total_outlet` selalu `1` (karena endpoint ini per-outlet); `outlet_active`/`outlet_non_active` = 1 sesuai status outlet.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"total_debit\": 0,\n    \"total_orders\": 42,\n    \"total_income\": 1500000,\n    \"outlet\": {\n      \"total_outlet\": 1,\n      \"outlet_active\": 1,\n      \"outlet_non_active\": 0\n    }\n  },\n  \"message\": \"Success\"\n}\n"}]},{"id":"payment-methods","title":"Payment Methods","description":"Master metode pembayaran shared platform. Sumber data:\n- **Online**: di-seed dari **payment-service** (provider, channel, subchannel). Hasil seed misalnya `Midtrans/qris/-`, `Xendit/bank_transfer/bca`, dst. Wacca tidak ngatur metode online secara langsung — admin platform yang mengelola lewat payment-service.\n- **Offline** (internal wacca): `cash` (kasir) dan `cod` (Cash on Delivery), di-seed lewat migration `20260609120200`.\n\nField penting:\n- `code` — string kunci (mis. `qris`, `bank_transfer`, `cash`, `cod`). **Tidak unik** karena banyak provider bisa berbagi code yang sama (mis. semua QRIS aggregator pakai `qris`). FE pakai `code` untuk grouping di UI dropdown.\n- `is_offline` — `true` untuk `cash`/`cod`; metode offline tidak butuh redirect/webhook payment-service.\n- `is_active` — toggle visibility di dropdown checkout.\n\n**Permission**:\n- List = open ke semua JWT authenticated (dipakai checkout customer dan kasir tenant).\n- Create + Update = **admin platform only**, gate `wacca-admin:*`. Tenant biasa tidak boleh add/edit master.\n\n**FK orders.payment_method_id** (migration `20260610001347`): tiap order menunjuk satu payment_method. payment_method soft-delete (row tetap ada saat `deleted_at` di-set) sehingga referensi historis order ke metode yang sudah dinonaktifkan tetap valid.\n\n**Backfill historis** (migration `20260609120300`): `orders.payment_method` lama (kolom code string) di-backfill ke `payment_method_id` dengan subquery deterministik (`order by is_active desc, created_at, id`). Bila satu code (mis. `qris`) punya banyak provider, pemetaan ke \"provider yang tepat\" **tidak bisa direkonstruksi** dari data order — hanya konsisten, bukan akurat.\n","endpoints":[{"name":"List payment methods","method":"GET","path":"/api/v1/payment-methods","auth":"JWT Bearer","description":"List metode pembayaran. Tanpa filter default — FE filter sendiri (mis. tampilkan `is_offline=true` di kasir, `is_active=true \u0026 is_offline=false` di checkout customer).\n","example_response":"{\n  \"success\": true,\n  \"data\": [\n    { \"id\": \"01923c8b-...-pm-cash\", \"name\": \"Cash\",       \"code\": \"cash\",          \"provider\": \"wacca\",   \"channel\": \"cash\",          \"is_offline\": true,  \"is_active\": true },\n    { \"id\": \"01923c8b-...-pm-cod\",  \"name\": \"COD\",        \"code\": \"cod\",           \"provider\": \"wacca\",   \"channel\": \"cod\",           \"is_offline\": true,  \"is_active\": true },\n    { \"id\": \"01923c8b-...-pm-qris\", \"name\": \"QRIS\",       \"code\": \"qris\",          \"provider\": \"Midtrans\",\"channel\": \"qris\",          \"is_offline\": false, \"is_active\": true },\n    { \"id\": \"01923c8b-...-pm-bca\",  \"name\": \"BCA VA\",     \"code\": \"bank_transfer\", \"provider\": \"Midtrans\",\"channel\": \"bank_transfer\", \"subchannel\": \"bca\", \"is_offline\": false, \"is_active\": true }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"Create payment method (admin)","method":"POST","path":"/api/v1/payment-methods","auth":"JWT Bearer","permission":"wacca-admin:*","description":"Tambah metode baru. Biasanya tidak dipanggil manual — payment-service melakukan ini lewat `EnsureSeeded` saat startup. Disediakan untuk operator kasus khusus (mis. add metode offline custom).\n\n`code` di-set server-side berdasar `channel` (kecuali offline).\n","example_body":"{\n  \"name\": \"OVO\",\n  \"provider\": \"Midtrans\",\n  \"channel\": \"ovo\"\n}\n"},{"name":"Update payment method (admin, partial)","method":"PUT","path":"/api/v1/payment-methods/{id}","auth":"JWT Bearer","permission":"wacca-admin:*","description":"Partial update. Field editable: `name`, `description`, `is_active`. Code/provider/channel **tidak** editable (ubah identity → buat baru).\n","example_body":"{ \"is_active\": false }\n"}]},{"id":"payments-revenue","title":"Payments \u0026 Revenue","description":"Integrasi mesin-ke-mesin. **payment-service** adalah source-of-truth transaksi uang; wacca menerima event lewat webhook lalu menyimpan agregat pendapatan per outlet (`outlet_revenue`), diproyeksikan dari inbox append-only `payment_events`.\n\nEndpoint di sini **bukan** JWT — diverifikasi shared-secret. Kalau secret/key belum di-set di env, endpoint balas `503` (dinonaktifkan, fail-safe).\n","endpoints":[{"name":"Payment webhook","method":"POST","path":"/api/v1/payments/webhook","auth":"HMAC (header X-Payment-Signature)","description":"Dipanggil payment-service saat status pembayaran berubah. Body di-verifikasi HMAC-SHA256 (hex) atas raw body memakai `PAYMENT_WEBHOOK_SECRET`.\n\n**Idempoten** by `event_id` — event yang sama dikirim ulang tidak diproses dua kali. Event disimpan mentah di `payment_events`, lalu diproyeksikan ke `outlet_revenue`:\n- `payment.paid` → baris revenue baru (channel `digital`, settlement `pending`); `net = amount − pajak order`.\n- `payment.settled` → baris di-update `settled` (cair).\n- `payment.refunded` → baris dinolkan \u0026 ditandai `refunded`.\n- `payment.failed` → hanya dicatat di `payment_events`, tanpa proyeksi.\n\nPajak diambil dari snapshot di order (tidak dihitung ulang) → konsisten dengan tagihan final.\n\nRespons `status`: `processed` | `duplicate` | `recorded_no_order` (event tersimpan tapi order tak ditemukan → proyeksi ditunda).\n","example_body":"{\n  \"event_id\": \"evt_01J9XYZ\",\n  \"event\": \"payment.paid\",\n  \"payment_ref\": \"pay_abc123\",\n  \"order_id\": \"01923c8b-1234-7000-a000-0000000000aa\",\n  \"amount\": 96570,\n  \"method\": \"qris\",\n  \"channel\": \"digital\",\n  \"paid_at\": \"2026-05-28T10:00:00Z\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"event_id\": \"evt_01J9XYZ\", \"status\": \"processed\", \"projected\": true },\n  \"message\": \"ok\"\n}\n"}]},{"id":"admin-order-ops","title":"Admin — Order Ops (cron)","description":"Endpoint internal untuk cron eksternal. Diverifikasi `ADMIN_API_KEY` lewat header `X-Internal-Key` (bukan JWT). `503` kalau key belum di-set.\n","endpoints":[{"name":"Auto-advance orders","method":"POST","path":"/api/v1/admin/orders/auto-advance","auth":"Internal key (header X-Internal-Key)","description":"Memajukan order yang \"macet\" melewati ambang waktu, secara batch. Dirancang untuk dipanggil cron.\n\n`action`:\n- `approve_expired_price` — order `price_proposed` yang tak direspons customer → `processing`.\n- `complete_expired_delivery` — order `ready_pickup`/`delivering` lewat ambang → `completed`.\n\nFilter (semua opsional): `older_than_hours` (default 24), `outlet_ids`, `order_ids`, `dry_run` (preview tanpa eksekusi). Respons mencantumkan setiap transisi (`items`).\n","example_body":"{\n  \"action\": \"approve_expired_price\",\n  \"older_than_hours\": 24,\n  \"outlet_ids\": [],\n  \"order_ids\": [],\n  \"dry_run\": true\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"action\": \"approve_expired_price\",\n    \"dry_run\": true,\n    \"matched\": 1,\n    \"advanced\": 0,\n    \"items\": [\n      { \"order_id\": \"01923c8b-1234-7000-a000-0000000000aa\", \"from\": \"price_proposed\", \"to\": \"processing\" }\n    ]\n  },\n  \"message\": \"success\"\n}\n"}]},{"id":"service-prices","title":"Service Prices","description":"Harga per-outlet untuk **service** (item type=service). Polymorphic ke tabel `price`; use case resolve `outlet_service_id` → item → outlet, lalu simpan dengan `tags=\"by-outlet\"`.\n\nTidak ada concept \"harga global\" di endpoint ini — selalu per-outlet. Untuk price list / katalog cross-outlet, kombinasikan query side-by-side dari sisi caller.\n","endpoints":[{"name":"List service prices","method":"GET","path":"/api/v1/tenant/outlet/service-prices","auth":"JWT Bearer","description":"List harga layanan milik tenant. Filter via query.","query_params":[{"name":"page","type":"int"},{"name":"limit","type":"int"},{"name":"outlet_service_id","type":"string","description":"Filter by item id (service)."},{"name":"tags","type":"string"},{"name":"is_active","type":"boolean"}]},{"name":"Create service price","method":"POST","path":"/api/v1/tenant/outlet/service-prices","auth":"JWT Bearer","description":"Tambah harga untuk satu service-item. `outlet_service_id` harus refer ke item dengan `type=\"service\"`. Use case resolve outlet otomatis dari item.\n","example_body":"{\n  \"outlet_service_id\": \"01923c8b-1234-7000-a000-000000000400\",\n  \"name\": \"Cuci Kering Reguler\",\n  \"description\": \"1-2 hari\",\n  \"unit_of_measure\": \"kg\",\n  \"rate\": 8000\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-000000000500\",\n    \"tenant_id\": \"01923c8b-1234-7000-a000-000000000001\",\n    \"item_id\": \"01923c8b-1234-7000-a000-000000000400\",\n    \"name\": \"Cuci Kering Reguler\",\n    \"description\": \"1-2 hari\",\n    \"unit_of_measure\": \"kg\",\n    \"rate\": 8000,\n    \"type\": \"service\",\n    \"tags\": \"by-outlet\",\n    \"is_active\": true,\n    \"created_at\": \"2026-05-19T10:00:00Z\",\n    \"updated_at\": \"2026-05-19T10:00:00Z\"\n  },\n  \"message\": \"created successfully\"\n}\n"},{"name":"Get service price","method":"GET","path":"/api/v1/tenant/outlet/service-prices/{price-id}","auth":"JWT Bearer","description":"Detail satu harga service."},{"name":"Update service price","method":"PUT","path":"/api/v1/tenant/outlet/service-prices/{price-id}","auth":"JWT Bearer","description":"Partial update.","example_body":"{\n  \"rate\": 9000,\n  \"description\": \"1 hari\"\n}\n"},{"name":"Delete service price","method":"DELETE","path":"/api/v1/tenant/outlet/service-prices/{price-id}","auth":"JWT Bearer","description":"Soft delete harga."}]},{"id":"product-prices","title":"Product Prices","description":"Harga per-outlet untuk **product** (item type=product). Sama strukturnya dengan service-prices tapi field domain bernama `outlet_product_id`.\n","endpoints":[{"name":"List product prices","method":"GET","path":"/api/v1/tenant/outlet/product-prices","auth":"JWT Bearer","description":"List harga produk milik tenant.","query_params":[{"name":"page","type":"int"},{"name":"limit","type":"int"},{"name":"outlet_product_id","type":"string"},{"name":"tags","type":"string"},{"name":"is_active","type":"boolean"}]},{"name":"Create product price","method":"POST","path":"/api/v1/tenant/outlet/product-prices","auth":"JWT Bearer","description":"Tambah harga product. `outlet_product_id` harus refer ke item `type=\"product\"`.","example_body":"{\n  \"outlet_product_id\": \"01923c8b-1234-7000-a000-000000000410\",\n  \"name\": \"Pewangi Lavender 500ml\",\n  \"unit_of_measure\": \"botol\",\n  \"rate\": 25000\n}\n"},{"name":"Get product price","method":"GET","path":"/api/v1/tenant/outlet/product-prices/{price-id}","auth":"JWT Bearer","description":"Detail satu harga produk."},{"name":"Update product price","method":"PUT","path":"/api/v1/tenant/outlet/product-prices/{price-id}","auth":"JWT Bearer","description":"Partial update."},{"name":"Delete product price","method":"DELETE","path":"/api/v1/tenant/outlet/product-prices/{price-id}","auth":"JWT Bearer","description":"Soft delete."}]},{"id":"driver-prices","title":"Driver Prices","description":"Harga per-outlet untuk **driver** (item type=driver). Ongkos antar-jemput. Field domain bernama `outlet_driver_id`.\n\nUse case ongkir umumnya pakai `rate` per trip atau per km — bebas; service tidak validate satuan.\n","endpoints":[{"name":"List driver prices","method":"GET","path":"/api/v1/tenant/outlet/driver-prices","auth":"JWT Bearer","description":"List harga driver milik tenant.","query_params":[{"name":"page","type":"int"},{"name":"limit","type":"int"},{"name":"outlet_driver_id","type":"string"},{"name":"tags","type":"string"},{"name":"is_active","type":"boolean"}]},{"name":"Create driver price","method":"POST","path":"/api/v1/tenant/outlet/driver-prices","auth":"JWT Bearer","description":"Tambah harga driver. `outlet_driver_id` harus refer ke item `type=\"driver\"`.","example_body":"{\n  \"outlet_driver_id\": \"01923c8b-1234-7000-a000-000000000420\",\n  \"name\": \"Antar Reguler 0-5km\",\n  \"unit_of_measure\": \"trip\",\n  \"rate\": 10000\n}\n"},{"name":"Get driver price","method":"GET","path":"/api/v1/tenant/outlet/driver-prices/{price-id}","auth":"JWT Bearer","description":"Detail satu harga driver."},{"name":"Update driver price","method":"PUT","path":"/api/v1/tenant/outlet/driver-prices/{price-id}","auth":"JWT Bearer","description":"Partial update."},{"name":"Delete driver price","method":"DELETE","path":"/api/v1/tenant/outlet/driver-prices/{price-id}","auth":"JWT Bearer","description":"Soft delete."}]},{"id":"register","title":"One-shot Signup (Register)","description":"Endpoint **public** (tanpa JWT required) untuk signup customer/tenant baru. Satu request: server akan (a) bikin account di account-service, (b) provision profile lokal di wacca, (c) kembalikan token pair siap pakai — app langsung logged-in.\n\n### Dua skenario auth\n\n1. **Email/Password** (default) — `email`+`password`+`display_name` wajib. Wacca pakai `account_service_client` untuk RegisterUser (platform `wacca-mobile` untuk customer, `wacca-admin` untuk tenant) → dapat `account_id` + token pair → provision profile lokal.\n\n2. **Google OAuth preauth** — kalau client sudah login via Google (di account-service sebelumnya, dapat Google JWT), kirim token Google itu di `Authorization: Bearer \u003cgoogle_token\u003e`. `OptionalJWTAuth` middleware verify token; kalau valid, `account_id` (claim `sub`) dipakai langsung — **skip** RegisterUser ke account-service (account sudah ada upstream). Email/Password/DisplayName **tidak diperlukan**. Response `access_token`/`refresh_token`/`expires_in` kosong (app pakai token Google yang sudah ada).\n\nField opsional `timezone` (mis. `Asia/Jakarta`) + `language` (`id`/`en`) di-forward ke account-service untuk lokalisasi notif email.\n","endpoints":[{"name":"Register customer","method":"POST","path":"/api/v1/register/customer","auth":"none","description":"Signup customer + provision profile lokal + (kalau email/password path) langsung dapat token. Tidak ada idempotency check — `CreateProfile` aman dipanggil ulang (ensure default address).\n\n**Path Google preauth**: kirim Google JWT di `Authorization: Bearer \u003ctoken\u003e`. Middleware `OptionalJWTAuth` verify → claim `sub` jadi `PreauthAccountID`. Body `email`/`password`/`display_name` **diabaikan** di path ini.\n\nField opsional yang dipersist: `address`/`location`/`latitude`/`longitude` → default address customer; `mobile_number` → `customer_profile.mobile_phone` (auto-create row customer_profile, dipakai owner outlet untuk hubungi customer saat antar/jemput).\n\n**Errors**:\n- **400 `email is required` / `password is required` / `display_name is required`** — path email/password only.\n- **400 `email already exists`** — account-service balas 409 (email duplikat upstream).\n- **400 `invalid account`** — `PreauthAccountID` (kalau ada) bukan UUID valid.\n- **500 `account registrar not configured`** — `account_service_client` belum di-config (server bug).\n- **502** — account-service unreachable atau response invalid (`missing user id` / `invalid user id`).\n- **401/503** dari middleware kalau Bearer token dikirim tapi invalid (401) atau key store kosong (503).\n","example_body":"{\n  \"email\": \"siti@example.com\",\n  \"password\": \"secret-pass-123\",\n  \"display_name\": \"Siti Nurhaliza\",\n  \"timezone\": \"Asia/Jakarta\",\n  \"language\": \"id\",\n  \"mobile_number\": \"+62811xxxxxxx\",\n  \"address\": \"Jl. Merdeka 10\",\n  \"location\": \"Depan Indomaret\",\n  \"latitude\": \"-6.175392\",\n  \"longitude\": \"106.827153\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"01923c8b-1234-7000-a000-000000000010\",\n    \"email\": \"siti@example.com\",\n    \"access_token\": \"eyJ...\",\n    \"refresh_token\": \"eyJ...\",\n    \"expires_in\": 900,\n    \"profile\": {\n      \"id\": \"01923c8b-1234-7000-a000-000000000010\",\n      \"organization_id\": \"0db3bcc1-8e2e-4519-9a41-d4f1d314136a\",\n      \"name\": \"Siti Nurhaliza\",\n      \"address\": \"Jl. Merdeka 10\",\n      \"location\": \"Depan Indomaret\",\n      \"latitude\": \"-6.175392\",\n      \"longitude\": \"106.827153\",\n      \"is_active\": true\n    }\n  },\n  \"message\": \"customer registered successfully\"\n}\n"},{"name":"Register tenant","method":"POST","path":"/api/v1/register/tenant","auth":"none","description":"Signup tenant owner + provision tenant + bikin **business org** di account-service + langsung dapat token. 3 step server-side: (1) bikin/verify account, (2) mint business org via `account_service_client.CreateOrganization`, (3) provision local `tenant_detail` + admin member.\n\n**Token \u0026 org switch (kritis)**: token yang dikembalikan masih ber-`org_id` = **personal org** akun (bukan business org tenant). App **wajib** switch ke field `org_id` di response (= business org baru) lewat `POST /organizations/{org-id}/switch` di account-service sebelum panggil tenant-scoped endpoint.\n\n**Path Google preauth**: kirim Bearer token di header. Middleware verify → extract `sub` (account_id) **dan** raw access token. Server pakai token itu untuk panggil CreateOrganization atas nama user. Email/password/display_name body **diabaikan** di path ini. `business_name` tetap wajib.\n\n**Errors**:\n- **400 `business_name is required`** — selalu wajib.\n- **400 `email is required` / `password is required` / `display_name is required`** — path email/password only.\n- **400 `email already exists`** — account-service balas 409.\n- **409 `sudah terdaftar sebagai tenant`** — idempotency guard: account ini (PreauthAccountID ATAU baru saja dibuat) sudah punya tenant. Refuse mint org kedua. Token Google bisa di-replay; email/password retry juga land sini.\n- **500** — `account_service_client` tidak ter-config.\n- **502** — account-service unreachable / response invalid / `CreateOrganization` gagal.\n","example_body":"{\n  \"email\": \"budi@laundry.id\",\n  \"password\": \"secret-pass-123\",\n  \"display_name\": \"Budi Santoso\",\n  \"timezone\": \"Asia/Jakarta\",\n  \"language\": \"id\",\n  \"business_name\": \"Bersih Cepat Laundry\",\n  \"nationality_number\": \"3201xxxxxxxxxxxx\",\n  \"mobile_number\": \"+62812xxxxxxx\",\n  \"description\": \"Cabang flagship\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"01923c8b-1234-7000-a000-000000000020\",\n    \"org_id\": \"01923c8b-1234-7000-a000-000000000099\",\n    \"email\": \"budi@laundry.id\",\n    \"access_token\": \"eyJ...\",\n    \"refresh_token\": \"eyJ...\",\n    \"expires_in\": 900,\n    \"tenant\": {\n      \"id\": \"01923c8b-1234-7000-a000-000000000001\",\n      \"organization_id\": \"01923c8b-1234-7000-a000-000000000099\",\n      \"business_name\": \"Bersih Cepat Laundry\",\n      \"owner_name\": \"Budi Santoso\",\n      \"nationality_number\": \"3201xxxxxxxxxxxx\",\n      \"mobile_number\": \"+62812xxxxxxx\",\n      \"is_active\": true\n    }\n  },\n  \"message\": \"tenant registered successfully\"\n}\n"}]},{"id":"service-setups","title":"Service Setups (item + price dalam 1 call)","description":"Endpoint convenience: buat **service-item** sekaligus dengan **price pair**-nya (By Outlet + By Platform) dalam satu request. Cocok untuk wizard onboarding outlet di UI di mana tenant ingin daftar layanan baru tanpa multi-step.\n\nInternally memanggil `CreateService` lalu `CreateServicePrice` (yang sendiri sudah menyimpan dua record price dalam satu DB transaction). Kalau create price gagal setelah service berhasil dibuat, service akan **di-rollback via compensating delete** sehingga tenant tidak meninggalkan service tanpa harga.\n\nTidak ada GET/UPDATE/DELETE di sini — gunakan endpoint `outlet-services` dan `service-prices` masing-masing setelah setup.\n","endpoints":[{"name":"Create service setup","method":"POST","path":"/api/v1/tenant/outlet/service-setups","auth":"JWT Bearer","description":"Buat service item + price pair dalam 1 request. `rate` mengisi record By Outlet; record By Platform dihitung otomatis dari setting platform percent/cap.\n","example_body":"{\n  \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n  \"name\": \"Cuci Kering Reguler\",\n  \"description\": \"1-2 hari\",\n  \"unit_of_measure\": \"kg\",\n  \"tags\": \"standard,reguler\",\n  \"rate\": 8000\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"service\": {\n      \"id\": \"01923c8b-1234-7000-a000-000000000400\",\n      \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n      \"type\": \"service\",\n      \"name\": \"Cuci Kering Reguler\",\n      \"description\": \"1-2 hari\",\n      \"unit_of_measure\": \"kg\",\n      \"tags\": \"standard,reguler\",\n      \"is_active\": true,\n      \"created_at\": \"2026-05-21T10:00:00Z\",\n      \"updated_at\": \"2026-05-21T10:00:00Z\"\n    },\n    \"price\": {\n      \"id\": \"01923c8b-1234-7000-a000-000000000500\",\n      \"outlet_service_id\": \"01923c8b-1234-7000-a000-000000000400\",\n      \"name\": \"Cuci Kering Reguler\",\n      \"description\": \"1-2 hari\",\n      \"unit_of_measure\": \"kg\",\n      \"rate\": 8000,\n      \"is_active\": true,\n      \"created_at\": \"2026-05-21T10:00:00Z\",\n      \"updated_at\": \"2026-05-21T10:00:00Z\"\n    }\n  },\n  \"message\": \"service setup created successfully\"\n}\n"},{"name":"Get service setup","method":"GET","path":"/api/v1/tenant/outlet/service-setups/{id}","auth":"JWT Bearer","description":"Detail service-setup record by id (jejak setup yang pernah dibuat)."},{"name":"Update service setup","method":"PUT","path":"/api/v1/tenant/outlet/service-setups/{id}","auth":"JWT Bearer","description":"Update setup record. Partial — kirim hanya field yang berubah pada item / price pair-nya.\n"},{"name":"Delete service setup","method":"DELETE","path":"/api/v1/tenant/outlet/service-setups/{id}","auth":"JWT Bearer","description":"Soft-delete setup record (item + harga turunannya tetap di tabel masing-masing)."}]},{"id":"product-setups","title":"Product Setups (item + price dalam 1 call)","description":"Sama dengan service-setups tapi untuk **product-item**. Buat product + price pair (By Outlet + By Platform) sekaligus, dengan compensating delete kalau price gagal.\n","endpoints":[{"name":"Create product setup","method":"POST","path":"/api/v1/tenant/outlet/product-setups","auth":"JWT Bearer","description":"Buat product item + price pair dalam 1 request.","example_body":"{\n  \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n  \"name\": \"Pewangi Lavender 500ml\",\n  \"description\": \"Botol semprot\",\n  \"unit_of_measure\": \"botol\",\n  \"tags\": \"fragrance\",\n  \"rate\": 25000\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"product\": {\n      \"id\": \"01923c8b-1234-7000-a000-000000000410\",\n      \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n      \"type\": \"product\",\n      \"name\": \"Pewangi Lavender 500ml\",\n      \"unit_of_measure\": \"botol\",\n      \"tags\": \"fragrance\",\n      \"is_active\": true,\n      \"created_at\": \"2026-05-21T10:00:00Z\",\n      \"updated_at\": \"2026-05-21T10:00:00Z\"\n    },\n    \"price\": {\n      \"id\": \"01923c8b-1234-7000-a000-000000000510\",\n      \"outlet_product_id\": \"01923c8b-1234-7000-a000-000000000410\",\n      \"name\": \"Pewangi Lavender 500ml\",\n      \"unit_of_measure\": \"botol\",\n      \"rate\": 25000,\n      \"is_active\": true,\n      \"created_at\": \"2026-05-21T10:00:00Z\",\n      \"updated_at\": \"2026-05-21T10:00:00Z\"\n    }\n  },\n  \"message\": \"product setup created successfully\"\n}\n"}]},{"id":"driver-setups","title":"Driver Setups (item + price dalam 1 call)","description":"Sama dengan service-setups tapi untuk **driver-item**. Buat driver + price pair (By Outlet + By Platform) sekaligus, dengan compensating delete kalau price gagal.\n","endpoints":[{"name":"Create driver setup","method":"POST","path":"/api/v1/tenant/outlet/driver-setups","auth":"JWT Bearer","description":"Buat driver item + price pair dalam 1 request.","example_body":"{\n  \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n  \"name\": \"Antar Reguler 0-5km\",\n  \"description\": \"Motor matic\",\n  \"unit_of_measure\": \"trip\",\n  \"tags\": \"motor,city\",\n  \"rate\": 10000\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"driver\": {\n      \"id\": \"01923c8b-1234-7000-a000-000000000420\",\n      \"outlet_id\": \"01923c8b-1234-7000-a000-000000000100\",\n      \"type\": \"driver\",\n      \"name\": \"Antar Reguler 0-5km\",\n      \"unit_of_measure\": \"trip\",\n      \"tags\": \"motor,city\",\n      \"is_active\": true,\n      \"created_at\": \"2026-05-21T10:00:00Z\",\n      \"updated_at\": \"2026-05-21T10:00:00Z\"\n    },\n    \"price\": {\n      \"id\": \"01923c8b-1234-7000-a000-000000000520\",\n      \"outlet_driver_id\": \"01923c8b-1234-7000-a000-000000000420\",\n      \"name\": \"Antar Reguler 0-5km\",\n      \"unit_of_measure\": \"trip\",\n      \"rate\": 10000,\n      \"is_active\": true,\n      \"created_at\": \"2026-05-21T10:00:00Z\",\n      \"updated_at\": \"2026-05-21T10:00:00Z\"\n    }\n  },\n  \"message\": \"driver setup created successfully\"\n}\n"}]},{"id":"tenant-summary","title":"Tenant Summary","description":"Ringkasan agregat untuk dashboard tenant. Endpoint individual mengembalikan masing-masing dimensi (outlet counts, income, orders), sedangkan endpoint combined menggabungkan ketiganya — cocok dipakai sebagai single call dashboard.\n\nUntuk ringkasan per-outlet (bukan tenant-level) gunakan `GET /tenant/outlet/{outlet-id}/summary` di section Outlet.\n","endpoints":[{"name":"Outlet summary (tenant-level)","method":"GET","path":"/api/v1/tenant/summary/outlet","auth":"JWT Bearer","description":"Total outlet, jumlah outlet aktif, dan non-aktif untuk tenant saat ini.","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"total_outlet\": 5,\n    \"outlet_active\": 4,\n    \"outlet_non_active\": 1\n  },\n  \"message\": \"Success\"\n}\n"},{"name":"Income summary","method":"GET","path":"/api/v1/tenant/summary/income","auth":"JWT Bearer","description":"Pendapatan **riil** tenant, dihitung dari `outlet_revenue` (uang yang benar-benar masuk lewat pembayaran digital/COD) — bukan lagi penjumlahan estimasi semua order.\n\n- `total_income` — net revenue (settled + pending) setelah PPN dipotong.\n- `settled_income` — net yang sudah cair/lunas.\n- `pending_income` — net digital menunggu settlement (cair H+1).\n- `cash_income` / `digital_income` — pecahan net per kanal.\n- `gross_income` — total dibayar customer (termasuk PPN).\n- `tax_collected` — total PPN pass-through (bukan pendapatan outlet).\n- `total_debit` — `0`, placeholder untuk feature withdrawal/billing.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"total_debit\": 0,\n    \"total_income\": 11250000,\n    \"settled_income\": 9800000,\n    \"pending_income\": 1450000,\n    \"cash_income\": 4200000,\n    \"digital_income\": 7050000,\n    \"gross_income\": 12487500,\n    \"tax_collected\": 1237500\n  },\n  \"message\": \"Success\"\n}\n"},{"name":"Orders summary","method":"GET","path":"/api/v1/tenant/summary/orders","auth":"JWT Bearer","description":"Total jumlah order tenant.","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"total_orders\": 1234\n  },\n  \"message\": \"Success\"\n}\n"},{"name":"Combined summary","method":"GET","path":"/api/v1/tenant/summary","auth":"JWT Bearer","description":"Single-call dashboard — gabungan outlet + income + orders. Hemat round-trip dibanding hit tiga endpoint terpisah.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"total_debit\": 0,\n    \"total_orders\": 1234,\n    \"total_income\": 12500000,\n    \"outlet\": {\n      \"total_outlet\": 5,\n      \"outlet_active\": 4,\n      \"outlet_non_active\": 1\n    }\n  },\n  \"message\": \"Success\"\n}\n"}]},{"id":"tenant-cod-commission","title":"Tenant — COD Commission Debt","description":"Subsystem hutang komisi platform atas order COD (cash-on-delivery). **R-PAYMENT.03/.18**: setiap confirm-COD (online) atau walk-in COD → server accrue entry `commission_debt` per order (idempoten pada `cod-\u003corder_id\u003e`). Saldo debt nempel di tenant.\n\n**Gate baru COD**: kalau saldo hutang aktif \u003e 0, owner **tidak boleh** terima/create order COD baru sampai dibayar lewat `/cod-debt/repay`. Cash flow lain (qris/bank_transfer) tidak terdampak.\n","endpoints":[{"name":"Get COD debt status","method":"GET","path":"/api/v1/tenant/cod-debt","auth":"JWT Bearer","description":"Status hutang COD tenant: `outstanding` (saldo aktif) + `threshold` (batas yang ditentukan billing_mode) + `can_accept_cod` (gating flag) + `billing_mode` (skema komisi yang berlaku).\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"tenant_id\": \"01923c8b-1234-7000-a000-000000000001\",\n    \"outstanding\": 45000,\n    \"billing_mode\": \"per_order\",\n    \"threshold\": 50000,\n    \"can_accept_cod\": true\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Repay COD debt","method":"POST","path":"/api/v1/tenant/cod-debt/repay","auth":"JWT Bearer","description":"Catat pembayaran (full / parsial) terhadap saldo. Setelah saldo turun di bawah threshold, `can_accept_cod` jadi `true` lagi. Response = status terbaru.\n","example_body":"{\n  \"amount\": 25000,\n  \"note\": \"TRX-20260601-001 transfer bank\"\n}\n"}]},{"id":"tenant-walk-in","title":"Tenant — Walk-in (Kasir)","description":"Endpoint owner/kasir untuk buat order **walk-in** — customer datang langsung tanpa akun Wacca, dicatat sebagai **guest**. Order disimpan dengan `customer_id = NULL` + `guest_name` + `guest_phone`.\n\nWalk-in COD ikut akrual komisi (lihat [Tenant — COD Commission Debt](#tenant-cod-commission)).\n","endpoints":[{"name":"Create walk-in order","method":"POST","path":"/api/v1/tenant/walk-in","auth":"JWT Bearer","description":"Buat order walk-in untuk customer guest. Owner langsung set service + final quantity → server hitung amount + accrue COD debt (kalau payment_method=cod) + generate receipt.\n\n**Idempotency** — FE wajib generate `idempotency_key` per submit (UUID/v7 atau ULID), kirim di body. Server simpan key di `orders.idempotency_key` dengan partial unique `WHERE deleted_at IS NULL` — submit yang sama dua kali (mis. double-tap, retry network) → server return order yang sudah dibuat sebelumnya (200 OK, tidak duplicate). Tanpa key, dua submit cepat berturutan bisa bikin order ganda.\n\n**`payment_method_id`** sekarang diterima (UUID dari [Payment Methods](#payment-methods)) — lebih disukai dari `payment_method` legacy string. Untuk walk-in, hanya boleh metode `is_offline=true` (cash, cod). Server tolak metode online → 400.\n\n**403 cod-gated** kalau payment_method=cod dan tenant punya saldo hutang COD aktif (lihat `/cod-debt`).\n","example_body":"{\n  \"idempotency_key\": \"01923c8b-7777-7000-aaaa-1234567890ab\",\n  \"outlet_id\": \"01923c8b-1234-7000-a000-000000000004\",\n  \"guest_name\": \"Bu Ani\",\n  \"guest_phone\": \"08123456789\",\n  \"payment_method_id\": \"01923c8b-...-pm-cod\",\n  \"tax_code\": \"PPN11\",\n  \"discount\": 5000,\n  \"items\": [\n    { \"item_id\": \"01923c8b-...-svc1\", \"quantity\": 2.5, \"unit_of_measure\": \"kg\" },\n    { \"item_id\": \"01923c8b-...-prd1\", \"quantity\": 1, \"unit_of_measure\": \"pcs\" }\n  ],\n  \"note\": \"Cuci express selesai sore ini\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-0000000000w1\",\n    \"order_number\": \"WCC-260601-W01\",\n    \"outlet_id\": \"01923c8b-1234-7000-a000-000000000004\",\n    \"customer_id\": null,\n    \"guest_name\": \"Bu Ani\",\n    \"guest_phone\": \"08123456789\",\n    \"payment_method\": \"cod\",\n    \"final_total\": 32500,\n    \"status\": \"processing\",\n    \"receipt_url\": \"/receipts/WCC-260601-W01.pdf\"\n  },\n  \"message\": \"walk-in order created\"\n}\n"},{"name":"Link walk-in order to registered account","method":"POST","path":"/api/v1/tenant/walk-in/{id}/link-account","auth":"JWT Bearer","description":"**Issue #43 GAP I** — owner kaitkan order walk-in guest ke akun customer terdaftar (mis. customer baru daftar setelah selesai cuci pertama → owner link supaya order masuk riwayatnya). Set `orders.customer_id = \u003ccustomer_id\u003e`; data guest (`guest_name`, `guest_phone`) tetap disimpan untuk audit.\n\n**Guard**:\n- **400** kalau order bukan walk-in (tidak ada `guest_name`).\n- **400** kalau order sudah ter-link (`customer_id != NULL`) — cegah owner menimpa akun lain.\n- **400** kalau `customer_id` tidak valid (UUID invalid atau bukan account aktif).\n- **404** kalau order tidak ditemukan / bukan milik tenant.\n\nResponse = receipt yang sama dengan create walk-in, tapi `customer_id` sudah terisi. Setelah link, semua endpoint `/customer/orders/{id}` bisa diakses oleh customer tsb (riwayat order, timeline, rating).\n","example_body":"{\n  \"customer_id\": \"01923c8b-1234-7000-a000-000000000010\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-0000000000w1\",\n    \"order_number\": \"WCC-260601-W01\",\n    \"customer_id\": \"01923c8b-1234-7000-a000-000000000010\",\n    \"guest_name\": \"Bu Ani\",\n    \"guest_phone\": \"08123456789\",\n    \"status\": \"processing\"\n  },\n  \"message\": \"account linked successfully\"\n}\n"}]},{"id":"tenant","title":"Tenant","description":"Profil bisnis laundry. 1 organisasi (JWT `org_id`) = 1 tenant. Auto-resolve dari claim — caller tidak supply `organization_id`. Setiap action (GET/POST/PUT) bekerja pada tenant milik org saat ini.\n\nWorkflow:\n1. Login → cek `GET /tenant` → 400 \"you are not registered as a tenant\" kalau belum di-bootstrap.\n2. `POST /tenant` dengan `business_name` → state berubah menjadi tenant aktif.\n3. `PUT /tenant` untuk update profil partial.\n","endpoints":[{"name":"Get my tenant","method":"GET","path":"/api/v1/tenant/profile","auth":"JWT Bearer","description":"Ambil profil tenant milik org saat ini. 400 kalau belum di-create.","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-000000000001\",\n    \"organization_id\": \"0db3bcc1-8e2e-4519-9a41-d4f1d314136a\",\n    \"business_name\": \"Bersih Cepat Laundry\",\n    \"owner_name\": \"Budi Santoso\",\n    \"nationality_number\": \"3201xxxxxxxxxxxx\",\n    \"mobile_number\": \"+62812xxxxxxx\",\n    \"is_active\": true,\n    \"created_at\": \"2026-05-19T10:00:00Z\",\n    \"updated_at\": \"2026-05-19T10:00:00Z\"\n  },\n  \"message\": \"success\",\n  \"request_id\": \"uuid\"\n}\n"},{"name":"Create tenant","method":"POST","path":"/api/v1/tenant/profile","auth":"JWT Bearer","description":"Bootstrap tenant pertama kali. `organization_id` otomatis dari JWT.\n\n**400 'your account already registered'** kalau tenant untuk org ini sudah ada (re-cek via GET /tenant dulu).\n","example_body":"{\n  \"business_name\": \"Bersih Cepat Laundry\",\n  \"owner_name\": \"Budi Santoso\",\n  \"nationality_number\": \"3201xxxxxxxxxxxx\",\n  \"mobile_number\": \"+62812xxxxxxx\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"...\", \"organization_id\": \"...\", \"business_name\": \"Bersih Cepat Laundry\", \"is_active\": true, ... },\n  \"message\": \"tenant created successfully\",\n  \"request_id\": \"uuid\"\n}\n"},{"name":"Update tenant","method":"PUT","path":"/api/v1/tenant/profile","auth":"JWT Bearer","description":"Partial update — kirim hanya field yang berubah. Field tidak dikirim = tetap.","example_body":"{\n  \"owner_name\": \"Budi Santoso S.E.\",\n  \"mobile_number\": \"+628999999999\"\n}\n"}]},{"id":"customer-profile","title":"Customer Profile","description":"Profil end-user laundry. **Customer = sebuah account account-service** (tidak ada tabel `customer` lagi). `customer_id` di seluruh sistem = `account_id` (JWT `sub`).\n\n**Sumber data per-field di response:**\n- `name` — `accounts.display_name` (event-synced dari account-service, **read-only** di wacca; field `name` di body ignored saat create/update).\n- `mobile_number` — `customer_profile.mobile_phone` (writable lokal — owner outlet baca ini saat antar/jemput). Auto-create row `customer_profile` saat pertama di-set; trim+empty di-skip.\n- `address`/`location`/`latitude`/`longitude` — `address` table (default address, `reference_type=customer`).\n- `profile_picture_id` — belum dipersist (returns empty/zero).\n\nWorkflow: panggil `POST /customer/profile` saat onboarding (idempotent — memastikan default address ada); selanjutnya `GET`/`PUT` sesuai kebutuhan.\n","endpoints":[{"name":"Get my profile","method":"GET","path":"/api/v1/customer/profile","auth":"JWT Bearer","description":"Ambil profil customer. **Selalu 200** — selama JWT valid, profil disintesis dari account_id walau mirror `accounts` belum sync (name kosong sampai event RabbitMQ datang).\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-000000000010\",\n    \"organization_id\": \"0db3bcc1-8e2e-4519-9a41-d4f1d314136a\",\n    \"name\": \"Siti Nurhaliza\",\n    \"profile_picture_id\": \"00000000-0000-0000-0000-000000000000\",\n    \"address\": \"Jl. Merdeka 10\",\n    \"location\": \"Jakarta Pusat\",\n    \"latitude\": \"-6.175392\",\n    \"longitude\": \"106.827153\",\n    \"mobile_number\": \"+62811xxxxxxx\",\n    \"is_active\": true,\n    \"created_at\": \"2026-05-19T10:00:00Z\",\n    \"updated_at\": \"2026-05-29T08:00:00Z\"\n  },\n  \"message\": \"success\",\n  \"request_id\": \"uuid\"\n}\n"},{"name":"Register profile (idempotent)","method":"POST","path":"/api/v1/customer/profile","auth":"JWT Bearer","description":"Onboarding customer — **idempotent**: menyimpan alamat sebagai default address, dan menyimpan `mobile_number` ke `customer_profile.mobile_phone` (kalau non-empty). `name` (= display_name) tetap **read-only** karena di-mirror dari account-service.\n\n**400 'name is required'** bila `name` kosong/whitespace.\n","example_body":"{\n  \"name\": \"Siti Nurhaliza\",\n  \"address\": \"Jl. Merdeka 10\",\n  \"location\": \"Jakarta Pusat\",\n  \"latitude\": \"-6.175392\",\n  \"longitude\": \"106.827153\",\n  \"mobile_number\": \"+62811xxxxxxx\"\n}\n"},{"name":"Update profile","method":"PUT","path":"/api/v1/customer/profile","auth":"JWT Bearer","description":"Partial update. **Writable**:\n- `address`/`location`/`latitude`/`longitude` → upsert ke default `address` row.\n- `mobile_number` → upsert ke `customer_profile.mobile_phone`. Empty/whitespace dilewati (gak hapus nilai existing — perlu endpoint khusus untuk clear).\n\n**Read-only (diterima tapi no-op)**: `name` (display_name dari accounts), `profile_picture_id`, `is_active`. Mutasi identity inti dilakukan di account-service.\n\nSelalu 200 (termasuk untuk update identitas-only).\n","example_body":"{\n  \"address\": \"Jl. Sudirman 5, Apt 12\",\n  \"latitude\": \"-6.208763\",\n  \"longitude\": \"106.845599\"\n}\n"}]},{"id":"location-lookup","title":"Location Lookup (Provinces / Cities / Subdistricts)","description":"Lookup data wilayah Indonesia untuk dropdown alamat — di-cascade: pilih **province** → load **cities** → pilih city → load **subdistricts**. Dipakai di form set-up outlet (lihat [Outlet](#outlet)) dan form customer address (lihat [Customer Address](#customer-address)).\n\nData statis (read-only). Tidak ada filter selain parent ID.\n\n**Dataset di-embed ke binary** (Kemendagri/BPS code via `//go:embed` di `data/local/`). Tidak lagi tergantung working directory atau file sidecar — list tidak akan kosong meski server jalan dari CWD berbeda. Parse failure ke-detect saat startup (panic), bukan diam-diam balas empty array.\n\nEndpoint authenticated (JWT) — sama gating dengan endpoint domain lain. ID adalah integer (bukan UUID) karena merefer ke nomor kode wilayah BPS.\n","endpoints":[{"name":"List provinces","method":"GET","path":"/api/v1/locations/provinces","auth":"JWT Bearer","description":"Semua provinsi (~38 baris). Dipakai untuk dropdown pertama.","example_response":"{\n  \"success\": true,\n  \"data\": [\n    { \"id\": 31, \"name\": \"DKI Jakarta\" },\n    { \"id\": 32, \"name\": \"Jawa Barat\" },\n    { \"id\": 33, \"name\": \"Jawa Tengah\" }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"List cities in province","method":"GET","path":"/api/v1/locations/provinces/{id}/cities","auth":"JWT Bearer","description":"Kota/kabupaten dalam satu provinsi (`{id}` = province_id).","example_response":"{\n  \"success\": true,\n  \"data\": [\n    { \"id\": 3171, \"province_id\": 31, \"name\": \"Kota Jakarta Pusat\" },\n    { \"id\": 3172, \"province_id\": 31, \"name\": \"Kota Jakarta Utara\" }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"List subdistricts in city","method":"GET","path":"/api/v1/locations/cities/{id}/subdistricts","auth":"JWT Bearer","description":"Kecamatan dalam satu kota/kabupaten (`{id}` = city_id).","example_response":"{\n  \"success\": true,\n  \"data\": [\n    { \"id\": 317101, \"city_id\": 3171, \"name\": \"Gambir\" },\n    { \"id\": 317102, \"city_id\": 3171, \"name\": \"Sawah Besar\" }\n  ],\n  \"message\": \"success\"\n}\n"}]},{"id":"tenant-member","title":"Tenant Member (Karyawan)","description":"Manajemen karyawan tenant: undang/daftarkan akun, beri **role**, dan\ntugaskan ke **outlet** tertentu. Dua tabel mendasarinya:\n\n- `tenant_member` — karyawan di bawah satu tenant, punya `role_id`.\n- `outlet_member` — junction yang menugaskan member ke outlet spesifik.\n\n**Model akses berbasis role:**\n- `admin` / `manager` → akses **implisit ke semua outlet** tenant; tidak\n  butuh baris `outlet_member` (`outlet_ids` diabaikan saat assign).\n- `staff` (operator/viewer) → akses **eksplisit**; harus punya baris\n  `outlet_member` per outlet yang ditugaskan.\n\n**Aturan kepemilikan (penting):** setiap `outlet_id` yang ditugaskan wajib\nmilik tenant pemanggil. Menugaskan member ke outlet milik tenant lain\n(atau outlet yang sudah dihapus) ditolak `400` — mencegah eskalasi hak.\nMenugaskan ke outlet milik sendiri yang sedang non-aktif tetap diizinkan\n(penugasan staf adalah pembukuan internal, lepas dari status buka/tutup).\n\nAkun login dikelola di **account-service**; endpoint di sini memetakan\nakun tersebut ke tenant + outlet. Role yang valid diambil dari\n`GET /api/v1/tenant/roles`.\n\n**Identitas (`display_name`/`email`) bersumber dari mirror `accounts`**\n(event-synced dari account-service via RabbitMQ). Tidak lagi disimpan\nlokal di `tenant_member`. Saat Create/Invite/Register, server seed mirror\nsaat itu juga supaya list langsung punya nama; selanjutnya event accounts\nyang menjaga supaya tidak basi. Anggota lama yang event-nya belum tiba\nbisa muncul dengan `display_name`/`email` kosong sementara — bukan error.\n\n**Role lintas-tenant ditolak:** `role_id` yang `tenant_id`-nya milik\ntenant lain ditolak `400 invalid role_id` di Create / Invite / Register /\nUpdate. Role sistem (`tenant_id NULL`, mis. `admin`/`manager`/`staff`)\nshared dan tetap assignable oleh semua tenant.\n\n**Owner tenant disembunyikan dari list \u0026 summary.** Akun yang bootstrap\ntenant (`tenant_detail.owner_account_id`) bukan \"staff\", jadi:\n- `GET /api/v1/tenant/member` (list) — baris owner tidak muncul, **tidak\n  peduli query param** (`?account_id=\u003cowner\u003e` juga balas list kosong\n  karena exclude di-apply unconditional).\n- `GET /summary` — semua 3 badge (active/on_leave/inactive) tidak\n  menghitung owner. Headcount tenant aktif **turun by 1** dibanding\n  sebelum perubahan ini.\n\nUntuk resolve owner via REST: pakai `GET /api/v1/tenant/profile`\n(`owner_account_id` + `owner_name`) atau `GET /api/v1/tenant/member/{id}`\nkalau ID `tenant_member`-nya sudah diketahui.\n\n**Hak akses per role (RBAC):** role bukan cuma soal assignment outlet — ia\njuga menggate aksi di seluruh API tenant. Matrix lengkap, outlet-scope,\ndan rollout (`RBAC_ENFORCE`) ada di section **RBAC — Hak Akses Karyawan**.\n","endpoints":[{"name":"Headcount summary","method":"GET","path":"/api/v1/tenant/member/summary","auth":"JWT Bearer","description":"Header badges Manajemen Karyawan: total + breakdown per status (`active` / `on_leave` / `inactive` / `invited`). Tenant-scoped + RBAC outlet-scope (admin/manager lihat full, staff scoped ke outlet-nya).\n\nBadge **mutually-exclusive** — tiap member dihitung tepat satu kali. Sebelumnya `active` ikut menghitung member cuti (juga `is_active=true`); sekarang difilter by `status` agar tidak double-count.\n\n**`invited`** = jumlah undangan pending (tabel `member_invitations`, belum di-accept invitee) — bukan dari `tenant_member`. `total` di sini menghitung real members (active+on_leave+inactive); `invited` di-track terpisah supaya owner tahu berapa undangan menggantung.\n\n**Owner tenant tidak dihitung** di semua badge — owner = bootstrap akun, bukan staf.\n\n**Catatan `active` vs `is_active`**: `active` count di sini = jumlah status `active` saja (TIDAK termasuk yang `on_leave`). Tapi field `is_active` di tabel member-nya tetap `true` untuk `on_leave` (masih bisa login) — `total active+on_leave` = jumlah yang `is_active=true`.\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"total\": 8, \"active\": 6, \"on_leave\": 1, \"inactive\": 1, \"invited\": 2 },\n  \"message\": \"success\"\n}\n"},{"name":"Add staff (unified — assign / invite / register)","method":"POST","path":"/api/v1/tenant/member","auth":"JWT Bearer","description":"**Satu entrypoint** untuk menambah staf ke tenant + outlet. Field `mode` memilih cabang behavior. Sebelumnya 3 endpoint terpisah (`/assign-by-email`, `/invite-outlet-members`, `/register-outlet-members`) — sekarang digabung; endpoint lama **DIHAPUS** dan akan balas `404 page not found`. Client mobile/web wajib di-migrasi ke endpoint ini.\n\n**Identitas per mode:** `assign` \u0026 `invite` pakai **`user_id`** (account UUID,\ndari `GET /api/v1/accounts/search` di frontend). `register` pakai **`email`**\n(akun baru dibuat). Email **tidak lagi** diterima untuk assign/invite.\n\n**`mode`** (required, `oneof=assign invite register`):\n- **`assign`** — user **sudah** anggota organisasi. Identitas via **`user_id`**; server verifikasi membership by account_id (tanpa lookup email) lalu map ke tenant + outlet. **404** kalau user bukan member org; **400** kalau member sudah ada / outlet asing.\n- **`invite`** — identitas via **`user_id`**. Server **resolve email** dari user_id lewat **gRPC internal account-service** (`InternalAccountService.GetAccount`, system-to-system), lalu bikin **pending invitation** (`member_invitations`) + push notifikasi inbox ke akun invitee dengan 2 action button **Accept** / **Reject**. **Tidak** langsung bikin `tenant_member` — undangan harus diterima via `POST /api/v1/tenant/invitations/{id}/accept`. Unique per `(org_id, account_id)`; invite ke-2 → 409. Outlet + role lintas-tenant divalidasi sebelum invite disimpan. **503** kalau resolver gRPC tak terkonfigurasi.\n- **`register`** — user **belum punya akun** di mana pun → pakai **`email`**. Server bikin account baru via account-service + `tenant_member` + tugaskan outlet — 1 transaksi (rollback kalau gagal). Validasi outlet **sebelum** account dibuat (no orphan akun).\n  - `account.display_name` **required** untuk mode=register.\n  - `account.password` boleh kosong → server **auto-generate temp password**, kembalikan **sekali** di `data.temp_password` (owner wajib catat — tidak bisa di-recall).\n\n**Field per mode**:\n- **`user_id`** (uuid) — **WAJIB untuk `assign` \u0026 `invite`** (diabaikan untuk register).\n- **`email`** — **WAJIB untuk `register`** (diabaikan untuk assign/invite).\n- `mobile_number`, `profile_picture_id`, `role_id` (**optional**), `outlet_ids` (optional) — common ke semua mode.\n- `role_id` **boleh dikosongkan** / tidak dikirim → server default ke system role **`staff`** (privilege terendah, dipakai sebagai default onboarding \"tambah staf dulu, set role belakangan\"). Kalau dikirim, di-validasi: harus UUID role yang ada + (untuk role custom) milik tenant ini.\n- `outlet_ids` diabaikan untuk role admin/manager (akses implisit ke semua outlet, lihat [Update member](#tenant-member) untuk rule `outlet:all`).\n- **Role lintas-tenant ditolak**: `role_id` yang `tenant_id`-nya milik tenant lain → 400 `invalid role_id`. Role sistem (`tenant_id NULL`) shared.\n\n**Response shape** (`CreateMemberPresent`):\n- `assign` → `member` populated.\n- `invite` → `invitation_id` populated; **`member` tidak ada** sampai invitee accept. Pesan menjelaskan \"user needs to accept invitation\".\n- `register` → `user_id` + `member`; `temp_password` muncul **hanya** kalau server generate (password owner dikosongkan).\n","example_body":"# mode=assign — user sudah di org (pakai user_id dari /accounts/search)\n{\n  \"mode\": \"assign\",\n  \"user_id\": \"37d00210-91c7-40b6-ab71-122c334a45ea\",\n  \"role_id\": \"01900000-0000-7fff-8fff-000000000012\",\n  \"outlet_ids\": [\"01923c8b-aaaa-7000-a000-0000000000a4\"]\n}\n\n# mode=invite — kirim undangan (pakai user_id; email di-resolve server-side)\n{\n  \"mode\": \"invite\",\n  \"user_id\": \"37d00210-91c7-40b6-ab71-122c334a45ea\",\n  \"role_id\": \"01900000-0000-7fff-8fff-000000000012\",\n  \"mobile_number\": \"081298765432\",\n  \"outlet_ids\": [\"01923c8b-aaaa-7000-a000-0000000000a4\"]\n}\n\n# mode=register — bikin akun baru + temp password auto-generate\n{\n  \"mode\": \"register\",\n  \"email\": \"rina@laundry.id\",\n  \"role_id\": \"01900000-0000-7fff-8fff-000000000012\",\n  \"mobile_number\": \"081234567890\",\n  \"outlet_ids\": [\"01923c8b-aaaa-7000-a000-0000000000a4\"],\n  \"account\": {\n    \"display_name\": \"Rina Wijaya\",\n    \"password\": \"\",\n    \"timezone\": \"Asia/Jakarta\",\n    \"language\": \"id\"\n  }\n}\n\n# mode=register — minimal (role_id \u0026 outlet_ids di-omit; default ke role \"staff\", no outlet)\n{\n  \"mode\": \"register\",\n  \"email\": \"rina@laundry.id\",\n  \"account\": { \"display_name\": \"Rina Wijaya\" }\n}\n","example_response":"# mode=assign / invite (sudah di org) / register — member populated\n{\n  \"success\": true,\n  \"data\": {\n    \"mode\": \"register\",\n    \"message\": \"user registered and assigned to tenant and outlets\",\n    \"user_id\": \"01923c8b-2222-7000-a000-000000000002\",\n    \"member\": {\n      \"id\": \"01923c8b-3333-7000-a000-000000000003\",\n      \"account_id\": \"01923c8b-2222-7000-a000-000000000002\",\n      \"display_name\": \"Rina Wijaya\",\n      \"email\": \"rina@laundry.id\",\n      \"role_id\": \"01900000-0000-7fff-8fff-000000000012\",\n      \"role_name\": \"staff\",\n      \"mobile_number\": \"081234567890\",\n      \"is_active\": true,\n      \"status\": \"active\",\n      \"outlets\": [{ \"id\": \"01923c8b-aaaa-7000-a000-0000000000a4\", \"name\": \"Cabang Pusat\" }],\n      \"created_at\": \"2026-06-03T08:00:00Z\",\n      \"updated_at\": \"2026-06-03T08:00:00Z\"\n    },\n    \"temp_password\": \"kQ7s-mE4t-9pX\"\n  },\n  \"message\": \"success\"\n}\n\n# mode=invite — user belum accept\n{\n  \"success\": true,\n  \"data\": {\n    \"mode\": \"invite\",\n    \"message\": \"user needs to accept invitation before outlet assignment\",\n    \"invitation_id\": \"01923c8b-4444-7000-a000-000000000004\"\n  },\n  \"message\": \"success\"\n}\n"},{"name":"List members","method":"GET","path":"/api/v1/tenant/member","auth":"JWT Bearer","description":"Daftar member tenant (paginated). Identitas (display_name/email) + outlet names di-batch-load (bebas N+1) — UI Direktori Karyawan bisa render card lengkap tanpa lookup tambahan.\n\n**Pending invitations overlay** — default list (tanpa `?status=`) **menyatukan** real members + undangan pending sebagai virtual row `status=invited`. Row invited di-pin di awal (page 1) supaya owner lihat siapa yang belum accept. Row invited punya field tambahan:\n- `id` — invitation ID (BUKAN tenant_member ID).\n- `account_id` — invitee.\n- `invitation_id` — sama dengan `id`, di-expose terpisah untuk kejelasan.\n- `expired: true` kalau melewati `expires_at` (owner bisa resend; lihat endpoint Resend invitation).\n- `status: \"invited\"`.\n\nFilter `?status=invited` → return **HANYA** pending invitations (paged in-memory).\nFilter `?status=active|on_leave|inactive` → tetap real-members-only (overlay tidak di-apply).\n\n**Owner tenant tidak muncul di list.** Akun pembuat tenant (`tenant_detail.owner_account_id`) di-exclude unconditional — bahkan `?account_id=\u003cowner\u003e` balas list kosong. Untuk resolve owner pakai `GET /api/v1/tenant/profile`.\n\n**Tab Semua/Aktif/Cuti/Nonaktif/Diundang** di UI:\n- tab Semua → tidak kirim `is_active`/`status` (overlay invited aktif)\n- tab Aktif → `status=active` (atau `is_active=true` untuk inklusi cuti)\n- tab Cuti → `status=on_leave`\n- tab Nonaktif → `status=inactive` (atau `is_active=false`)\n- tab Diundang → `status=invited` (hanya pending)\n","query_params":[{"name":"page","type":"int","default":"1","description":"Halaman 1-based. Nilai `\u003c1` di-clamp ke `1`."},{"name":"limit","type":"int","default":"20","description":"Jumlah per halaman. Clamp: `\u003c1 → 20`, `\u003e100 → 100`."},{"name":"outlet_id","type":"string","description":"Filter member dengan outlet_member aktif ke outlet ini"},{"name":"account_id","type":"string","description":"Filter UUID akun"},{"name":"role_id","type":"string","description":"Filter UUID role"},{"name":"is_active","type":"bool","description":"Filter access gate (true/false). Tidak dikirim = semua. `on_leave` masuk `is_active=true`"},{"name":"status","type":"string","description":"Filter status: `active` / `on_leave` / `inactive` / `invited`. Status `invited` = pending invitation (bukan real member). Tab UI: Aktif=active, Cuti=on_leave, Nonaktif=inactive, Diundang=invited. Tak dikenal → 400."},{"name":"search","type":"string","description":"Search match `display_name`/`email` (dari mirror `accounts`) atau `mobile_number` (dari `tenant_member`), case-insensitive."},{"name":"sort_by","type":"string","default":"created_at","description":"Field sort: `created_at`, `updated_at`, atau `display_name` (di-resolve ke `accounts.display_name`)."},{"name":"sort_order","type":"string","default":"desc","description":"`asc` atau `desc`"}],"example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"01923c8b-4444-7000-a000-000000000004\",\n      \"account_id\": \"01923c8b-2222-7000-a000-000000000007\",\n      \"display_name\": \"Andi (Diundang)\",\n      \"email\": \"andi@laundry.id\",\n      \"role_id\": \"01900000-0000-7fff-8fff-000000000012\",\n      \"role_name\": \"staff\",\n      \"mobile_number\": \"\",\n      \"is_active\": false,\n      \"status\": \"invited\",\n      \"invitation_id\": \"01923c8b-4444-7000-a000-000000000004\",\n      \"expired\": false,\n      \"outlets\": [\n        { \"id\": \"01923c8b-aaaa-7000-a000-0000000000a4\", \"name\": \"Cabang Pusat\" }\n      ],\n      \"created_at\": \"2026-06-04T11:30:00Z\",\n      \"updated_at\": \"2026-06-04T11:30:00Z\"\n    },\n    {\n      \"id\": \"01923c8b-3333-7000-a000-000000000003\",\n      \"account_id\": \"01923c8b-2222-7000-a000-000000000002\",\n      \"display_name\": \"Rina Wijaya\",\n      \"email\": \"rina@laundry.id\",\n      \"role_id\": \"01900000-0000-7fff-8fff-000000000012\",\n      \"role_name\": \"staff\",\n      \"mobile_number\": \"081234567890\",\n      \"is_active\": true,\n      \"status\": \"active\",\n      \"invitation_id\": \"\",\n      \"expired\": false,\n      \"outlets\": [\n        { \"id\": \"01923c8b-aaaa-7000-a000-0000000000a4\", \"name\": \"Cabang Pusat\" }\n      ],\n      \"created_at\": \"2026-05-28T10:00:00Z\",\n      \"updated_at\": \"2026-05-28T10:00:00Z\"\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"total\": 2, \"total_pages\": 1, \"has_next\": false, \"has_prev\": false },\n  \"message\": \"success\"\n}\n"},{"name":"Get member by ID","method":"GET","path":"/api/v1/tenant/member/{id}","auth":"JWT Bearer","description":"Detail satu member tenant + identitas (display_name, email, profile_picture_id) + outlet names. Dipakai screen \"Detail Karyawan\" untuk render full info. **404** bila bukan milik tenant ini.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-3333-7000-a000-000000000003\",\n    \"account_id\": \"01923c8b-2222-7000-a000-000000000002\",\n    \"display_name\": \"Rina Wijaya\",\n    \"email\": \"rina@laundry.id\",\n    \"role_id\": \"01900000-0000-7fff-8fff-000000000012\",\n    \"role_name\": \"staff\",\n    \"profile_picture_id\": null,\n    \"mobile_number\": \"081234567890\",\n    \"is_active\": true,\n    \"status\": \"active\",\n    \"outlets\": [\n      { \"id\": \"01923c8b-aaaa-7000-a000-0000000000a4\", \"name\": \"Cabang Pusat\" }\n    ],\n    \"created_at\": \"2026-05-28T10:00:00Z\",\n    \"updated_at\": \"2026-05-28T10:00:00Z\"\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Update member","method":"PATCH","path":"/api/v1/tenant/member/{id}","auth":"JWT Bearer","description":"Perbarui member (partial — semua field opsional). Method berubah dari `PUT` → `PATCH` (semantik sebenarnya patch); 3 endpoint terpisah `set-on-leave` / `activate-staff` / `deactivate-staff` **DIHAPUS** dan digabung jadi field `status` di sini. Client lama yang hit endpoint tersebut akan 404.\n\n**Field editable di sini:**\n- `role_id` — ganti role (`staff`/`manager`/`admin`/custom).\n- `mobile_number` — nomor telepon karyawan (denormalized di `tenant_member`).\n- `profile_picture_id` — avatar.\n- `status` — transisi status karyawan, salah satu dari `active` | `on_leave` | `inactive`. **Menggantikan** field `is_active` lama + 3 endpoint sub-action:\n  - `status=active` → `is_active=true`, badge UI \"Aktif\".\n  - `status=on_leave` (cuti) → `is_active=true` (masih bisa login), badge UI \"Cuti\".\n  - `status=inactive` (nonaktif) → `is_active=false`, login diblokir.\n  Server enforce invarian `status → is_active` (active/on_leave → true, inactive → false) — client tidak perlu set `is_active` manual. Value selain ketiga itu → `400 invalid status`.\n- `outlet_ids` — `null`/diabaikan = tidak diubah; `[]` = hapus semua assignment; daftar = ganti seluruh assignment (sync atomik). Server **trim + canonicalize + dedup** input duplicate (`[\"a\",\"a\",\"A\",\"a \"]` → `[\"a\"]`) sebelum validasi — tidak lagi 500 pada partial-unique index. Ownership check **batched** (1 query untuk semua id, anti N+1).\n\n**Read-only di endpoint ini** (tidak akan berubah meskipun dikirim):\n- `email` — identitas mirror dari `accounts` (event-synced dari account-service); mutasi dilakukan di account-service.\n- `display_name` — saat ini juga read-only dari account-service (rencana extend untuk editable lokal tracked di design doc, belum tersedia).\n\n**Rules role-outlet:**\n- Role **all-outlet** (admin/manager — capability `outlet:all`):\n  - `outlet_ids: [...]` non-empty → **`400 \"role ini sudah punya akses ke semua outlet; ubah role ke staff dulu bila ingin membatasi ke outlet tertentu\"`**. Dulu dibuang diam-diam → response 200 dengan outlets kosong (menyesatkan). Sekarang ditolak eksplisit.\n  - `outlet_ids: []` atau tidak dikirim → `200`, semua `outlet_member` lama dibersihkan (akses implisit ke semua outlet tetap berlaku).\n- Role **staff** (`outlet:all` tidak punya): `outlet_ids` divalidasi kepemilikannya (outlet asing → `400`, assignment lama dibiarkan utuh).\n","example_body":"# ubah role + tambah outlet\n{\n  \"role_id\": \"01900000-0000-7fff-8fff-000000000012\",\n  \"mobile_number\": \"081299990000\",\n  \"outlet_ids\": [\n    \"01923c8b-aaaa-7000-a000-0000000000a4\",\n    \"01923c8b-aaaa-7000-a000-0000000000a5\"\n  ]\n}\n\n# set cuti\n{ \"status\": \"on_leave\" }\n\n# nonaktifkan\n{ \"status\": \"inactive\" }\n\n# aktifkan kembali (dari cuti atau inactive)\n{ \"status\": \"active\" }\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-3333-7000-a000-000000000003\",\n    \"account_id\": \"01923c8b-2222-7000-a000-000000000002\",\n    \"display_name\": \"Rina Wijaya\",\n    \"email\": \"rina@laundry.id\",\n    \"role_id\": \"01900000-0000-7fff-8fff-000000000012\",\n    \"role_name\": \"staff\",\n    \"profile_picture_id\": null,\n    \"mobile_number\": \"081299990000\",\n    \"is_active\": true,\n    \"status\": \"active\",\n    \"outlets\": [\n      { \"id\": \"01923c8b-aaaa-7000-a000-0000000000a4\", \"name\": \"Cabang Pusat\" },\n      { \"id\": \"01923c8b-aaaa-7000-a000-0000000000a5\", \"name\": \"Cabang Selatan\" }\n    ],\n    \"created_at\": \"2026-05-28T10:00:00Z\",\n    \"updated_at\": \"2026-05-28T11:00:00Z\"\n  },\n  \"message\": \"member updated successfully\"\n}\n"},{"name":"Accept invitation (invitee)","method":"POST","path":"/api/v1/tenant/invitations/{id}/accept","auth":"JWT Bearer","description":"**Invitee-side action** untuk menerima undangan dari `POST /tenant/member` mode=invite. Endpoint **tidak di-gate** `member:write` (invitee belum jadi member) — authorization by `claim.subject` (account_id JWT) matching invitee.\n\nUrutannya: **(1)** server **accept org invitation di account-service** lewat `POST /api/v1/me/invitations/{account_invitation_id}/accept` (mem-forward Bearer token invitee — account-service mencocokkan undangan ke akun pemanggil), sehingga invitee jadi **member organisasi sungguhan** (source of truth lintas-service). **(2)** baru server bikin `tenant_member` + `outlet_member` lokal sesuai role + outlet yang owner tetapkan saat invite. Status invitation → `accepted`. Owner di-notify via inbox.\n\nRespons account-service \"already processed\" / \"already a member\" diperlakukan **idempotent** (dianggap sukses, accept lokal lanjut). Kalau org invitation gagal keras di account-service (expired, email mismatch, not found) → status account-service di-propagate dan accept lokal **dibatalkan** (tenant_member tidak dibikin).\n\n**Notifikasi inbox di-update in-place.** Server **re-publish notifikasi `member_invited` yang sama** (id notif identik dengan yang dikirim saat invite) dengan `actions: []` (tombol Terima/Tolak hilang), `body` jadi \"Kamu telah menerima undangan…\", dan `metadata.state: \"accepted\"`. notification-service menimpa payload by (platform, id) — **tidak** kirim ulang email/push, `read`/status tetap. FE cukup re-fetch inbox (atau terima push) lalu render state settled: sembunyikan tombol saat `actions` kosong / `metadata.state` ada. Id notif **stabil** lintas update, jadi FE bisa mencocokkan.\n\n**Konvergensi saat 409.** Kalau invitee tap Accept lagi pada undangan yang sudah resolved (→ **409**), itu sinyal inbox-nya masih nampilin tombol — server tetap **re-settle** notifikasi (best-effort, sesuai status final invitation) supaya UI nyusul, lalu balikin 409. (Hanya untuk invitation yang punya notification id tersimpan, mis. dibuat setelah fitur ini live.)\n\n**404** kalau invitation tidak ada / sudah di-soft-delete. **409** kalau invitation sudah accepted/rejected (state finalized; notifikasi tetap di-settle). **410** kalau sudah expired. **502** kalau account-service tak terjangkau saat accept org invitation.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-3333-7000-a000-000000000003\",\n    \"account_id\": \"01923c8b-2222-7000-a000-000000000002\",\n    \"display_name\": \"Rina Wijaya\",\n    \"email\": \"rina@laundry.id\",\n    \"role_id\": \"01900000-0000-7fff-8fff-000000000012\",\n    \"role_name\": \"staff\",\n    \"is_active\": true,\n    \"status\": \"active\",\n    \"outlets\": [{ \"id\": \"01923c8b-aaaa-7000-a000-0000000000a4\", \"name\": \"Cabang Pusat\" }],\n    \"created_at\": \"2026-06-04T11:30:00Z\",\n    \"updated_at\": \"2026-06-04T11:30:00Z\"\n  },\n  \"message\": \"invitation accepted\"\n}\n"},{"name":"Resend invitation (owner)","method":"POST","path":"/api/v1/tenant/invitations/{id}/resend","auth":"JWT Bearer","permission":"wacca-tenant:*","description":"**Owner-side action** untuk kirim ulang undangan pending atau expired. Authorized by `member:write` capability. Server bump `expires_at` + set status balik ke `pending` (kalau sudah expired) + re-fire notifikasi inbox + push ke invitee. **Invitation id tetap sama** (tidak bikin row baru), **tapi notification id baru** di-mint tiap resend (notif baru → email/push benar-benar terkirim ulang) dan disimpan di invitation, supaya accept/reject berikutnya meng-update notifikasi **terakhir** yang dilihat invitee, bukan yang lama.\n\n**404** kalau invitation tidak ada / sudah accepted/rejected. Body tidak diperlukan.\n","example_response":"{\n  \"success\": true,\n  \"data\": null,\n  \"message\": \"invitation resent\"\n}\n"},{"name":"Reject invitation (invitee)","method":"POST","path":"/api/v1/tenant/invitations/{id}/reject","auth":"JWT Bearer","description":"**Invitee-side action** untuk menolak undangan. Authorization sama dengan accept (JWT subject matching invitee). Server juga **decline org invitation di account-service** lewat `POST /api/v1/me/invitations/{account_invitation_id}/decline` (forward Bearer token invitee) supaya undangan organisasi tidak menggantung. \"Already processed\" diperlakukan idempotent. Status invitation lokal → `rejected`. Owner di-notify. Body tidak diperlukan.\n\n**Notifikasi inbox di-update in-place** sama seperti accept: notif `member_invited` yang sama di-re-publish dengan `actions: []`, `body` \"Kamu menolak undangan…\", `metadata.state: \"rejected\"` (no re-send email/push). Tap Reject lagi pada undangan resolved (**409**) tetap me-re-settle notifikasinya.\n\nSetelah reject, owner boleh kirim ulang invite (unique constraint `WHERE status='pending'` saja). **404/409/410** sama dengan accept.\n","example_response":"{\n  \"success\": true,\n  \"data\": null,\n  \"message\": \"invitation rejected\"\n}\n"},{"name":"Reset member password","method":"POST","path":"/api/v1/tenant/member/{id}/reset-password","auth":"JWT Bearer","description":"Owner reissue kredensial member — server panggil account-service admin reset-password lalu sajikan hasilnya ke owner sesuai mode `delivery`:\n\n- `return` (default) — temp password dikembalikan **sekali** di response (`temp_password` + `expires_at`); owner share manual ke staff.\n- `email` — account-service kirim email reset langsung ke staff; response tidak punya `temp_password`.\n\n`force_change_on_next_login` (default `true` di account-service): paksa staff ganti password saat login pertama. Body **opsional** — body kosong = `delivery=return` dengan default.\n\n**403** kalau caller bukan admin/manager. **404** kalau member bukan milik tenant.\n","example_body":"{\n  \"delivery\": \"return\",\n  \"force_change_on_next_login\": true\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"user_id\": \"01923c8b-2222-7000-a000-000000000002\",\n    \"delivery\": \"return\",\n    \"temp_password\": \"kQ7s-mE4t-9pX\",\n    \"expires_at\": \"2026-06-08T10:00:00Z\",\n    \"must_change_on_next_login\": true\n  },\n  \"message\": \"password reset successfully\"\n}\n"},{"name":"Remove member","method":"DELETE","path":"/api/v1/tenant/member/{id}","auth":"JWT Bearer","description":"Keluarkan member: hapus dari organisasi (account-service), lalu\nsoft-delete `tenant_member` + seluruh `outlet_member`-nya secara\natomik. `404` bila bukan milik tenant ini.\n","example_response":"{\n  \"success\": true,\n  \"data\": null,\n  \"message\": \"member removed successfully\"\n}\n"}]},{"id":"tenant-provisioning","title":"Tenant — Provisioning Check","description":"Status onboarding tenant — step-step yang sudah aktif (tenant, outlet, service/product/driver, bank). Dipakai UI sebagai gate sebelum render dashboard/CRUD lain (bila belum bootstrap, redirect ke wizard create-tenant) + checklist progres.\n\nField `setup_done` di response = `true` kalau **owner sudah eksplisit menandai selesai** (via POST /done) **ATAU** semua step sudah aktif otomatis. UI dashboard pakai ini untuk hide/show banner \"Lanjutkan setup\".\n","endpoints":[{"name":"Get provisioning status","method":"GET","path":"/api/v1/tenant/provisioning","auth":"JWT Bearer","description":"Cek status onboarding `org_id` JWT. Selalu 200; UI baca per-step + `setup_done`.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"tenant_is_active\": true,\n    \"outlet_is_active\": true,\n    \"service_is_active\": true,\n    \"product_is_active\": false,\n    \"driver_is_active\": false,\n    \"bank_is_active\": false,\n    \"setup_done\": true\n  },\n  \"message\": \"Success\"\n}\n"},{"name":"Mark provisioning done","method":"POST","path":"/api/v1/tenant/provisioning/done","auth":"JWT Bearer","permission":"wacca-tenant:*","description":"Owner tandai onboarding selesai walaupun step opsional (service/product/driver/bank) belum aktif. Server validasi: **tenant + outlet wajib aktif**; sisanya boleh skipped.\n\nIdempoten — kalau sudah `setup_done=true`, set ulang aman.\n\n**204 No Content** sukses (no response body).\n**400 'tenant and outlet must be active before completing setup'** kalau belum cukup.\n**403** kalau caller tidak punya capability `tenant:write` (owner/manager only).\n"}]},{"id":"tenant-promo","title":"Tenant — Promo Management","description":"Owner kelola promo (voucher/diskon) end-to-end dengan **lifecycle status** + **5 promo type** + **3 mechanism** + outlet/service scope M2M (PR #56, mengganti `tenant/promotional` lama). Customer pakai promo via:\n- Apply `coupon_code` di [`POST /customer/orders/coupon-preview`](#customer-orders) dan POST order (mechanism=`code`).\n- Klaim dulu lewat [`POST /customer/promo/{id}/claim`](#customer-promo) lalu kirim `customer_coupon_id` saat checkout (mechanism=`claim`).\n- Auto-evaluated saat order (mechanism=`automatic`) — server pilih promo terbaik.\n\n**Lifecycle status** (`status` field) — 5 nilai dengan state machine:\n```\ndraft → scheduled (kalau valid_from di masa depan saat publish)\n      ↓\n      active (kalau valid_from ≤ now \u003c valid_end saat publish)\n      ↓\n      paused (manual via Pause; resume balik ke active/scheduled)\n      ↓\n      ended (manual via End/Cancel, atau auto saat valid_end lewat — via cron admin)\n```\n**Allowed actions** ikut status, FE pakai field `allowed_actions[]` di detail response untuk menentukan tombol aktif. Cron platform `POST /api/v1/admin/promo/transition-status` auto-promote `scheduled → active` dan `active → ended` saat tanggal lewat.\n\n**Promo type** (`promo_type`, 5 nilai) — masing-masing punya field nilai sendiri:\n- `percentage` — `discount_percent_value` (0–100), `discount_percent_max` opsional (cap rupiah).\n- `fixed_amount` — `discount_fixed_value` (nominal rupiah).\n- `free_service` — gratiskan service yang ada di `free_service_ids[]` (tidak butuh nilai uang).\n- `shipping_discount` — `shipping_discount_type` (`percentage`/`fixed`) + `shipping_discount_value`; `shipping_max_radius_km` opsional batas jarak antar.\n- `bundle` — `bundle_discount_value`; berlaku saat semua `bundle_service_ids[]` ada di order.\n\n**Mechanism** (`mechanism`, 3 nilai):\n- `code` — customer ketik `coupon_code` saat checkout (unique per tenant + lower-case via partial unique index `WHERE deleted_at IS NULL`).\n- `claim` — customer klaim dulu (muncul di \"Promo Saya\"), kirim `customer_coupon_id` saat checkout. Satu customer hanya bisa klaim 1× per promo (partial unique `customer_coupon (customer_id, promotional_id)`).\n- `automatic` — tanpa input customer; server evaluasi semua promo `automatic` eligible saat checkout dan pilih yang memberi diskon terbesar.\n\n**Scope \u0026 requirement**:\n- `outlet_scope_type`: `all_outlets` (semua outlet tenant) atau `selected` (kirim `outlet_ids[]`, di-store M2M `promo_outlet`).\n- `service_requirement_mode`: `all_services` | `selected_services` (`required_service_ids[]`) | `service_combo` (semua service di list wajib ada di order).\n- `customer_criteria`: `all` | `new_user` (dengan `new_user_days` opsional sebagai batas hari sejak register).\n\n**Kuota \u0026 budget**:\n- `maximum_use` — total pemakaian semua customer.\n- `max_use_per_customer` — limit per-customer (audit lewat `coupon_apply.account_id` + `customer_coupon`).\n- `budget_limit` (nullable) — total nominal subsidi maksimum; tiap apply tambah `budget_used`. Kalau `budget_used + new_discount \u003e budget_limit` → tolak.\n\n**Atomic consume** (`coupon_apply.order_id` unique partial) — satu order hanya bisa pakai 1 promo; pemakaian + budget naik dalam transaksi yang sama dengan create order, rollback bareng kalau order gagal.\n\n**`is_global`** — flag promo platform-wide (di-manage admin platform); tenant biasa tidak bisa toggle.\n","endpoints":[{"name":"List promos","method":"GET","path":"/api/v1/tenant/promo","auth":"JWT Bearer","description":"List promo milik tenant (paginasi). Query opsional:\n- `status` — `draft`/`scheduled`/`active`/`paused`/`ended`\n- `promo_type` — salah satu 5 nilai\n- `mechanism` — `code`/`claim`/`automatic`\n- `q` — search by name / coupon_code (ILIKE)\n- `page`, `limit` (default 1/20)\n\nResponse include label human-readable (`status_label`, `promo_type_label`, `mechanism_label`, `scope_label`, `service_requirement_label`, `customer_criteria_label`) + `usage_percentage` + `budget_percentage` (nullable kalau no budget).\n"},{"name":"Summary stats","method":"GET","path":"/api/v1/tenant/promo/summary","auth":"JWT Bearer","description":"Counter per-status (`active_count`, `scheduled_count`, `ended_count`, `draft_count`) + `total_subsidy_this_month` (rupiah dari `coupon_apply.discount_amount` bulan berjalan).","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"active_count\": 3,\n    \"scheduled_count\": 1,\n    \"ended_count\": 8,\n    \"draft_count\": 2,\n    \"total_subsidy_this_month\": 245000\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Create promo (draft)","method":"POST","path":"/api/v1/tenant/promo","auth":"JWT Bearer","permission":"wacca-tenant:* (catalog:write capability)","description":"Buat promo status `draft` — belum berlaku, tidak muncul di customer side. Validasi: `promo_type` + `mechanism` wajib, `coupon_code` required hanya kalau `mechanism=code` (unique per tenant case-insensitive), nilai type-specific wajib (mis. `discount_percent_value` untuk `percentage`).\n\n`outlet_ids[]`, `required_service_ids[]`, `free_service_ids[]`, `bundle_service_ids[]` di-validasi sebagai milik tenant ini.\n","example_body":"# Percentage code-based, scope selected outlets, service requirement all\n{\n  \"promo_type\": \"percentage\",\n  \"mechanism\": \"code\",\n  \"name\": \"Welcome 15%\",\n  \"coupon_code\": \"WELCOME15\",\n  \"description\": \"Diskon pengguna baru\",\n  \"discount_percent_value\": 15,\n  \"discount_percent_max\": 30000,\n  \"min_order_amount\": 50000,\n  \"maximum_use\": 500,\n  \"max_use_per_customer\": 1,\n  \"budget_limit\": 10000000,\n  \"outlet_scope_type\": \"selected\",\n  \"outlet_ids\": [\"01923c8b-...-0004\", \"01923c8b-...-0005\"],\n  \"customer_criteria\": \"new_user\",\n  \"new_user_days\": 30,\n  \"valid_from\": \"2026-07-01T00:00:00Z\",\n  \"valid_end\": \"2026-12-31T23:59:59Z\"\n}\n\n# Bundle automatic, harus mengandung 2 service tertentu\n{\n  \"promo_type\": \"bundle\",\n  \"mechanism\": \"automatic\",\n  \"name\": \"Cuci + Setrika hemat 20rb\",\n  \"bundle_discount_value\": 20000,\n  \"bundle_service_ids\": [\"01923c8b-...-svc-cuci\", \"01923c8b-...-svc-setrika\"],\n  \"service_requirement_mode\": \"service_combo\",\n  \"outlet_scope_type\": \"all_outlets\",\n  \"maximum_use\": 0,\n  \"valid_from\": \"2026-06-01T00:00:00Z\",\n  \"valid_end\": \"2026-09-30T23:59:59Z\"\n}\n"},{"name":"Save and publish (atomic)","method":"POST","path":"/api/v1/tenant/promo/save-and-publish","auth":"JWT Bearer","permission":"wacca-tenant:* (catalog:write)","description":"Create+Publish atau Update+Publish dalam satu request. Body sama dengan Create plus:\n- `id` (opsional) — kalau diisi, update promo existing lalu publish.\n- `publish` (bool) — kalau `false`, sama dengan Create/Update biasa (tetap `draft`).\n\nSetelah publish, status ke `scheduled` (kalau `valid_from \u003e now`) atau `active`.\n"},{"name":"Get promo detail","method":"GET","path":"/api/v1/tenant/promo/{id}","auth":"JWT Bearer","description":"Detail satu promo + relasi M2M lengkap: `outlets[]`, `services[]` (required), `free_services[]`, `bundle_items[]`. Plus:\n- `allowed_actions[]` — daftar action yang valid di state ini (mis. `[\"pause\", \"end\"]` untuk `active`).\n- `info_banner` — string warning kontekstual (mis. \"Budget hampir habis\").\n- `performa` — `{conversion_count, total_discount_amount, reward_value}` agregat untuk dashboard.\n"},{"name":"Update promo (partial)","method":"PUT","path":"/api/v1/tenant/promo/{id}","auth":"JWT Bearer","permission":"wacca-tenant:* (catalog:write)","description":"Partial update — kirim field yang berubah saja. Kalau status `active`, beberapa field di-lock (mis. `promo_type`, `mechanism`, `coupon_code`)."},{"name":"Delete promo","method":"DELETE","path":"/api/v1/tenant/promo/{id}","auth":"JWT Bearer","permission":"wacca-tenant:* (catalog:write)","description":"Soft delete. Hanya boleh dari status `draft` atau `ended` — promo aktif/scheduled harus di-`cancel` dulu.\n"},{"name":"Publish","method":"PATCH","path":"/api/v1/tenant/promo/{id}/publish","auth":"JWT Bearer","permission":"wacca-tenant:* (catalog:write)","description":"Dari `draft` → `scheduled`/`active` (tergantung `valid_from`). 409 kalau dari state lain. `published_at` di-stamp.\n"},{"name":"Pause","method":"PATCH","path":"/api/v1/tenant/promo/{id}/pause","auth":"JWT Bearer","permission":"wacca-tenant:* (catalog:write)","description":"Dari `active`/`scheduled` → `paused`. `paused_at` di-stamp. Customer tidak bisa apply selama paused."},{"name":"Resume","method":"PATCH","path":"/api/v1/tenant/promo/{id}/resume","auth":"JWT Bearer","permission":"wacca-tenant:* (catalog:write)","description":"Dari `paused` → kembali ke `active`/`scheduled` (sesuai tanggal saat ini)."},{"name":"Cancel","method":"PATCH","path":"/api/v1/tenant/promo/{id}/cancel","auth":"JWT Bearer","permission":"wacca-tenant:* (catalog:write)","description":"Dari `scheduled`/`active`/`paused` → `ended` lebih awal. Permanen."},{"name":"End","method":"PATCH","path":"/api/v1/tenant/promo/{id}/end","auth":"JWT Bearer","permission":"wacca-tenant:* (catalog:write)","description":"Akhiri promo sekarang (sebelum `valid_end`). Status → `ended`."},{"name":"Duplicate","method":"POST","path":"/api/v1/tenant/promo/{id}/duplicate","auth":"JWT Bearer","permission":"wacca-tenant:* (catalog:write)","description":"Bikin promo baru sebagai `draft` dari template promo existing. `coupon_code` dikosongkan (wajib re-input — supaya tidak conflict unique index). Relasi M2M (outlets/services) ikut di-copy. Berguna untuk seasonal promo yang berulang.\n"},{"name":"Get available outlets (helper)","method":"GET","path":"/api/v1/tenant/promo/outlets/available","auth":"JWT Bearer","description":"Helper saat owner pilih outlet di form create — return outlet milik tenant lengkap dengan info \"service yang dibutuhkan promo ini\" sudah ada / belum di tiap outlet. Query opsional:\n- `service_ids` (comma-separated UUID)\n- `require_all` (`true`/`false`) — `true` = outlet harus punya SEMUA service, `false` = minimal 1.\n\nResponse per-outlet: `has_services[]`, `missing_services[]`, `is_eligible`, `unavailable_reason`.\n"}]},{"id":"admin-promo-ops","title":"Admin — Promo Lifecycle Worker","description":"Background worker platform untuk auto-transition promo lifecycle. Trigger dari scheduler (cron/job runner) — bukan dipanggil dari UI tenant.\n","endpoints":[{"name":"Auto-transition promo status","method":"POST","path":"/api/v1/admin/promo/transition-status","auth":"Internal API Key (X-Internal-Key header)","description":"Sapu seluruh `promotional_schema` dan:\n- `scheduled` → `active` kalau `valid_from ≤ now`.\n- `active` → `ended` kalau `valid_end \u003c now`.\nIdempoten. Aman dipanggil tiap menit.\n"}]},{"id":"tenant-order","title":"Tenant — Order Inbox","description":"List / detail / update order dari sisi tenant (owner + karyawan). Untuk transisi status (accept, reject, set-final-price, advance, confirm-cod) lihat section [Order Flow — Owner](#order-flow-owner).\n\n**Outlet-scope (RBAC)**: staff hanya boleh akses order dari outlet yang ditugaskan; admin/manager bypass. Filter `outlet_id` di list tetap berfungsi tapi staff yang minta outlet di luar assignment-nya akan dapat hasil kosong (RBAC middleware enforce).\n\n**Status order valid** (8 nilai): `pending` → `accepted` → `price_proposed` → `processing` → `ready_pickup` | `delivering` → `completed`. Plus `cancelled` dari mana saja via reject. Filter `?status=` **harus pakai konstanta backend** ini — bukan label UI seperti \"waiting_approval\" (gunakan `price_proposed` untuk order menunggu approve customer).\n","endpoints":[{"name":"List tenant orders","method":"GET","path":"/api/v1/tenant/orders","auth":"JWT Bearer","description":"Order milik tenant saat ini (paginasi + filter). `tenant_id` resolve otomatis dari JWT.\n\n**Enriched (anti N+1)**: row sudah include `customer_name` (dari accounts mirror) + `items[]` inline (line items dengan `quantity`/`amount`/dst) — UI list bisa render card lengkap tanpa fetch detail tambahan. `quantity` selalu render sebagai angka (termasuk `0`), tidak `null`.\n","query_params":[{"name":"page","type":"int","default":"1","description":"Halaman 1-based"},{"name":"limit","type":"int","default":"20","description":"Jumlah per halaman"},{"name":"outlet_id","type":"string","description":"Filter UUID outlet (tetap tenant-scoped + RBAC outlet-scope)"},{"name":"customer_id","type":"string","description":"Filter UUID customer (= account_id post-F3)"},{"name":"status","type":"string","description":"Salah satu: `pending`, `accepted`, `price_proposed`, `processing`, `ready_pickup`, `delivering`, `completed`, `cancelled`"}],"example_response":"{\n  \"success\": true,\n  \"data\": [\n    {\n      \"id\": \"01923c8b-1234-7000-a000-0000000000aa\",\n      \"order_number\": \"WCC-260528-001\",\n      \"customer_id\": \"01923c8b-1234-7000-a000-000000000010\",\n      \"customer_name\": \"Siti Nurhaliza\",\n      \"outlet_id\": \"01923c8b-1234-7000-a000-000000000004\",\n      \"total_amount\": 77800,\n      \"note\": \"Pisahkan pakaian putih\",\n      \"status\": \"price_proposed\",\n      \"items\": [\n        {\n          \"id\": \"01923c8b-1234-7000-a000-0000000000ab\",\n          \"item_id\": \"01923c8b-1234-7000-a000-000000000020\",\n          \"name\": \"Cuci Reguler\",\n          \"unit_of_measure\": \"kg\",\n          \"estimated_quantity\": 3,\n          \"quantity\": 3.2,\n          \"amount\": 22400,\n          \"is_addon\": false,\n          \"status\": \"pending\"\n        }\n      ],\n      \"created_at\": \"2026-05-28T08:00:00Z\",\n      \"updated_at\": \"2026-05-28T08:35:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"total\": 1,\n    \"total_pages\": 1,\n    \"has_next\": false,\n    \"has_prev\": false\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Get tenant order","method":"GET","path":"/api/v1/tenant/orders/{id}","auth":"JWT Bearer","description":"Detail satu order + items, dengan **enrichment lengkap** (batched, no N+1): identitas customer (`customer_name`, `customer_phone`), money breakdown lengkap (`estimated_total`, `final_total` nullable, `discount_amount`, `pickup_fee`, `tax_amount`), `delivery_method`, plus tiap item `estimated_quantity` \u0026 `is_addon`.\n\n`final_total` rendered `null` sebelum owner set final-price (pre `price_proposed`). `quantity` selalu render sebagai angka (termasuk `0`), tidak `null` (fix `c312294`).\n\n**404** kalau bukan milik tenant. RBAC outlet-scope enforce: staff yang outlet-nya bukan order ini juga dapat 404.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-1234-7000-a000-0000000000aa\",\n    \"order_number\": \"WCC-260528-001\",\n    \"customer_id\": \"01923c8b-1234-7000-a000-000000000010\",\n    \"customer_name\": \"Siti Nurhaliza\",\n    \"customer_phone\": \"+62811xxxxxxx\",\n    \"outlet_id\": \"01923c8b-1234-7000-a000-000000000004\",\n    \"delivery_method\": \"pickup\",\n    \"total_amount\": 77800,\n    \"estimated_total\": 77800,\n    \"final_total\": 81000,\n    \"discount_amount\": 18200,\n    \"pickup_fee\": 5000,\n    \"tax_amount\": 8000,\n    \"note\": \"Pisahkan pakaian putih\",\n    \"status\": \"processing\",\n    \"items\": [\n      {\n        \"id\": \"01923c8b-1234-7000-a000-0000000000ab\",\n        \"item_id\": \"01923c8b-1234-7000-a000-000000000020\",\n        \"name\": \"Cuci Reguler\",\n        \"unit_of_measure\": \"kg\",\n        \"estimated_quantity\": 3,\n        \"quantity\": 3.2,\n        \"amount\": 22400,\n        \"is_addon\": false,\n        \"status\": \"processing\"\n      },\n      {\n        \"id\": \"01923c8b-1234-7000-a000-0000000000ac\",\n        \"item_id\": \"01923c8b-1234-7000-a000-000000000021\",\n        \"name\": \"Pewangi Extra\",\n        \"unit_of_measure\": \"pcs\",\n        \"estimated_quantity\": 0,\n        \"quantity\": 1,\n        \"amount\": 5000,\n        \"is_addon\": true,\n        \"status\": \"processing\"\n      }\n    ],\n    \"created_at\": \"2026-05-28T08:00:00Z\",\n    \"updated_at\": \"2026-05-28T09:10:00Z\"\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Update tenant order","method":"PUT","path":"/api/v1/tenant/orders/{id}","auth":"JWT Bearer","description":"Partial update — kirim hanya field yang berubah. Setiap update **wajib lewat state machine yang sesuai** kalau berbentuk transisi status; untuk transisi formal (accept/reject/final-price/advance/confirm-cod) **pakai endpoint [Order Flow](#order-flow-owner)** — bukan PUT ini. PUT cocok untuk koreksi `note` / `total_amount` (atau status manual untuk hal selain transisi resmi).\n\n400 kalau transisi status tidak valid menurut state machine.\n","example_body":"{\n  \"note\": \"Customer minta jemput sore\"\n}\n"}]},{"id":"tenant-order-list","title":"Tenant — Order Lines (per-Item Actions)","description":"Endpoint per-baris order (level `order_list`) untuk eksekusi pekerjaan dapur: tandai selesai, batalkan baris, koreksi kuantitas estimasi/final per service item. Berbeda dari Order Flow yang beroperasi level header order.\n","endpoints":[{"name":"Mark order-list finished","method":"PATCH","path":"/api/v1/tenant/order-list/{id}/status-finished","auth":"JWT Bearer","description":"Tandai satu baris item selesai. Setelah semua baris finished + state header sesuai, order otomatis advance ke status berikut via Order Flow (`advance`).\n"},{"name":"Cancel order-list","method":"PATCH","path":"/api/v1/tenant/order-list/{id}/status-canceled","auth":"JWT Bearer","description":"Batalkan satu baris item (mis. service tidak bisa dilakukan untuk material tertentu). Tidak membatalkan order header — untuk full cancel pakai reject di Order Flow.\n"},{"name":"Update order-list quantity (estimasi)","method":"PATCH","path":"/api/v1/tenant/order-list/{id}/update-quantity","auth":"JWT Bearer","description":"Koreksi `quantity` estimasi sebuah baris (mis. owner timbang ulang sebelum verifikasi final). Amount line dihitung ulang.\n","example_body":"{\n  \"quantity\": 3.5\n}\n"},{"name":"Set final quantity per order_list line","method":"PATCH","path":"/api/v1/tenant/order-list/{id}/set-final-quantity","auth":"JWT Bearer","description":"Owner set kuantitas FINAL per baris (berat aktual setelah timbang untuk service, atau revised pcs untuk product). Amount baris dihitung ulang dengan rate yang berlaku, dan **`order_list.verified_at` di-set otomatis ke `now()`** — itu yang nanti dicek oleh `verify-complete` di [Order Flow — Owner](#order-flow-owner).\n\nPath **berubah** dari `/final-service-quantity` (lama) → `/set-final-quantity` (sekarang). FE wajib pakai path baru; lama akan 404.\n\nBody opsional `note` di-append ke `order_list.note` (delimiter `\" | \"`).\n","example_body":"{\n  \"final_quantity\": 3.2,\n  \"note\": \"Berat aktual sesuai struk\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": null,\n  \"message\": \"final quantity recorded\"\n}\n"}]},{"id":"tenant-rating","title":"Tenant — Outlet \u0026 Service Ratings","description":"Owner / staff melihat rating yang masuk ke outlet \u0026 service mereka. **Read-only** — owner tidak bisa edit/hapus rating customer (single endpoint mutasi: `/read` untuk mark-as-read state).\n\nPer-page **is_read state**: server tracking baca-baru-baru oleh owner; counter unread di-show di badge \"Belum dibaca\". Owner tap detail → server auto-mark read.\n\n**Sentiment label** server-derived dari `scale`:\n- `positive` — scale ≥4\n- `neutral` — scale = 3\n- `negative` — scale ≤2\n\n## ⚠️ Known gaps (tracked di [PR #59](https://github.com/Ikavia/wacca-service/pull/59))\n\n- **Filter \"Komplain\"**: chip Figma \"Komplain\" maksudnya filter scale ≤2. Backend saat ini hanya support `?star=N` (single int 1-5). Phase 1 PR #59 akan tambah `?komplain=true` → server interpret sebagai `scale \u003c= 2`. **Mitigasi FE**: 2× request paralel (`?star=1` + `?star=2`) lalu merge, atau hide chip sampai backend siap.\n- **List belum enriched**: backend list saat ini return Present **tanpa** `reviewer_name`, `initials`, `order_number`, `order_date`, `outlet_name`. Field tsb baru ada di detail endpoint (`GET /:id`). Phase 1 PR #59 akan enrich list juga.\n- **Future \"Balas Ulasan\" / \"Tandai Ditangani\"**: Figma siapkan CTA area tapi marked \"coming soon\". Backend belum ada endpoint reply/handle. Defer.\n","endpoints":[{"name":"List outlet ratings","method":"GET","path":"/api/v1/tenant/outlet/outlet-ratings","auth":"JWT Bearer","description":"Rating outlet di tenant ini (paginasi). Tiap row include `is_read` state untuk badge \"Belum dibaca\". Field enrichment (reviewer_name, order context, helpful count, sentiment) baru tersedia di detail endpoint — lihat known gap.\n","query_params":[{"name":"page","type":"int","default":"1","description":"Halaman 1-based"},{"name":"limit","type":"int","default":"20","description":"Jumlah per halaman"},{"name":"outlet_id","type":"string","description":"Filter UUID outlet (kosong = semua outlet tenant)"},{"name":"star","type":"int","description":"Filter rating bintang spesifik (1-5). Filter 'Komplain' (≤2) belum support — lihat known gap."}]},{"name":"Get outlet rating detail","method":"GET","path":"/api/v1/tenant/outlet/outlet-ratings/{id}","auth":"JWT Bearer","description":"Detail rating lengkap dengan enrichment:\n- `reviewer_name` (dari accounts mirror)\n- `likes[]` (aspect chips capital case)\n- `photos[]` (array media_id)\n- `helpful_count` (aggregate dari rating_helpful)\n- `sentiment` (positive/neutral/negative — derived dari scale)\n- `is_read` (state owner)\n- `order_context: {order_id, order_number, services[], total_weight, total_price}`\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-...-rating\",\n    \"tenant_id\": \"01923c8b-...-tenant\",\n    \"outlet_id\": \"01923c8b-...-outlet\",\n    \"customer_id\": \"01923c8b-...-customer\",\n    \"order_id\": \"01923c8b-...-order\",\n    \"scale\": 5,\n    \"review\": \"Pelayanan sangat memuaskan, baju bersih dan wangi!\",\n    \"likes\": [\"Bersih\", \"Wangi\", \"Ramah\"],\n    \"is_read\": false,\n    \"sentiment\": \"positive\",\n    \"reviewer_name\": \"Budi Santoso\",\n    \"helpful_count\": 12,\n    \"order_context\": {\n      \"order_id\": \"01923c8b-...-order\",\n      \"order_number\": \"W2401\",\n      \"services\": [\n        { \"item_id\": \"...\", \"name\": \"Cuci Reguler\", \"weight\": 2.2, \"amount\": 22000 },\n        { \"item_id\": \"...\", \"name\": \"Setrika\", \"weight\": 1.0, \"amount\": 16600 }\n      ],\n      \"total_weight\": 3.2,\n      \"total_price\": 38600\n    },\n    \"photos\": [\"01923c8b-...-photo1\"],\n    \"created_at\": \"2026-05-20T08:00:00Z\"\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Mark outlet rating read","method":"POST","path":"/api/v1/tenant/outlet/outlet-ratings/{id}/read","auth":"JWT Bearer","description":"Tandai rating ini sudah dibaca owner. **Idempoten** — call 2× tetap 200 OK. Counter `unread_count` di summary berkurang setelah call sukses. FE biasanya panggil otomatis on-mount detail screen.\n"},{"name":"Outlet ratings summary","method":"GET","path":"/api/v1/tenant/outlet/outlet-ratings/summary","auth":"JWT Bearer","description":"Aggregate untuk dashboard / list header. Field:\n- `average` — avg score 0-5\n- `count` — total rating\n- `unread_count` — yang belum di-mark read owner (badge \"Belum dibaca\")\n- `distribution[]` — 5 entry (star 5→1), tiap entry `{star, count, percent}` untuk render bar chart distribusi\n","query_params":[{"name":"outlet_id","type":"string","description":"Optional — scope ke 1 outlet. Kosong = semua outlet tenant."}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"average\": 4.8,\n    \"count\": 47,\n    \"unread_count\": 3,\n    \"distribution\": [\n      { \"star\": 5, \"count\": 35, \"percent\": 75 },\n      { \"star\": 4, \"count\": 7,  \"percent\": 15 },\n      { \"star\": 3, \"count\": 3,  \"percent\": 6 },\n      { \"star\": 2, \"count\": 1,  \"percent\": 2 },\n      { \"star\": 1, \"count\": 1,  \"percent\": 2 }\n    ]\n  },\n  \"message\": \"success\"\n}\n"},{"name":"List service ratings","method":"GET","path":"/api/v1/tenant/outlet/service-ratings","auth":"JWT Bearer","description":"Service-level rating (per-item) di tenant ini."},{"name":"Get service rating detail","method":"GET","path":"/api/v1/tenant/outlet/service-ratings/{id}","auth":"JWT Bearer","description":"Detail service rating + helpful count + reviewer + order context."}]},{"id":"tenant-rbac","title":"RBAC — Hak Akses Karyawan","description":"Role karyawan menggate **aksi** di seluruh API tenant (`/api/v1/tenant/*`)\n**dan** outlet mana yang boleh disentuh. RBAC bersifat **dinamis per-tenant**:\n\n- **Role template sistem** (`admin` / `manager` / `staff`) — dibagikan ke\n  semua tenant, **tidak bisa diubah/dihapus**. Owner di-bootstrap sebagai\n  `admin` saat tenant dibuat.\n- **Role custom** — tiap tenant bisa **membuat role sendiri** + memilih\n  capability-nya (mis. `kasir`, `supervisor`), lalu menugaskannya ke member.\n\n**Capability** adalah unit izin (mis. `catalog:write`). Daftar capability\nbersifat tetap (di-define di kode, tiap key menggate route nyata) — yang\ndinamis adalah **role mana punya capability mana**. Ambil daftar lengkap +\narti tiap capability dari `GET /api/v1/tenant/capabilities`.\n\n## Capability \u0026 fitur (untuk frontend)\n\nFrontend sebaiknya gate menu dari **capability** (atau `features` turunannya),\nbukan dari nama role — supaya tetap sinkron saat role di-custom. Capability:\n\n| Capability | Cakupan | Template default |\n|---|---|---|\n| `order:fulfill` | proses order (accept/reject/final-price/advance/COD) | staff, manager, admin |\n| `catalog:write` | item, harga, promo, jam-operasi, media | manager, admin |\n| `outlet:manage` | edit outlet, buka/tutup, driver | manager, admin |\n| `outlet:lifecycle` | buat / hapus outlet | admin |\n| `member:read` | lihat daftar karyawan | manager, admin |\n| `member:write` | kelola karyawan (assign/invite/update/aktif-nonaktif/hapus) | admin |\n| `role:manage` | kelola role custom + izinnya | admin |\n| `tenant:write` | edit profil tenant | manager, admin |\n| `tenant:config` | sync | admin |\n| `outlet:all` | akses implisit SEMUA outlet (tanpa penugasan per-outlet) | manager, admin |\n\n## Outlet-scope\n\n- Role dengan `outlet:all` → akses implisit ke **semua** outlet.\n- Role tanpa `outlet:all` (mis. `staff`) → hanya boleh **mengoperasikan\n  order** untuk outlet yang\n  ditugaskan ke dirinya; aksi pada order outlet lain → `403`.\n- **Baca tetap tenant-wide** untuk semua role.\n\n## Penegakan \u0026 response\n\n- Aksi tanpa capability → `403` `insufficient role`.\n- Aksi pada outlet di luar assignment → `403` `no access to this outlet`.\n- Token platform-admin (`*`) **melewati semua gate**.\n- Route baca + bootstrap (`POST /api/v1/tenant/profile`) tidak di-gate.\n\n## Rollout (audit-mode)\n\nDikontrol env **`RBAC_ENFORCE`** (default `false`): audit-mode hanya **log**\nkeputusan deny (`[rbac] AUDIT would-deny …`) tanpa memblokir; `true` =\nenforce (`403`). Aktifkan setelah verifikasi tiap tenant punya member admin\nyang resolvable.\n","endpoints":[{"name":"My access","method":"GET","path":"/api/v1/tenant/me","auth":"JWT Bearer","description":"Ringkasan akses pemanggil — role, capability, `features` (key menu),\nstatus all-outlet, dan outlet yang ditugaskan. Dipakai frontend untuk\nmenyembunyikan menu. Berlaku juga untuk non-member (`is_member=false`,\ncapability kosong).\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\": \"01923c8b-1111-7000-a000-000000000001\",\n    \"is_member\": true,\n    \"is_active\": true,\n    \"role\": \"kasir\",\n    \"is_all_outlets\": false,\n    \"capabilities\": [\"order:fulfill\"],\n    \"features\": [\"orders\"],\n    \"outlet_ids\": [\"01923c8b-aaaa-7000-a000-0000000000a4\"]\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Capability catalog","method":"GET","path":"/api/v1/tenant/capabilities","auth":"JWT Bearer","description":"Katalog statis semua capability: `key`, `label`, `description`, dan\n`features` (key menu). Dipakai frontend untuk menafsirkan capability\nsebuah role + membangun editor role.\n","example_response":"{\n  \"success\": true,\n  \"data\": [\n    { \"key\": \"catalog:write\", \"label\": \"Kelola katalog \u0026 harga\", \"description\": \"Buat/ubah/hapus item, harga, promo, jam operasi, dan media outlet.\", \"features\": [\"pricing\",\"items\",\"promo\",\"operations\",\"media\"] },\n    { \"key\": \"order:fulfill\", \"label\": \"Proses order\", \"description\": \"Terima/tolak order, harga final, advance, COD.\", \"features\": [\"orders\"] }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"List roles","method":"GET","path":"/api/v1/tenant/roles","auth":"JWT Bearer","description":"Role template sistem + role custom milik tenant, masing-masing dengan\n`capabilities` + `is_system`. `id`-nya dipakai sebagai `role_id` saat\nassign/register/invite member.\n","example_response":"{\n  \"success\": true,\n  \"data\": [\n    { \"id\": \"01900000-0000-7fff-8fff-000000000010\", \"name\": \"admin\", \"is_system\": true, \"capabilities\": [\"order:fulfill\",\"member:read\",\"member:write\",\"outlet:manage\",\"outlet:lifecycle\",\"catalog:write\",\"tenant:write\",\"tenant:config\",\"outlet:all\",\"role:manage\"], \"created_at\": \"2026-05-26T00:00:00Z\", \"updated_at\": \"2026-05-26T00:00:00Z\" },\n    { \"id\": \"01923c8b-7777-7000-a000-000000000077\", \"name\": \"kasir\", \"is_system\": false, \"tenant_id\": \"01923c8b-...\", \"capabilities\": [\"order:fulfill\"], \"created_at\": \"2026-05-29T00:00:00Z\", \"updated_at\": \"2026-05-29T00:00:00Z\" }\n  ],\n  \"message\": \"success\"\n}\n"},{"name":"Create custom role","method":"POST","path":"/api/v1/tenant/roles","auth":"JWT Bearer","description":"Buat role custom milik tenant. Butuh capability `role:manage`. `name`\ntidak boleh sama dengan role sistem; tiap capability harus ada di\nkatalog (`400` kalau tidak dikenal).\n","example_body":"{\n  \"name\": \"kasir\",\n  \"capabilities\": [\"order:fulfill\"]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"01923c8b-7777-7000-a000-000000000077\", \"name\": \"kasir\", \"is_system\": false, \"tenant_id\": \"01923c8b-...\", \"capabilities\": [\"order:fulfill\"], \"created_at\": \"...\", \"updated_at\": \"...\" },\n  \"message\": \"role created\"\n}\n"},{"name":"Get role detail","method":"GET","path":"/api/v1/tenant/roles/{id}","auth":"JWT Bearer","description":"Detail satu role + capabilities. Dipakai role editor UI untuk pre-fill form sebelum edit. **404** kalau role bukan template sistem atau bukan milik tenant ini.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"01923c8b-7777-7000-a000-000000000077\",\n    \"name\": \"kasir\",\n    \"is_system\": false,\n    \"tenant_id\": \"01923c8b-1234-7000-a000-000000000001\",\n    \"capabilities\": [\"order:fulfill\", \"catalog:write\"],\n    \"created_at\": \"2026-05-29T10:00:00Z\",\n    \"updated_at\": \"2026-05-30T08:00:00Z\"\n  },\n  \"message\": \"success\"\n}\n"},{"name":"Update custom role","method":"PUT","path":"/api/v1/tenant/roles/{id}","auth":"JWT Bearer","description":"Ubah `name` / `capabilities` role custom (partial; `capabilities` non-null\n= ganti seluruhnya). Role sistem → `403`. Butuh `role:manage`.\n**Lockout-guard:** menurunkan `role:manage` ditolak `409` bila membuat\ntenant tak punya member aktif yang bisa kelola role.\n","example_body":"{\n  \"capabilities\": [\"order:fulfill\", \"catalog:write\"]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"01923c8b-7777-7000-a000-000000000077\", \"name\": \"kasir\", \"is_system\": false, \"capabilities\": [\"order:fulfill\",\"catalog:write\"], \"created_at\": \"...\", \"updated_at\": \"...\" },\n  \"message\": \"role updated\"\n}\n"},{"name":"Delete custom role","method":"DELETE","path":"/api/v1/tenant/roles/{id}","auth":"JWT Bearer","description":"Hapus role custom. Role sistem → `403`; role yang masih ditugaskan ke\nmember → `409` (reassign dulu). Butuh `role:manage`.\n","example_response":"{\n  \"success\": true,\n  \"data\": null,\n  \"message\": \"role deleted\"\n}\n"}]}],"screens":[{"id":"chat-list","icon":"💬","title":"Chat Inbox","description":"Daftar chat room — customer lihat chat ke outlet manapun, tenant lihat chat dari customer untuk outlet-nya. Mobile app render row per chat; `channel_id` dipakai untuk join WebSocket chat-service buat preview pesan terakhir.\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/chats","purpose":"List chat room caller","trigger":"On mount tab Chat + on focus refresh","auth":"required","notes":"Tenant + customer pakai endpoint sama; server deteksi role via JWT permission wacca-tenant:*."},{"method":"GET","path":"/api/v1/chats?outlet_id={id}","purpose":"Filter chat per outlet (kasus customer pilih outlet di header)","trigger":"On switch outlet selector","auth":"required"}]},{"id":"chat-detail","icon":"✉️","title":"Chat Detail","description":"Halaman chat ↔ outlet. Wacca cuma menyediakan `channel_id`; pesan, history, unread state semua dari **chat-service** (WebSocket / REST API chat-service). Buka chat baru lewat POST /chats (idempotent — kalau pair (outlet, customer) sudah ada, return chat existing).\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/chats/{id}","purpose":"Verify visibility + dapat channel_id","trigger":"On mount chat detail","auth":"required","notes":"404 kalau bukan visible scope. channel_id → join chat-service WebSocket."},{"method":"POST","path":"/api/v1/chats","purpose":"Bikin chat baru kalau belum ada (idempotent)","trigger":"On tap 'Chat Outlet' dari halaman outlet detail (customer)","auth":"required","notes":"Body {outlet_id}. customer_id auto dari JWT."}]},{"id":"customer-address-list","icon":"📍","title":"Daftar Alamat","description":"Daftar alamat customer dengan badge DEFAULT pada alamat utama. Urut `is_default DESC, created_at DESC` — default selalu di atas.\n\nFigma: [1477:130](https://www.figma.com/design/N2kzy9KHqHVFp3tg58uIqt/Wacca-Laundry?node-id=1477-130).\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/customer/address","purpose":"Load alamat customer (paginated)","trigger":"On mount","auth":"required"}]},{"id":"customer-address-form-create","icon":"➕","title":"Form Alamat — Tambah","description":"Form input alamat baru. `name`/`address`/`latitude`/`longitude`/`category`/`mobile_phone` wajib diisi. `category` dipilih dari quick-pick (Rumah/Kos/Kantor/Lainnya). Koordinat di-pick dari map picker.\n\nAlamat **pertama** otomatis di-default (tanpa perlu set `is_default=true`).\n\nFigma: [1477:199](https://www.figma.com/design/N2kzy9KHqHVFp3tg58uIqt/Wacca-Laundry?node-id=1477-199).\n","platform":["Mobile"],"calls":[{"method":"POST","path":"/api/v1/customer/address","purpose":"Submit alamat baru","trigger":"On tap 'Simpan'","auth":"required","notes":"Setelah sukses → kembali ke Daftar Alamat"}]},{"id":"customer-address-form-edit","icon":"✏️","title":"Form Alamat — Edit","description":"Partial update — hanya kirim field yang berubah. Server validasi ownership (403 kalau bukan milik customer).\n\nFigma: [1477:199](https://www.figma.com/design/N2kzy9KHqHVFp3tg58uIqt/Wacca-Laundry?node-id=1477-199) (sama dengan Form Tambah, pre-filled).\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/customer/address/{id}","purpose":"Pre-fill form dengan alamat existing","trigger":"On tap card alamat","auth":"required"},{"method":"PUT","path":"/api/v1/customer/address/{id}","purpose":"Submit perubahan","trigger":"On tap 'Simpan'","auth":"required"}]},{"id":"customer-address-delete-confirm","icon":"🗑️","title":"Konfirmasi Hapus","description":"Bottom-sheet konfirmasi hapus alamat. **Soft delete** — data tetap tersimpan; pesanan aktif yang refer ke alamat ini tidak terdampak.\n\nFigma: [1477:249](https://www.figma.com/design/N2kzy9KHqHVFp3tg58uIqt/Wacca-Laundry?node-id=1477-249).\n","platform":["Mobile"],"calls":[{"method":"DELETE","path":"/api/v1/customer/address/{id}","purpose":"Soft-delete alamat","trigger":"On tap 'Hapus' di konfirmasi","auth":"required"}]},{"id":"customer-address-set-default","icon":"⭐","title":"Jadikan Default","description":"Action di setiap card alamat (non-default) untuk jadikan default. Transactional: server unset semua default lain + set ini = true.\n","platform":["Mobile"],"calls":[{"method":"PATCH","path":"/api/v1/customer/address/{id}/set-default","purpose":"Jadikan alamat ini default","trigger":"On tap 'Jadikan Default'","auth":"required"}]},{"id":"customer-promo-list","icon":"🎟️","title":"Customer — Tab \"Promo Saya\"","description":"Halaman list voucher yang sudah di-klaim customer (mechanism=claim). 4 tab status pakai query `?status=` — server side render computed_status, jangan filter di FE.\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/customer/promo?status=active","purpose":"List voucher aktif (default tab)","trigger":"On mount tab Promo Saya","auth":"required"},{"method":"GET","path":"/api/v1/customer/promo?status=upcoming","purpose":"List voucher belum valid_from","trigger":"On switch tab 'Akan Datang'","auth":"required"},{"method":"GET","path":"/api/v1/customer/promo?status=expired","purpose":"List voucher expired/used","trigger":"On switch tab 'Riwayat'","auth":"required"}]},{"id":"customer-promo-outlet","icon":"🏷️","title":"Customer — Promo Outlet (di Outlet Detail)","description":"Strip / banner promo di halaman outlet detail. FE tampilkan badge per-promo + tombol \"Klaim\" untuk mechanism=claim (kalau `claimed=false`). Untuk mechanism=code, customer salin coupon_code. Untuk mechanism=automatic, tampilkan info \"Otomatis berlaku saat checkout\".\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/customer/outlets/{id}/promo","purpose":"Load promo aktif outlet (dengan flag claimed per-customer)","trigger":"On mount outlet detail","auth":"required","notes":"Path singular (PR #56). Field `claimed=true` → tombol 'Klaim' di-hide / ganti 'Sudah Diklaim'."},{"method":"POST","path":"/api/v1/customer/promo/{id}/claim","purpose":"Klaim promo (mechanism=claim) ke wallet customer","trigger":"On tap tombol 'Klaim'","auth":"required","notes":"`{id}` = `promotional_id`. 409 → refetch list (claim sudah ada / kuota habis).\n"}]},{"id":"customer-promo-detail","icon":"📜","title":"Customer — Detail Voucher","description":"Halaman detail satu voucher yang sudah diklaim (dari \"Promo Saya\"). Tampilkan T\u0026C, kuota tersisa, scope (outlet/service), dan tombol \"Pakai Sekarang\" yang deeplink ke list outlet eligible.\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/customer/promo/{id}","purpose":"Detail customer_coupon + T\u0026C lengkap","trigger":"On mount detail voucher","auth":"required","notes":"`{id}` = `customer_coupon.id`, BUKAN `promotional_id`."}]},{"id":"customer-rating-pending","icon":"⭐","title":"Customer — Tab \"Belum Ulas\"","description":"Tab di halaman \"Ulasan Saya\" yang list order completed customer yang belum di-rating. Card per order dengan tombol \"Beri Ulasan\" untuk navigate ke form.\n\nBanner reward \"Berikan ulasan, dapatkan poin!\" tampil di atas list — saat ini static (backend points wallet belum ada, lihat known gap).\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/customer/outlet-ratings/pending","purpose":"List order completed yang belum di-rating (untuk card per row)","trigger":"On mount tab Belum Ulas","auth":"required","notes":"Response: PendingPresent[] dengan order_id, order_number, outlet_name, service_names[], created_at."},{"method":"GET","path":"/api/v1/customer/outlet-ratings/summary","purpose":"Count \"Riwayat (N)\" di tab sebelah","trigger":"On mount + after submit rating","auth":"required","notes":"Pakai field `count`."}]},{"id":"customer-rating-history","icon":"📋","title":"Customer — Tab \"Riwayat\" Ulasan","description":"Tab list review yang sudah customer submit. Header stat card menampilkan average rating yang dikasih + total helpful received. Card per row enriched dengan outlet_name, services, star, tag chips (Bersih/Cepat/dst), comment, photo grid, helpful count.\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/customer/outlet-ratings/summary","purpose":"Header stat (avg + helpful received)","trigger":"On mount tab Riwayat","auth":"required","notes":"Field: average, count, helpful_received."},{"method":"GET","path":"/api/v1/customer/outlet-ratings?page=1\u0026limit=20","purpose":"List review customer (enriched outlet_name, service_names, photos, helpful_count)","trigger":"On mount + pagination scroll","auth":"required"}]},{"id":"customer-rating-form","icon":"✍️","title":"Customer — Form Ulasan","description":"Form lengkap untuk submit review. 5-star tap rating + 8 tag chips multi-select (whitelist case-sensitive) + comment textarea ≤255 char. Photo upload UI **harus di-hide / disabled** sampai backend Phase 2 PR #59 merge.\n","platform":["Mobile"],"calls":[{"method":"POST","path":"/api/v1/customer/outlet-ratings","purpose":"Submit review baru","trigger":"On tap 'Kirim Ulasan' setelah validasi client","auth":"required","notes":"Body {outlet_id, scale, review, likes[]}. Aspect chips harus exact match whitelist (Bersih/Cepat/Wangi/Rapi/Ramah/Berkualitas/Sesuai Harga/Tepat Waktu — capital case). FE enforce scale ≥1 (backend masih accept 0, akan di-fix).\n"}]},{"id":"customer-rating-helpful","icon":"👍","title":"Customer — Vote Helpful di Review Orang Lain","description":"Saat customer baca review orang di outlet detail / profile customer lain, bisa tap 👍 untuk mark \"merasa terbantu\". Toggle: tap lagi = unvote. Counter naik/turun realtime.\n","platform":["Mobile"],"calls":[{"method":"POST","path":"/api/v1/customer/outlet-ratings/{id}/helpful","purpose":"Vote helpful di review orang lain","trigger":"On tap 👍 (icon belum berisi)","auth":"required","notes":"Idempoten — vote 2x = no-op. Response: {rating_id, helpful_count, voted: true}."},{"method":"DELETE","path":"/api/v1/customer/outlet-ratings/{id}/helpful","purpose":"Tarik kembali vote","trigger":"On tap 👍 (icon sudah berisi)","auth":"required","notes":"Idempoten. Response: {rating_id, helpful_count, voted: false}."}]},{"id":"owner-notification-bootstrap","icon":"🔔","title":"Owner — Bootstrap Notifikasi (App Mount + Login)","description":"Setup sekali per session: pastikan FCM token terdaftar di wacca + JWT siap untuk hit notification-service. Logout flow: unregister token dulu, baru clear JWT.\n","platform":["Mobile"],"calls":[{"method":"POST","path":"/api/v1/tenant/device-tokens","purpose":"Register FCM token owner ke wacca","trigger":"After login + on every FCM token refresh (Firebase SDK onTokenRefresh)","auth":"required","notes":"Body `{token, platform: \"android\"|\"ios\"|\"web\"}`. Token MENTAH dari `getToken()`, tanpa prefix `fcm:`. 200 baru terdaftar, 409 sudah terdaftar untuk akun ini (aman, jangan retry), 400 malformed.\n"},{"method":"DELETE","path":"/api/v1/tenant/device-tokens","purpose":"Unregister token sebelum clear JWT","trigger":"On tap Logout (sebelum hapus session)","auth":"required","notes":"Body `{token}`. Idempoten (200 walau token sudah ga ada)."}]},{"id":"owner-notification-inbox","icon":"📬","title":"Owner — Inbox Tab + Badge","description":"Tab \"Notifikasi\" di app owner — list semua notif tenant (group inbox + per-staff). Shared read-state: kalau owner baca, staff lain juga ke-mark read.\n\n**Endpoint live di notification-service** (`https://notification.ikavia.com`), BUKAN wacca. Header `X-Platform: wacca` wajib. JWT owner yang sama dipakai (notif-service trust account-service signature).\n\n**Render aturan** (per record):\n- `payload.actions[]` ada → tampilkan tombol (`approve_price`/`reject_price`, `member_accept`/`member_reject`)\n- `payload.actions[]` kosong + `payload.metadata.state` ada → render badge settled (sudah disetujui/ditolak/diterima)\n- `channels[].fcm.status` → diagnose kenapa push gak nyampe (lihat troubleshoot table di atas)\n","platform":["Mobile"],"calls":[{"method":"GET","path":"https://notification.ikavia.com/api/v1/notifications/unread-count","purpose":"Badge angka di bottom-tab Notifikasi","trigger":"On app mount + polling 30s di foreground","auth":"required","notes":"Header `X-Platform: wacca`. Owner principal = human → otomatis ke-scope ke `target_id=claim.sub`. Group inbox notif (target_type=group) ikut dihitung berdasar membership roster (synced otomatis dari wacca).\n"},{"method":"GET","path":"https://notification.ikavia.com/api/v1/notifications?limit=20\u0026offset=0","purpose":"Daftar inbox (latest first)","trigger":"On open tab + pull-to-refresh + infinite scroll","auth":"required","notes":"Query opsional: `category=order_new` (filter), `unread-only=true`. Response `data[]` include `payload` verbatim + `channels[]` status. Group inbox owner notif (mis. `order_new`) muncul di sini karena roster sync.\n"},{"method":"POST","path":"https://notification.ikavia.com/api/v1/notifications/{id}/read","purpose":"Tandai notif sudah dibaca (shared state — staff lain juga ke-mark)","trigger":"On user tap notif row","auth":"required"},{"method":"POST","path":"https://notification.ikavia.com/api/v1/notifications/read-all","purpose":"Mark-all-as-read","trigger":"On tap 'Tandai semua dibaca'","auth":"required"}]},{"id":"owner-notification-interactive","icon":"✋","title":"Owner — Tap Tombol Aksi (Approve Price / Accept Invite)","description":"Saat notif punya `payload.actions[]`, FE render tombol → tap → call wacca API yang sesuai. Setelah sukses, wacca re-publish notif dengan id sama → tombol hilang otomatis di next refresh (badge settled muncul). FE TIDAK panggil notif-service mark-read manual untuk action — wacca yang handle.\n","platform":["Mobile"],"calls":[{"method":"POST","path":"/api/v1/tenant/orders/{order_id}/respond-price","purpose":"Tap \"Setujui\"/\"Tolak\" pada notif order_price_proposed (action.key = `approve_price`|`reject_price`)","trigger":"On tap action button di notif order_price_proposed (customer-side notif — owner tidak terima ini, ini untuk reference customer flow)","auth":"required","notes":"Body sesuai action: `{action: \"approve\"|\"reject\", reason?}`. Resolve `order_id` dari `payload.metadata.order_id`.\n"},{"method":"POST","path":"/api/v1/tenant/invitations/{invitation_id}/accept","purpose":"Tap \"Terima\" pada notif member_invited (action.key = `member_accept`)","trigger":"On tap action button di notif member_invited","auth":"required","notes":"Resolve `invitation_id` dari `payload.metadata.invitation_id`. Wacca akan re-publish notif dengan id sama, `actions[]` kosong, body \"Kamu telah menerima…\", `metadata.state=\"accepted\"`.\n"},{"method":"POST","path":"/api/v1/tenant/invitations/{invitation_id}/reject","purpose":"Tap \"Tolak\" pada notif member_invited (action.key = `member_reject`)","trigger":"On tap action button di notif member_invited","auth":"required"}]},{"id":"owner-notification-preferences","icon":"⚙️","title":"Owner — Setelan Notifikasi (opsional)","description":"Halaman setelan untuk mute notif per (kategori, channel). Owner-bound notifs `target_type=group` selalu bypass preferences (by design — owner harus tetap dapat) — preferences hanya efektif untuk customer-side categories yang non-mandatory (mis. `order_rating_prompt`).\n","platform":["Mobile"],"calls":[{"method":"GET","path":"https://notification.ikavia.com/api/v1/preferences/options","purpose":"Katalog channel + kategori yang bisa di-mute","trigger":"On mount setelan notifikasi","auth":"required","notes":"Authoritative — apa pun yang muncul di sini pasti diterima PUT preferences."},{"method":"GET","path":"https://notification.ikavia.com/api/v1/preferences","purpose":"Load preferensi saat ini (`muted` map kategori→channels)","trigger":"On mount setelan","auth":"required"},{"method":"PUT","path":"https://notification.ikavia.com/api/v1/preferences","purpose":"Replace seluruh preferensi (bukan merge)","trigger":"On tap 'Simpan'","auth":"required","notes":"Body `{\"muted\": {\"order_rating_prompt\": [\"fcm\"]}}`. Kategori `\"*\"` = global mute (semua kategori). Channel valid: `inapp`/`email`/`fcm`/`telegram`. Notif mandatory + target_type=group **tetap kirim** walau di-mute.\n"}]},{"id":"owner-orders-inbox","icon":"📥","title":"Owner — Inbox Order Masuk","description":"Daftar order baru (`status=pending`) — owner accept / reject. Polling tiap 10–15 detik (atau pakai push notif `order_new`) untuk update unread badge.\n","platform":["Mobile","Web"],"calls":[{"method":"GET","path":"/api/v1/tenant/orders?status=pending\u0026page=1\u0026limit=20","purpose":"List order pending untuk owner","trigger":"On mount + interval polling (15s) atau on push notif order_new","auth":"required"},{"method":"POST","path":"/api/v1/tenant/orders/{id}/accept","purpose":"Owner terima order","trigger":"On tap 'Terima'","auth":"required","notes":"→ status=accepted. Lanjut ke screen pickup atau verify-final-price."},{"method":"POST","path":"/api/v1/tenant/orders/{id}/reject","purpose":"Owner tolak order pending (HANYA saat status=pending)","trigger":"On tap 'Tolak' + isi alasan","auth":"required","notes":"Body {reason:'...'}. Setelah accepted, batal lewat komplain."}]},{"id":"owner-pickup-tracking","icon":"🛵","title":"Owner — Tracking Pickup (Driver Jemput)","description":"Hanya untuk order `delivery_method=pickup`. Owner mark driver berangkat dan tiba di lokasi customer. Mengaktifkan SetFinalPrice gate (Anomali #1 / GAP A).\n","platform":["Mobile"],"calls":[{"method":"POST","path":"/api/v1/tenant/orders/{id}/pickup-dispatch","purpose":"Catat driver berangkat","trigger":"On tap 'Driver Berangkat'","auth":"required","notes":"Push notif ke customer 'Driver sedang menuju lokasi'."},{"method":"POST","path":"/api/v1/tenant/orders/{id}/pickup-arrived","purpose":"Catat driver tiba + pickup selesai","trigger":"On tap 'Pesanan Terambil'","auth":"required","notes":"Push notif ke customer 'Pesanan sudah diambil'."}]},{"id":"owner-verify-final-price","icon":"⚖️","title":"Owner — Timbang \u0026 Set Harga Final","description":"Setelah pesanan di tangan (pickup-arrived ATAU customer datang antar self_dropoff), owner timbang setiap baris service, opsional foto verifikasi, lalu ajukan harga final ke customer.\n\n**Verify gate berbeda berdasarkan delivery_method**:\n- `pickup` → cukup `pickup-arrived`; verify-complete TIDAK perlu.\n- `self_dropoff` → wajib panggil verify-complete sebelum final-price.\n","platform":["Mobile"],"calls":[{"method":"PATCH","path":"/api/v1/tenant/order-list/{id}/set-final-quantity","purpose":"Set berat aktual per baris service (auto-set order_list.verified_at)","trigger":"On tap 'Simpan' di form timbang per baris","auth":"required","notes":"Body {final_quantity:3.2, note?:'...'}. Panggil sekali per order_list service."},{"method":"POST","path":"/api/v1/tenant/orders/{id}/verification-photos","purpose":"Upload foto pakaian/struk timbangan (opsional, audit)","trigger":"On tap 'Foto' lalu kirim","auth":"required"},{"method":"POST","path":"/api/v1/tenant/orders/{id}/verify-complete","purpose":"Kunci verifikasi (HANYA self_dropoff). 409 kalau ada baris belum di-set-final-quantity.","trigger":"On tap 'Lanjut Set Harga' (kalau self_dropoff)","auth":"required","notes":"Untuk pickup, skip langsung ke final-price."},{"method":"POST","path":"/api/v1/tenant/orders/{id}/final-price","purpose":"Ajukan harga final ke customer (hitung PPN dari setting, fallback 0 kalau belum ter-setting)","trigger":"On tap 'Ajukan Harga'","auth":"required","notes":"→ status=price_proposed. Customer akan terima notif untuk approve/reject."}]},{"id":"owner-order-progress","icon":"🔁","title":"Owner — Proses Order (Advance Status)","description":"Setelah customer approve-price → status `processing`. Owner advance ke `ready_pickup` (customer ambil di outlet) atau `delivering` (driver antar). Bayar tunai dicatat via confirm-cod.\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/tenant/orders/{id}","purpose":"Detail order untuk render UI (status, items, total, payment_method)","trigger":"On mount + after each action","auth":"required"},{"method":"POST","path":"/api/v1/tenant/orders/{id}/advance","purpose":"Maju ke status berikut","trigger":"On tap 'Siap Dijemput' / 'Antar Sekarang' / 'Selesai'","auth":"required","notes":"Body {target:'ready_pickup'|'delivering'|'completed'}.\nCatatan: target=completed dari delivering DIBLOKIR (customer yang confirm-receipt). Dari ready_pickup → completed boleh (serah-terima outlet).\n"},{"method":"POST","path":"/api/v1/tenant/orders/{id}/confirm-cod","purpose":"Konfirmasi uang tunai diterima (idempoten per order)","trigger":"On tap 'Sudah Dibayar Tunai'","auth":"required","notes":"Tidak mengubah fulfilment status. Boleh dipanggil pre- atau post-completed."}]},{"id":"customer-checkout","icon":"🛒","title":"Customer — Checkout / Buat Order","description":"Customer pilih items, optional preview kupon, lalu commit POST order.\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/payment-methods","purpose":"Load dropdown metode pembayaran (filter is_active=true \u0026 is_offline=false untuk online)","trigger":"On mount checkout","auth":"required"},{"method":"GET","path":"/api/v1/customer/orders/pickup-quote?outlet_id={id}\u0026pickup_address_id={id}","purpose":"Preview pickup fee untuk delivery_method=pickup","trigger":"On select delivery_method=pickup + alamat","auth":"required"},{"method":"POST","path":"/api/v1/customer/orders/coupon-preview","purpose":"Preview diskon kupon sebelum apply","trigger":"On input kode kupon + tap 'Cek'","auth":"required","notes":"Response {subtotal, discount_amount, final_total, is_free_shipping}. Untuk free_shipping, FE flip label 'Diskon' → 'Gratis Ongkir'."},{"method":"POST","path":"/api/v1/customer/orders","purpose":"Submit order","trigger":"On tap 'Bayar' / 'Pesan'","auth":"required","notes":"Body wajib payment_method_id (UUID, bukan string legacy). Optional addons[], photo_media_ids[]."}]},{"id":"customer-order-tracking","icon":"📍","title":"Customer — Tracking Order","description":"Customer lihat progress order — status header + timeline visual + pickup-status (kalau pickup). Listen push notif untuk auto-refresh.\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/customer/orders/{id}","purpose":"Detail order (status, payment_method_id, pickup_status, dst)","trigger":"On mount + on push notif","auth":"required"},{"method":"GET","path":"/api/v1/customer/orders/{id}/timeline","purpose":"8-step progress array siap render (created → accepted → pickup_dispatched/arrived → price_proposed → processing → handover → completed)","trigger":"On mount + after each push notif","auth":"required","notes":"Lebih murah daripada parse status sendiri. Step status enum: done|pending|skipped. Variant 'ready_pickup' atau 'delivering' di step handover."}]},{"id":"customer-approve-final-price","icon":"💰","title":"Customer — Setujui / Tolak Harga Final","description":"Notif `order_price_proposed` masuk → customer buka detail → setuju / tolak. Tolak harga = batal order dengan reject_reason.\n","platform":["Mobile"],"calls":[{"method":"POST","path":"/api/v1/customer/orders/{id}/approve-price","purpose":"Setuju harga final","trigger":"On tap 'Setuju' di card harga","auth":"required","notes":"→ status=processing (auto). Owner langsung mulai laundry."},{"method":"POST","path":"/api/v1/customer/orders/{id}/reject-price","purpose":"Tolak harga final + alasan","trigger":"On tap 'Tolak' + input alasan","auth":"required","notes":"Body {reason:'...'}. → status=cancelled, cancelled_by='customer'."}]},{"id":"customer-confirm-receipt","icon":"📦","title":"Customer — Konfirmasi Penerimaan","description":"Untuk delivery_method=pickup dengan target `delivering` (driver antar). Customer tap 'Sudah Diterima' setelah pakaian sampai di tangan → status=completed.\n\nUntuk `ready_pickup` (customer ambil di outlet), owner yang advance ke completed — FE customer tidak perlu hit endpoint ini.\n","platform":["Mobile"],"calls":[{"method":"POST","path":"/api/v1/customer/orders/{id}/confirm-receipt","purpose":"Tutup lifecycle order","trigger":"On tap 'Sudah Saya Terima' (visible kalau status=delivering)","auth":"required"}]},{"id":"customer-cancel-order","icon":"🚫","title":"Customer — Batal Order","description":"Cancel order milik customer sebelum dikerjakan (`pending` atau `price_proposed`). Setelah `processing`, harus lewat alur komplain (lihat [Complaint](#complaint)).\n","platform":["Mobile"],"calls":[{"method":"POST","path":"/api/v1/customer/orders/{id}/cancel","purpose":"Batalkan order","trigger":"On tap 'Batalkan Pesanan' + konfirmasi","auth":"required","notes":"→ status=cancelled. 409 kalau status sudah processing ke atas."}]},{"id":"tenant-outlet-address-setup","icon":"🏪","title":"Setting Alamat Outlet","description":"Form set-up alamat outlet (saat create) atau edit. Alamat outlet dipakai untuk hitung ongkir per-order (jarak outlet ↔ alamat customer) dan ditampilkan di customer-side discovery.\n\n**Flow cascading**: pilih provinsi → city dropdown enable → pilih city → subdistrict dropdown enable → pilih subdistrict. Setelah itu pin lokasi tepat di map picker untuk `latitude`/`longitude`. `address` (jalan) + `location` (catatan/landmark) di-input manual.\n","platform":["Web"],"calls":[{"method":"GET","path":"/api/v1/locations/provinces","purpose":"Load dropdown provinsi","trigger":"On mount form","auth":"required"},{"method":"GET","path":"/api/v1/locations/provinces/{id}/cities","purpose":"Cascade — load kota saat province dipilih","trigger":"On change province dropdown","auth":"required"},{"method":"GET","path":"/api/v1/locations/cities/{id}/subdistricts","purpose":"Cascade — load kecamatan saat city dipilih","trigger":"On change city dropdown","auth":"required"},{"method":"POST","path":"/api/v1/tenant/outlet","purpose":"Submit outlet baru (mode create)","trigger":"On tap 'Simpan'","auth":"required","notes":"Sertakan province_id, city_id, subdistrict_id, latitude, longitude"},{"method":"PUT","path":"/api/v1/tenant/outlet/{outlet-id}","purpose":"Update alamat outlet (mode edit)","trigger":"On tap 'Simpan' di edit form","auth":"required"}]},{"id":"checkout-payment-dropdown","icon":"💳","title":"Customer — Dropdown Pilih Pembayaran (Checkout)","description":"Dropdown di halaman checkout customer untuk pilih metode bayar. Filter:\n- `is_active=true` (selalu)\n- `is_offline=false` (online via payment-service), kecuali kalau outlet/customer support COD → tampilkan juga `cod`.\n\nGrouping by `code` (mis. semua QRIS aggregator jadi 1 item \"QRIS\"); detail provider muncul di subtitle.\n\nCustomer pilih → FE simpan `id` (UUID) → kirim sebagai `payment_method_id` saat POST /customer/orders.\n","platform":["Mobile"],"calls":[{"method":"GET","path":"/api/v1/payment-methods","purpose":"Load semua metode + filter di FE","trigger":"On mount checkout","auth":"required","notes":"Cache 5–10 menit di client; daftar metode jarang berubah."}]},{"id":"walkin-payment-dropdown","icon":"🧾","title":"Owner — Dropdown Pilih Pembayaran (Walk-in / Kasir)","description":"Dropdown di halaman walk-in / kasir. **Filter hanya `is_offline=true`** (cash, cod) — metode online tidak boleh dipakai walk-in (server tolak 400).\n","platform":["Mobile","Web"],"calls":[{"method":"GET","path":"/api/v1/payment-methods","purpose":"Load metode, filter is_offline=true di FE","trigger":"On mount walk-in form","auth":"required"}]},{"id":"owner-walkin-create","icon":"🏪","title":"Owner — Kasir Walk-in","description":"Form kasir untuk customer datang langsung (guest, tanpa akun Wacca). Owner pilih outlet, isi nama+phone tamu (opsional), pilih items + final_quantity, pilih metode bayar (offline only), submit dengan `idempotency_key` untuk mencegah submit ganda saat double-tap atau retry network.\n","platform":["Mobile","Web"],"calls":[{"method":"GET","path":"/api/v1/payment-methods","purpose":"Load metode bayar (filter is_offline=true)","trigger":"On mount form","auth":"required"},{"method":"GET","path":"/api/v1/tenant/cod-debt","purpose":"Cek saldo utang COD untuk gate COD","trigger":"On select payment_method=cod (atau on mount)","auth":"required","notes":"Kalau ada utang aktif, server tolak POST walk-in COD dengan 403. UI bisa pre-disable opsi COD di dropdown."},{"method":"POST","path":"/api/v1/tenant/walk-in","purpose":"Submit walk-in order","trigger":"On tap 'Simpan'","auth":"required","notes":"Body wajib idempotency_key (UUID/ULID di-generate FE). Submit yang sama 2x → server return order yang sama (200 OK, bukan duplicate).\npayment_method_id wajib UUID metode is_offline=true; metode online ditolak 400.\n"}]},{"id":"owner-walkin-link-account","icon":"🔗","title":"Owner — Link Order Guest ke Akun Customer","description":"Action sekunder dari halaman detail order walk-in. Saat customer baru daftar / login pasca-checkout, owner cari customer (by phone/email) lalu tap \"Kaitkan ke Akun\". Order pindah dari guest ke akun terdaftar — customer bisa lihat di riwayatnya, kasih rating, dst.\n\nGuard: order hanya bisa di-link sekali (server tolak 400 kalau sudah ter-link). Hanya berlaku untuk order yang berasal dari walk-in (punya `guest_name`).\n","platform":["Mobile","Web"],"calls":[{"method":"GET","path":"/api/v1/tenant/customers?search={phone_or_email}","purpose":"Cari customer terdaftar untuk pilih target link","trigger":"On user ketik di search box modal","auth":"required","notes":"Resolusi customer (akun terdaftar di tenant ini) — lihat section Tenant Customer."},{"method":"POST","path":"/api/v1/tenant/walk-in/{id}/link-account","purpose":"Kaitkan order guest ke customer terdaftar","trigger":"On tap 'Kaitkan' setelah pilih customer di modal","auth":"required","notes":"Body {customer_id: UUID}. 400 kalau order sudah ter-link / bukan walk-in. Setelah sukses, refresh detail order — field customer_id sekarang terisi, customer bisa akses order di app-nya.\n"}]},{"id":"tenant-promo-list","icon":"🎯","title":"Owner — Daftar Promo + Summary","description":"Halaman list promo milik tenant + ringkasan counter per-status. FE pakai `summary_count` di header tab, lalu list pakai filter `status=`. Action button per-row dirender dari `allowed_actions[]` (didapat saat fetch detail) — di list cukup tombol \"Lihat\".\n","platform":["Mobile","Web"],"calls":[{"method":"GET","path":"/api/v1/tenant/promo/summary","purpose":"Header — counter per tab (active/scheduled/ended/draft) + total subsidi bulan ini","trigger":"On mount Promo page","auth":"required"},{"method":"GET","path":"/api/v1/tenant/promo?status=active\u0026page=1\u0026limit=20","purpose":"List promo per tab","trigger":"On mount + on switch tab + scroll bawah (pagination)","auth":"required","notes":"Filter tambahan: `promo_type`, `mechanism`, `q` (search by name/code). Tampilkan `usage_percentage`, `budget_percentage` sebagai progress bar."}]},{"id":"tenant-promo-create","icon":"✨","title":"Owner — Form Buat / Edit Promo","description":"Form multi-step yang ngumpulin: tipe promo (5 pilihan) → mekanisme (3 pilihan) → outlet scope → service requirement → customer criteria → kuota/budget → masa berlaku. Field nilai conditional per `promo_type` (mis. percentage → `discount_percent_value`/`max`).\n\n**Pola \"Save Draft\" vs \"Save \u0026 Publish\"** — FE punya 2 tombol di akhir form:\n- \"Simpan Draft\" → `POST /tenant/promo` (status tetap `draft`)\n- \"Simpan \u0026 Publish\" → `POST /tenant/promo/save-and-publish` dengan `publish=true` (langsung ke `scheduled`/`active`).\n\nSaat edit (`id` ada), pakai `save-and-publish` dengan `id` di body untuk update+publish atomic, atau `PUT /tenant/promo/{id}` untuk update saja.\n","platform":["Mobile","Web"],"calls":[{"method":"GET","path":"/api/v1/tenant/outlet","purpose":"List outlet milik tenant — untuk picker \"outlet_ids[]\"","trigger":"On step 'Outlet Scope' kalau pilih 'selected'","auth":"required","notes":"Atau pakai endpoint helper di bawah kalau sudah pilih service requirement."},{"method":"GET","path":"/api/v1/tenant/promo/outlets/available?service_ids={ids}\u0026require_all=true","purpose":"List outlet + flag eligibility per service yang dibutuhkan promo","trigger":"On step pilih outlet, setelah service requirement diisi","auth":"required","notes":"Outlet `is_eligible=false` di-disable di picker dengan reason di tooltip."},{"method":"GET","path":"/api/v1/tenant/outlet-services","purpose":"Picker service untuk `required_service_ids[]`, `free_service_ids[]`, `bundle_service_ids[]`","trigger":"On step service requirement","auth":"required"},{"method":"POST","path":"/api/v1/tenant/promo","purpose":"Simpan draft","trigger":"On tap 'Simpan Draft'","auth":"required","notes":"Validasi server: coupon_code unique per tenant lower-case; nilai sesuai promo_type."},{"method":"POST","path":"/api/v1/tenant/promo/save-and-publish","purpose":"Simpan + langsung publish (create atau update)","trigger":"On tap 'Simpan \u0026 Publish'","auth":"required","notes":"Body include `publish: true` + `id` (opsional, untuk update existing)."}]},{"id":"tenant-promo-detail","icon":"📋","title":"Owner — Detail Promo + Action Buttons","description":"Halaman detail satu promo. FE render tombol action dari `allowed_actions[]`. State machine:\n- `draft` → [\"publish\", \"delete\"]\n- `scheduled` → [\"pause\", \"cancel\", \"publish-now\"]\n- `active` → [\"pause\", \"end\"]\n- `paused` → [\"resume\", \"cancel\"]\n- `ended` → [\"duplicate\", \"delete\"]\n\n`info_banner` tampilkan warning kontekstual (mis. \"Budget hampir habis\").\n","platform":["Mobile","Web"],"calls":[{"method":"GET","path":"/api/v1/tenant/promo/{id}","purpose":"Detail + relasi (outlets, services, free_services, bundle_items) + allowed_actions + performa","trigger":"On mount detail","auth":"required"},{"method":"PATCH","path":"/api/v1/tenant/promo/{id}/publish","purpose":"Publish dari draft","trigger":"On tap 'Publish'","auth":"required"},{"method":"PATCH","path":"/api/v1/tenant/promo/{id}/pause","purpose":"Pause active/scheduled","trigger":"On tap 'Pause'","auth":"required"},{"method":"PATCH","path":"/api/v1/tenant/promo/{id}/resume","purpose":"Resume paused","trigger":"On tap 'Resume'","auth":"required"},{"method":"PATCH","path":"/api/v1/tenant/promo/{id}/end","purpose":"Akhiri active sekarang","trigger":"On tap 'Akhiri Sekarang'","auth":"required"},{"method":"PATCH","path":"/api/v1/tenant/promo/{id}/cancel","purpose":"Batal scheduled/active/paused","trigger":"On tap 'Batalkan'","auth":"required"},{"method":"POST","path":"/api/v1/tenant/promo/{id}/duplicate","purpose":"Bikin draft baru dari template promo ini (coupon_code dikosongkan)","trigger":"On tap 'Duplicate'","auth":"required"},{"method":"DELETE","path":"/api/v1/tenant/promo/{id}","purpose":"Soft delete (hanya draft/ended)","trigger":"On tap 'Hapus' dengan confirm dialog","auth":"required"}]},{"id":"tenant-rating-list","icon":"⭐","title":"Owner — Daftar Ulasan \u0026 Rating","description":"Halaman list review customer ke outlet tenant. 3 lapis filter: outlet chips (Semua/per-outlet) + summary card (avg + count + 5-bar distribution) + star chips (Semua/5/4/Komplain).\n\nRender card per review dengan avatar 2-huruf inisial (FE derive dari nama), tanggal+order#, 5-star, comment, badge \"Belum dibaca\" kalau `is_read=false`.\n","platform":["Mobile","Web"],"calls":[{"method":"GET","path":"/api/v1/tenant/outlet","purpose":"Load outlet list untuk render chip \"Semua / Pusat / Outlet 1 / Outlet 2\"","trigger":"On mount halaman","auth":"required","notes":"Atau bisa cache hasil tenant-summary."},{"method":"GET","path":"/api/v1/tenant/outlet/outlet-ratings/summary","purpose":"Render summary card (avg + count + distribution 5-bar)","trigger":"On mount + on switch outlet filter","auth":"required","notes":"Param ?outlet_id={uuid} atau kosong = semua. Render distribution[] sebagai 5 bar chart dengan percent."},{"method":"GET","path":"/api/v1/tenant/outlet/outlet-ratings?outlet_id={id}\u0026star={n}\u0026page=1\u0026limit=20","purpose":"List review dengan filter","trigger":"On mount + switch outlet/star chip + pagination scroll","auth":"required","notes":"Filter \"Komplain\" ⚠️ gap — fallback: 2× request paralel (?star=1 + ?star=2) lalu merge sort, ATAU hide chip sampai backend ready (PR #59 Phase 1).\nCard per row: tap → navigate ke detail screen.\n"}]},{"id":"tenant-rating-detail","icon":"🔍","title":"Owner — Detail Ulasan","description":"Halaman detail satu review. Render reviewer card (avatar+name+order#+date+outlet), 5-star, \"Ulasan\" text + photo grid, \"Detail Pesanan Terkait\" card (services/weight/price), sentiment badge dinamis (positive=😊 hijau / neutral=😐 kuning / negative=😞 merah).\n\nAuto mark-read on mount supaya badge \"Belum dibaca\" di list hilang saat user kembali.\n","platform":["Mobile","Web"],"calls":[{"method":"GET","path":"/api/v1/tenant/outlet/outlet-ratings/{id}","purpose":"Load detail review lengkap dengan enrichment","trigger":"On mount detail screen","auth":"required","notes":"Response include reviewer_name, sentiment, helpful_count, photos[], order_context dengan services/weight/price.\n"},{"method":"POST","path":"/api/v1/tenant/outlet/outlet-ratings/{id}/read","purpose":"Mark sudah dibaca owner (badge \"Belum dibaca\" hilang)","trigger":"On mount detail (best-effort, idempoten)","auth":"required","notes":"Fire-and-forget — 200 walau sudah ke-mark sebelumnya. After back, list refresh + badge gone."}]}],"permissions":[{"name":"wacca-tenant:*","description":"Full access ke /tenant + semua child resource (outlet, items, prices, media, operations)."},{"name":"wacca-customer:*","description":"Full access ke /customer."},{"name":"*","description":"Wildcard owner/platform-admin — bypass semua gate."}],"constraints":["1 organisasi (JWT `org_id`) = maksimal 1 tenant. POST /tenant kedua → 400 'your account already registered'.","Customer = account (JWT `sub`); tidak ada tabel customer. POST /customer idempotent (memastikan default address), bukan 400.","Outlet tidak punya quota — tenant bisa create unlimited (rate limited by nginx).","Setiap entitas pakai UUIDv7 (time-sortable) sebagai PK; client tidak boleh men-supply ID.","Soft delete: list endpoint tidak mengembalikan row dengan `deleted_at` terisi. DELETE tidak benar-benar menghapus.","is_active flag dipakai untuk soft archive (default true); jangan dipakai untuk toggle visibility per-session — itu `is_opening`."],"flow_diagram_nodes":[{"id":"web","label":"🌐 wacca-web-admin","type":"client","color":"#0ea5e9","position":{}},{"id":"wacca","label":"🧺 wacca-service","type":"service","color":"#4f46e5","position":{}},{"id":"account","label":"🔐 account-service","type":"service","color":"#360185","position":{}},{"id":"pg","label":"🐘 PostgreSQL","type":"data","color":"#10b981","position":{}},{"id":"jwt","label":"🔑 JWT (RS256)","type":"data","color":"#f59e0b","position":{}}],"flow_diagram_edges":[{"source":"web","target":"wacca","label":"REST","animated":true,"color":"#0ea5e9"},{"source":"web","target":"account","label":"login/refresh","animated":true,"color":"#0ea5e9"},{"source":"account","target":"jwt","label":"issues","color":"#f59e0b"},{"source":"wacca","target":"jwt","label":"verify (JWKS)","color":"#f59e0b"},{"source":"wacca","target":"pg","label":"tenant, outlet, item, price, media","color":"#4f46e5"}],"api_tester_defaults":{"methods":["GET","POST","PUT","PATCH","DELETE"],"auth_modes":[{"name":"JWT Bearer","header":"Authorization","prefix":"Bearer ","placeholder":"YOUR_JWT_TOKEN_HERE"}]},"theme":{"title":"WACCA · Ikavia","logo_icon":"🧺","primary_color":"#4f46e5"}}