# Merged spec generated by docs-generator.
# This document is the effective YAML behind the rendered page —
# consolidated from every file in the source directory.
# project: wacca
# yaml-language-server: $schema=/docs/../schemas/spec.schema.json

info:
    title: WACCA Service
    version: 0.1.0
    description: |
        HTTP API untuk **WACCA** — platform SaaS multi-tenant untuk bisnis
        laundry di ekosistem ikavia. Setiap **tenant** = 1 organisasi (di
        account-service) → punya banyak **outlet** (cabang fisik) → outlet
        punya **items** (`service` / `product` / `driver`) + **harga per-outlet**
        untuk masing-masing item + **jam operasional** + **galeri foto**.

        Backend Go (gin + GORM + PostgreSQL). Auth delegated ke **Account
        Service** — JWT RS256 di-verify via JWKS endpoint `account.ikavia.com`.

        Two top-level resources scoped per-organization: `tenant` (profil bisnis
        laundry) dan `customer` (profil konsumen). Sisanya nested di bawah
        `tenant/outlet/*`.
    base_urls:
        - label: Production
          url: https://wacca.ikavia.com
          default: true
        - label: Local
          url: http://localhost:7281
    overview_cards:
        - icon: "\U0001F9FA"
          title: Domain model
          description: Tenant → Outlet → Items → Prices
          content: |
            ```
            Tenant (1 per org_id)
              └── Outlet (banyak)
                    ├── Media (galeri foto)
                    ├── TimeOperation (jam buka/tutup)
                    ├── Item (type: service | product | driver)
                    └── Price (per-outlet, polymorphic ke item)
            ```

            - **Tenant** profil bisnis laundry; 1:1 dengan organization_id dari JWT.
            - **Outlet** cabang fisik; punya status `is_opening` (sedang buka/tutup), `driver_available`.
            - **Item** entitas yang ditawarkan outlet. Kolom `type` membedakan:
              - `service` — layanan cuci (kering, setrika, dry-clean, dll)
              - `product` — barang dagangan (deterjen, pewangi)
              - `driver` — sumber daya antar-jemput
            - **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.
            - **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).
        - icon: "\U0001F9FE"
          title: Response envelope
          description: snake_case JSON + UUIDv7 + request_id
          content: |
            Success:
            ```json
            { "success": true, "data": { ... }, "message": "...", "request_id": "uuid" }
            ```

            Success (paginated list):
            ```json
            {
              "success": true,
              "data": [...],
              "pagination": {
                "page": 1, "limit": 20, "total": 123,
                "total_pages": 7, "has_next": true, "has_prev": false
              },
              "message": "...",
              "request_id": "uuid"
            }
            ```

            Error (4xx surfaced apa-adanya, 5xx generic):
            ```json
            { "success": false, "message": "...", "request_id": "uuid" }
            ```

            - **ID**: `uuid v7` (lexicographic time-ordered)
            - **Tanggal**: ISO-8601 UTC
            - **URL**: kebab-case (`outlet-products`, `service-prices`)
            - **JSON keys**: snake_case (`organization_id`, `mobile_number`)
            - **Soft delete**: setiap entitas punya `deleted_at` (gorm.DeletedAt); list endpoints auto-filter row terhapus.
            - **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.
        - icon: "\U0001F510"
          title: Permission model
          description: JWT permission claim + per-resource gate
          content: |
            Tiap resource family digate via `RequirePermission` middleware:

            | Resource family | Permission required |
            |---|---|
            | `/api/v1/tenant` + semua child (`outlet`, `outlet-*`, `*-prices`) | `wacca-tenant:*` |
            | `/api/v1/customer` | `wacca-customer:*` |

            Wildcard `*` 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.
        - icon: "\U0001F3E2"
          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`.

            **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.
        - icon: "\U0001F6A6"
          title: Outlet status
          description: Dua flag terpisah — is_active vs is_opening
          content: |
            | Field | Arti | Cara set |
            |---|---|---|
            | `is_active` | Outlet ada (belum di-archive) | Saat `DELETE` → soft-delete. Tidak di-toggle manual. |
            | `is_opening` | Sedang buka secara real-time | `PATCH /tenant/outlet/{id}/open-outlet` ↔ `/close-outlet` |
            | `driver_available` | Driver standby (siap antar) | `PATCH /tenant/outlet/{id}/open-drivers` ↔ `/close-drivers` |

            Frontend kasir pakai `is_opening` untuk allow/disallow order; `driver_available` untuk toggle delivery option. Logika business "boleh terima order kalau outlet `is_opening && is_active`" diserahkan ke konsumen (order-service, future).
        - icon: "\U0001F5C2"
          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:

            - Frontend laundry punya screen "Daftar Layanan", "Daftar Produk", "Daftar Driver" terpisah → langsung pakai endpoint kategorinya.
            - Filter `type=service` selalu ter-apply otomatis oleh use_case — caller tidak perlu kirim manual.

            Hal yang sama berlaku untuk `price`:
            - `service-prices` → field domain `outlet_service_id`
            - `product-prices` → field domain `outlet_product_id`
            - `driver-prices` → field domain `outlet_driver_id`

            Di storage layer ketiganya satu tabel `price` dengan kolom `item_id`. Use case resolve `outlet_*_id` → item lookup → outlet derivation → simpan dengan `tags="by-outlet"`.
authentication:
    methods:
        - type: JWT Bearer
          header: Authorization
          format: Bearer <access_token>
          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`.

            **Identitas = account-service.** Email & 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.
          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`.
                Service principal: `POST /auth/token-exchange` dengan `X-API-Key` → terima 1h JWT.
            - title: Pastikan org aktif sesuai konteks
              detail: |
                JWT cuma bawa **satu** `org_id`. Untuk pindah konteks:
                ```
                POST https://account.ikavia.com/api/v1/organizations/{org-id}/switch
                Authorization: Bearer <current_jwt>
                ```
                Response berisi JWT baru scoped ke org target.
            - 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.
            - 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`).
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.

        **Deteksi role JWT** (RequirePermission temporarily off untuk chat — owner/customer pakai endpoint yang sama):
        - JWT permission punya `wacca-tenant:*` → caller dianggap **tenant** (owner/staff). `org_id` = tenant scope.
        - Selain itu → caller dianggap **customer**. `sub` = account_id.

        **Visibility**:
        - **Customer** hanya lihat chat miliknya sendiri (`customer_id = claim.sub`).
        - **Tenant** lihat chat semua customer untuk outlet milik org-nya (resolve `outlet_id` → tenant scope).
      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.
            Filter `outlet_id` opsional (umumnya FE customer pakai untuk filter chat per outlet aktif).
          query_params:
            - name: outlet_id
              type: string
              description: Filter ke outlet tertentu (UUID).
          example_response: |
            {
              "success": true,
              "data": [
                {
                  "id": "01923c8b-1234-7000-a000-000000000c01",
                  "channel_id": "f1a2b3c4-d5e6-7890-1234-567890abcdef",
                  "customer_id": "a2f32c84-639c-4778-8d30-2cc1ba3ca427",
                  "outlet_id": "01923c8b-1234-7000-a000-000000000100",
                  "created_at": "2026-06-10T07:00:00Z",
                  "updated_at": "2026-06-10T07:00:00Z"
                }
              ],
              "message": "success"
            }
        - 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.

            **404** kalau bukan visible scope. **`channel_id` dipakai FE** untuk join WebSocket / poll API chat-service untuk message list.
        - 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`).

            Server panggil chat-service untuk mint `channel_id` baru, simpan mapping di tabel `chat`.

            Khusus **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.

            **400** kalau outlet_id invalid / bukan milik tenant ini (untuk tenant caller).
          example_body: |
            {
              "outlet_id": "01923c8b-1234-7000-a000-000000000100"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-000000000c01",
                "channel_id": "f1a2b3c4-d5e6-7890-1234-567890abcdef",
                "customer_id": "a2f32c84-639c-4778-8d30-2cc1ba3ca427",
                "outlet_id": "01923c8b-1234-7000-a000-000000000100",
                "created_at": "2026-06-10T07:00:00Z",
                "updated_at": "2026-06-10T07:00:00Z"
              },
              "message": "chat created"
            }
    - 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.

        **Status valid**: `open`, `in_discussion`, `awaiting_customer`, `resolved`, `rejected`, `escalated`, `auto_closed`, `confirmed`.

        **R-CHAT.07 auto-close**: komplain di status `awaiting_customer` atau `escalated` > 24h → diam-diam ditutup oleh admin worker. Customer bisa **reopen** sekali (kembali ke `in_discussion`).

        **R-CHAT.16**: order yang sudah `escalated` di salah satu komplain → blok komplain baru di order yang sama.
      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).
          example_body: |
            {
              "order_id": "01923c8b-1234-7000-a000-0000000000aa",
              "category": "result_quality",
              "description": "Setelah dicuci masih ada bau apek di kemeja putih"
            }
        - 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.
        - 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`.
    - 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.
      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`.
        - 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: |
            {
              "message": "Mohon maaf, kami akan jemput ulang gratis besok pagi"
            }
        - 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: |
            {
              "resolution_type": "rewash",
              "note": "Sudah di-cuci ulang, customer terima jemput hari ini"
            }
        - 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: |
            {
              "note": "Berdasarkan foto, kondisi sudah sesuai standar"
            }
        - 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.
      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 > threshold jam (default 24h dari store setting `auto_close_complaint_hours`). Idempoten — komplain yang sudah final tidak terdampak.
    - 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.

        Salah 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).
      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.
          query_params:
            - name: page
              type: integer
              default: "1"
              description: Halaman 1-based
            - name: limit
              type: integer
              default: "20"
              description: Jumlah per halaman
          example_response: |
            {
              "success": true,
              "data": [
                {
                  "id": "01923c8b-1234-7000-a000-000000000050",
                  "name": "Rumah",
                  "address": "Jl. Merdeka 10",
                  "location": "Jakarta Pusat",
                  "latitude": "-6.175392",
                  "longitude": "106.827153",
                  "category": "Rumah",
                  "mobile_phone": "+62811xxxxxxx",
                  "is_default": true,
                  "created_at": "2026-05-19T10:00:00Z",
                  "updated_at": "2026-05-29T08:00:00Z"
                }
              ],
              "pagination": { "page": 1, "limit": 20, "total": 1 },
              "message": "success"
            }
        - 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).

            `category` = label bebas (Rumah, Kantor, Kos, dll). `mobile_phone` = nomor kontak per-alamat (boleh beda dengan profil utama).
          example_body: |
            {
              "name": "Kantor",
              "address": "Jl. Sudirman 1, Lt. 12",
              "location": "Jakarta Pusat",
              "latitude": "-6.208763",
              "longitude": "106.845599",
              "category": "Kantor",
              "mobile_phone": "+62812xxxxxxx",
              "is_default": false
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-000000000051",
                "name": "Kantor",
                "address": "Jl. Sudirman 1, Lt. 12",
                "location": "Jakarta Pusat",
                "latitude": "-6.208763",
                "longitude": "106.845599",
                "category": "Kantor",
                "mobile_phone": "+62812xxxxxxx",
                "is_default": false,
                "created_at": "2026-05-30T08:00:00Z",
                "updated_at": "2026-05-30T08:00:00Z"
              },
              "message": "address created successfully"
            }
        - 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.
        - 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.
          example_body: |
            {
              "category": "Rumah Baru",
              "mobile_phone": "+62813xxxxxxx"
            }
        - name: Delete address
          method: DELETE
          path: /api/v1/customer/address/{id}
          auth: JWT Bearer
          description: |
            Soft delete. **403** kalau bukan milik customer.
        - 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.
    - id: customer-outlets
      title: Customer — Cari Outlet
      description: |
        Penemuan outlet dari sisi customer (Flow "Cari Outlet"). List + detail outlet
        lengkap dengan layanan/produk + harga, rating, jarak ke customer, dan harga
        termurah. Semua butuh JWT customer.
      endpoints:
        - name: List outlets
          method: GET
          path: /api/v1/customer/outlets
          auth: JWT Bearer
          description: |
            Daftar outlet aktif. Query opsional:
            - `search` — cari berdasarkan nama outlet (case-insensitive ILIKE).
            - `driver_available` — `true`/`false`, filter outlet yang punya layanan antar-jemput.
            - `tags` — filter outlet yang punya item dengan tag tsb.
            - `min_rating` — float; hanya outlet dengan `rating >= min_rating` yang muncul. Outlet baru tanpa review punya `rating=0`, jadi akan **tersembunyi** kalau `min_rating > 0`. Nilai non-numeric → filter diabaikan.
            - `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.
            - `page`, `limit` — paginasi (default 1/20).

            Tiap outlet menyertakan: `rating` (agregat), `distance_km` (haversine ke
            koordinat customer; null bila koordinat tak tersedia), `min_price` (layanan
            termurah → "Mulai Rp…"), `delivery_fee_per_km` (tarif jemput per km), serta
            daftar `services`/`products`/`drivers` + harga By-Platform.
          example_response: |
            {
              "success": true,
              "data": [
                {
                  "id": "01923c8b-...-0004",
                  "name": "Clean Express Laundry",
                  "address": "Jl. Kaliurang Km 5.5, Sleman",
                  "latitude": "-7.7560", "longitude": "110.4080",
                  "is_opening": true,
                  "driver_available": true,
                  "delivery_fee_per_km": 2000,
                  "is_active": true,
                  "rating": 4.8,
                  "distance_km": 0.8,
                  "min_price": 7000,
                  "services": [
                    { "id": "...", "type": "service", "name": "Cuci Reguler",
                      "price": { "unit_of_measure": "kg", "rate": 7000 } }
                  ],
                  "products": [],
                  "drivers": []
                }
              ],
              "pagination": { "page": 1, "limit": 20, "total": 12 }
            }
        - 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: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-...-0004",
                "name": "Clean Express Laundry",
                "rating": 4.8,
                "distance_km": 0.8,
                "min_price": 7000,
                "delivery_fee_per_km": 2000,
                "services": [ { "name": "Cuci Reguler", "price": { "unit_of_measure": "kg", "rate": 7000 } } ]
              },
              "message": "success"
            }
        - 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 & Klaim Voucher](#customer-promo). Path lama `/promos` (plural) sudah 404 — pakai singular `/promo`. Response shape diperluas dengan `promo_type`, `mechanism`, `claimed`, dst.
    - id: customer-favorites
      title: Customer — Favorit Outlet
      description: |
        Outlet favorit milik customer (❤️). Idempoten — memfavoritkan outlet yang sudah
        difavoritkan tidak menggandakan. Unfavorite = hapus; bisa difavoritkan ulang.
      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: |
            {
              "success": true,
              "data": [
                { "outlet_id": "01923c8b-...-0004", "name": "Clean Express Laundry",
                  "address": "Jl. Kaliurang Km 5.5", "is_opening": true,
                  "driver_available": true, "delivery_fee_per_km": 2000 }
              ],
              "message": "success"
            }
        - 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" }
          example_response: |
            { "success": true, "message": "outlet favorited" }
        - 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" }
    - 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.

        Semua list endpoint cross-outlet (bukan filter ke 1 outlet) — customer browse katalog. Filter `outlet_id` opsional. Harga = **By Platform** rate.

        **Gating customer-side (services only):** `GET /customer/item-services` hanya menampilkan service yang outlet-nya **aktif & 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 & drivers belum kena guard ini.
      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 & belum di-soft-delete, dan service harus punya minimal 1 harga `By Platform` aktif. Service tanpa salah satunya tidak akan muncul.
          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 & pelacakan order dari sisi customer (Flow "Order Online"). Order dibuat
        dengan **estimasi** (berat/pcs perkiraan); harga final dikonfirmasi owner setelah
        verifikasi berat aktual. Respons revenue/pembayaran riil di payment-service.

        Lihat juga section *Order Flow — Customer* untuk approve/reject harga final.

        **Field baru di response order** (semua endpoint):
        - `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`.
        - `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".
        - `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` & `pickup-arrived`.
        - `verified_at` (timestamp) — diisi saat owner panggil `verify-complete` untuk lock harga final.
        - `outlet_name` + `pickup_address` (object) — enriched in response, FE tak perlu lookup terpisah.

        **Validasi server baru saat Create**:
        - Tolak order kalau outlet sedang **tutup** (`is_opening=false`).
        - Tolak order `delivery_method=pickup` kalau outlet tidak punya **driver aktif** (`driver_available=false` atau tidak ada item driver tersedia).
        - Item `is_active=false` atau item tanpa harga aktif → ditolak.

        **Optional di Create**:
        - `addons[]` — addon items (charge tambahan, tidak menambah service line). Dipakai untuk express, biaya khusus, dll.
        - `photo_media_ids[]` — UUID media (lihat [Outlet Media](#outlet-media)) untuk attach foto pakaian/kondisi dari customer di POST awal.
      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
            sudah terikat ke satu outlet); semua item wajib dari outlet yang sama
            (kalau campur → `400`). Field:
            - `items[]` — layanan/produk dipilih; `quantity` = estimasi berat/qty, `estimated_pcs` = perkiraan jumlah potong. Menentukan outlet order.
            - `outlet_id` — **opsional**. Bila dikirim harus cocok dengan outlet dari `items` (cross-check; mismatch → `400`). Tidak perlu dikirim.
            - `delivery_method` — `pickup` (kurir jemput) atau `self_dropoff` (antar sendiri; default).
            - `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.
            - `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).
            - `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.
            - `coupon_code` — opsional; divalidasi (masa berlaku, kuota, min order) → diskon dihitung atas subtotal.

            Server menetapkan `order_number` (WCC-YYMMDD-NNN), `estimated_total`
            (= subtotal − diskon + ongkir), dan status `pending`.
          example_body: |
            {
              "outlet_id": "01923c8b-...-0004",
              "delivery_method": "pickup",
              "pickup_address_id": "01923c8b-...-0050",
              "payment_method": "qris",
              "coupon_code": "NEWUSER20",
              "note": "Pisahkan pakaian putih",
              "items": [
                { "item_id": "01923c8b-...-svc1", "quantity": 3, "estimated_pcs": 15 },
                { "item_id": "01923c8b-...-svc2", "quantity": 2 }
              ]
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-...-00aa",
                "order_number": "WCC-260528-001",
                "outlet_id": "01923c8b-...-0004",
                "delivery_method": "pickup",
                "pickup_fee": 5000,
                "payment_method": "qris",
                "payment_method_id": "01923c8b-...-pm-qris",
                "coupon_code": "NEWUSER20",
                "discount_amount": 18200,
                "estimated_total": 77800,
                "final_total": null,
                "tax_amount": 0,
                "status": "pending",
                "cancelled_by": "",
                "order_list": [
                  { "item_id": "01923c8b-...-svc1", "quantity": 3, "amount": 21000, "status": "pending" }
                ]
              },
              "message": "order created successfully"
            }
        - 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: |
            {
              "success": true,
              "data": [
                { "id": "...", "order_number": "WCC-260528-001", "status": "price_proposed",
                  "estimated_total": 77800, "final_total": 81000, "tax_amount": 8000 }
              ],
              "pagination": { "page": 1, "limit": 20, "total": 3 }
            }
        - 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.
          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: |
            {
              "success": true,
              "data": {
                "distance_km": 2.5,
                "rate_per_km": 2000,
                "pickup_fee": 5000
              },
              "message": "success"
            }
        - 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.

            **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.

            **`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.
          example_body: |
            {
              "outlet_id": "01923c8b-...-0004",
              "coupon_code": "NEWUSER20",
              "items": [
                { "item_id": "01923c8b-...-svc1", "quantity": 3 }
              ]
            }
          example_response: |
            {
              "success": true,
              "data": {
                "subtotal": 21000,
                "discount_amount": 4200,
                "final_total": 16800,
                "is_free_shipping": false
              },
              "message": "success"
            }
        - 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.

            Sumber 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).

            **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`.

            **`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.

            **`variant`** hanya muncul di step `handover` — nilainya `ready_pickup` (customer ambil di outlet) atau `delivering` (driver antar) tergantung status order.

            **404** kalau order bukan milik customer.
          example_response: |
            {
              "success": true,
              "data": {
                "steps": [
                  { "key": "created",           "status": "done",    "at": "2026-06-10T08:00:00Z" },
                  { "key": "accepted",          "status": "done",    "at": "2026-06-10T08:35:00Z" },
                  { "key": "pickup_dispatched", "status": "done",    "at": "2026-06-10T09:00:00Z" },
                  { "key": "pickup_arrived",    "status": "done",    "at": "2026-06-10T09:30:00Z" },
                  { "key": "price_proposed",    "status": "pending", "at": null },
                  { "key": "processing",        "status": "pending", "at": null },
                  { "key": "handover",          "status": "pending", "at": null, "variant": "delivering" },
                  { "key": "completed",         "status": "pending", "at": null }
                ]
              },
              "message": "success"
            }
        - 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`.
        - 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)).
        - 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.

            **Estimasi vs aktual.** Tiap line (di `items[]` dan `order_list[]`) bawa dua angka kuantitas:
            - `estimated_quantity` / `estimated_pcs` — perkiraan customer **saat order dibuat** (immutable; mis. "3 kg · 15 pcs").
            - `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)).

            UI bisa render badge "diperkirakan X kg → ditimbang Y kg" tanpa lookup tambahan.
          example_response: |
            {
              "success": true,
              "data": {
                "id": "...", "order_number": "WCC-260528-001", "status": "processing",
                "delivery_method": "pickup", "pickup_fee": 5000,
                "estimated_total": 77800, "final_total": 81000, "discount_amount": 18200, "tax_amount": 8000,
                "order_list": [
                  {
                    "name": "Cuci Reguler",
                    "estimated_quantity": 3, "estimated_pcs": 15,
                    "quantity": 3.2,
                    "amount": 22400
                  }
                ]
              },
              "message": "success"
            }
    - id: customer-promo
      title: Customer — Promo & 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).

        **Cara customer pakai promo** — tergantung `mechanism`:
        - `code` → ketik kode di field coupon saat checkout → [`POST /customer/orders/coupon-preview`](#customer-orders) + apply di POST order.
        - `claim` → klaim dulu lewat `POST /customer/promo/{id}/claim` (muncul di tab "Promo Saya") → checkout dengan `customer_coupon_id`.
        - `automatic` → tidak butuh aksi customer; server pilih promo terbaik otomatis saat order create / coupon-preview.

        **`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.

        **Computed status** di list ("Promo Saya") — diturunkan dari valid_from/valid_end + status promo + customer_coupon.status:
        - `active` — masih bisa dipakai.
        - `upcoming` — promo `scheduled`, belum valid_from.
        - `expired` — sudah lewat valid_end atau promo `ended`/`paused`/customer_coupon `used`.
      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.

            Tiap 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"`).

            **Path note**: lama `/outlets/{id}/promos` (plural). Sekarang singular `/promo` (PR #56). FE lama → 404.
          example_response: |
            {
              "success": true,
              "data": [
                {
                  "id": "01923c8b-...-pm-1",
                  "coupon_code": "NEWUSER20",
                  "name": "Diskon 20% Pengguna Baru",
                  "type": "percentage",
                  "promo_type": "percentage",
                  "mechanism": "code",
                  "value": 20,
                  "max_discount": 30000,
                  "min_amount": 50000,
                  "min_weight_kg": 0,
                  "maximum_use": 500,
                  "usage": 12,
                  "max_use_per_customer": 1,
                  "valid_from": "2026-06-01T00:00:00Z",
                  "valid_end": "2026-12-31T23:59:59Z",
                  "service_requirement_mode": "all_services",
                  "customer_criteria": "new_user",
                  "outlet_scope_type": "all_outlets",
                  "scope_label": "Semua outlet",
                  "terms": ["Berlaku untuk pengguna baru ≤30 hari"],
                  "claimed": false,
                  "is_global": false
                }
              ],
              "message": "success"
            }
        - 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`:
            - `active` (default) — masih bisa dipakai
            - `upcoming` — promo belum valid_from
            - `expired` — sudah lewat / promo end / sudah dipakai
            - `all` — gabungan

            Tiap row include `computed_status` (resolved server-side), `claimed_at`, dan ringkasan promo (name, type, value, max_discount, min_amount, valid period, is_global).
          example_response: |
            {
              "success": true,
              "data": [
                {
                  "id": "01923c8b-...-cc-1",
                  "promotional_id": "01923c8b-...-pm-1",
                  "coupon_code": "NEWUSER20",
                  "name": "Diskon 20% Pengguna Baru",
                  "promo_type": "percentage",
                  "value": 20,
                  "max_discount": 30000,
                  "min_amount": 50000,
                  "valid_from": "2026-06-01T00:00:00Z",
                  "valid_end": "2026-12-31T23:59:59Z",
                  "status": "claimed",
                  "computed_status": "active",
                  "claimed_at": "2026-06-10T08:15:00Z",
                  "is_global": false
                }
              ],
              "pagination": { "page": 1, "limit": 20, "total": 5 },
              "message": "success"
            }
        - 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 & condition (`terms[]`), source description, plus quota info.

            **404** kalau bukan milik customer ini.
        - 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).

            **Tolakan**:
            - 400 — promo `mechanism != claim` (kode/auto tidak perlu klaim).
            - 409 — sudah pernah klaim (partial unique).
            - 409 — promo bukan status `active` / sudah expired.
            - 409 — `customer_criteria=new_user` & customer tidak memenuhi.
            - 409 — `max_use_per_customer` sudah penuh.
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-...-cc-1",
                "promotional_id": "01923c8b-...-pm-1",
                "coupon_code": "NEWUSER20",
                "name": "Diskon 20% Pengguna Baru",
                "status": "claimed",
                "claimed_at": "2026-06-15T07:00:00Z",
                "valid_from": "2026-06-01T00:00:00Z",
                "valid_end": "2026-12-31T23:59:59Z"
              },
              "message": "promo claimed"
            }
    - 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`).

        Owner outlet **tidak boleh** memberi rating ke outlet-nya sendiri (self-rating guard) — diidentifikasi via `tenant_detail.owner_account_id` = JWT `sub`.

        **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").

        **One rating per (outlet, customer)** — server enforce 409 conflict kalau coba rating outlet yang sama lagi.

        **`likes` whitelist (8 nilai case-sensitive — capital case)**:
        ```
        Bersih · Cepat · Wangi · Rapi · Ramah · Berkualitas · Sesuai Harga · Tepat Waktu
        ```
        Server normalize (trim, deduplicate, urutkan canonical) + reject value tidak dikenal → `400 invalid rating aspect: X`.

        **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.

        ## ⚠️ Known gaps (tracked di [PR #59](https://github.com/Ikavia/wacca-service/pull/59))
        - **Photo upload saat create**: `POST /outlet-ratings` belum terima `photo_media_ids[]`. Photo support hanya di list/detail (read). Phase 2 di PR #59.
        - **Points reward**: `Present` response create belum return `points_earned`/`total_points`. Backend belum punya loyalty wallet. Phase 2 hardcode `points_earned: 50` dulu.
        - **Detail endpoint belum enriched**: `GET /:id` return Present **tanpa** `outlet_name`/`service_names`/`helpful_count`/`photos`. Phase 1 fix.
        - **Scale validation `< 1`**: backend saat ini accept `scale=0` (bug, akan di-fix Phase 1 → `1..5` strict). FE wajib enforce ≥1 di client.
      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).

            **400 errors**:
            - `outlet_id is required` / `invalid outlet_id`
            - `scale must be between 0 and 5` (current behavior — FE wajib enforce ≥1)
            - `review must be 255 characters or fewer`
            - `invalid rating aspect: X` (likes mengandung value di luar 8-whitelist case-sensitive)

            **403 'you cannot rate your own outlet'** — caller adalah owner tenant pemilik outlet ini (self-rating guard).

            **403 'you can only rate an outlet after a finished order'** — belum ada order `completed` di outlet ini.

            **409 'you have already rated this outlet'** — sudah pernah rating outlet ini (unique `(outlet_id, customer_id)`).
          example_body: |
            {
              "outlet_id": "01923c8b-1234-7000-a000-000000000004",
              "scale": 5,
              "review": "Kerjanya rapi dan cepat",
              "likes": ["Bersih", "Cepat", "Ramah"]
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-000000000090",
                "tenant_id": "01923c8b-1234-7000-a000-000000000001",
                "outlet_id": "01923c8b-1234-7000-a000-000000000004",
                "outlet_name": "",
                "customer_id": "01923c8b-1234-7000-a000-000000000010",
                "order_id": "01923c8b-1234-7000-a000-0000000000aa",
                "scale": 5,
                "review": "Kerjanya rapi dan cepat",
                "likes": ["Bersih", "Cepat", "Ramah"],
                "service_names": [],
                "helpful_count": 0,
                "photos": [],
                "created_at": "2026-05-29T10:00:00Z",
                "updated_at": "2026-05-29T10:00:00Z"
              },
              "message": "rating created successfully"
            }

            **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.
        - 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.

            **400** — `item_id` kosong/invalid, `scale` di luar 1..5, `review` > 255 char.

            **403 'you can only rate a service after a finished order'** — belum ada order Finish yang memuat service ini.

            **409** — sudah pernah rating service ini.
          example_body: |
            {
              "item_id": "01923c8b-1234-7000-a000-000000000020",
              "scale": 5,
              "review": "Hasil setrikaan licin",
              "likes": ["wangi", "licin"]
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-0000000000a1",
                "tenant_id": "01923c8b-1234-7000-a000-000000000001",
                "outlet_id": "01923c8b-1234-7000-a000-000000000004",
                "item_id": "01923c8b-1234-7000-a000-000000000020",
                "customer_id": "01923c8b-1234-7000-a000-000000000010",
                "order_id": "01923c8b-1234-7000-a000-0000000000aa",
                "scale": 5,
                "review": "Hasil setrikaan licin",
                "likes": ["wangi", "licin"],
                "created_at": "2026-05-29T10:00:00Z",
                "updated_at": "2026-05-29T10:00:00Z"
              },
              "message": "rating created successfully"
            }
        - 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.
          example_response: |
            {
              "success": true,
              "data": [
                {
                  "order_id": "01923c8b-1234-7000-a000-0000000000aa",
                  "order_number": "WCC-260528-001",
                  "outlet_id": "01923c8b-1234-7000-a000-000000000004",
                  "outlet_name": "Bersih Cepat — Cabang Pusat",
                  "service_names": ["Cuci Reguler", "Setrika"],
                  "created_at": "2026-05-28T08:00:00Z"
                }
              ],
              "message": "success"
            }
        - 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:
            - `average` — skor rata-rata yang customer kasih (0 kalau belum ada rating)
            - `count` — total review yang customer sudah submit
            - `helpful_received` — total helpful vote yang DITERIMA customer ini dari customer lain (agregat semua review-nya)
          example_response: |
            {
              "success": true,
              "data": {
                "average": 4.7,
                "count": 12,
                "helpful_received": 25
              },
              "message": "success"
            }
        - 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.
        - 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.
    - 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**.

        **Gating permission**:
        - **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).
        - **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 `*`.

        **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"`).

        **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).

        **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.
      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.
          query_params:
            - name: include_inactive
              type: bool
              description: Default false. true → admin overview (semua non-deleted).
          example_response: |
            {
              "success": true,
              "data": [
                { "id": "00300000-0000-8fff-8fff-000000000001", "code": "bca", "name": "Bank BCA", "is_active": true },
                { "id": "00300000-0000-8fff-8fff-000000000002", "code": "bri", "name": "Bank BRI", "is_active": true }
              ],
              "message": "success"
            }
        - 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** (`*`).
            **404** bila id tidak ada atau sudah soft-deleted.
        - name: Create bank (admin)
          method: POST
          path: /api/v1/finance/banks
          auth: JWT Bearer
          permission: '*'
          description: |
            Tambah master bank baru. **Admin-gated** (`*`).

            - `code` (required) — lowercase alphanumeric/dash/underscore, 2-20 char. Server normalize lowercase.
            - `name` (required) — display name (≤50 char).
            - **`is_active` tidak diterima** — bank baru selalu aktif. Untuk publish-disabled, panggil PUT setelahnya.

            Status:
            - **400** kalau code/name kosong, code tidak match regex, atau name >50 char.
            - **409** kalau code sudah dipakai (termasuk oleh bank inactive — partial unique tetap aktif).
          example_body: |
            {
              "code": "bsi",
              "name": "Bank Syariah Indonesia"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-...-0001",
                "code": "bsi",
                "name": "Bank Syariah Indonesia",
                "is_active": true
              },
              "message": "bank created"
            }
        - 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.

            - `code` re-key boleh; sama validasi format + collision check (409) seperti Create.
            - `name` ≤50 char.
            - `is_active` = `false` untuk deactivate (dropdown tenant tidak lihat tapi bank_account existing tetap reference); `true` untuk reaktivasi.

            **404** kalau id tidak ada. **409** kalau code conflict.
          example_body: |
            # deactivate
            { "is_active": false }

            # rename
            { "name": "Bank Syariah Indonesia (BSI)" }

            # rekey
            { "code": "bsi-baru" }
        - 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.

            **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.

            **404** kalau id tidak ada / sudah deleted sebelumnya.
    - 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`.

        **Gating**: semua endpoint di grup `/finance/*` butuh permission `wacca-tenant:*` (JWT tenant aktif). `*` (admin) tetap lolos lewat wildcard.

        **Uniqueness** (partial `WHERE deleted_at IS NULL`):
        - Max **1 rekening default per tenant** (`uq_ba_tenant_default`).
        - Tidak boleh duplikat `(tenant, bank, account_number)` (`uq_ba_tenant_bank_number`).

        Soft-delete (set `deleted_at`) — row tetap di DB untuk audit, tapi tidak ikut list/dropdown.
      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.

            Response field `tenant_id` **tidak** ikut di-expose — scoping otomatis dari JWT.
          query_params:
            - name: page
              type: int
              default: "1"
              description: Halaman 1-based
            - name: limit
              type: int
              default: "20"
              description: Jumlah per halaman
          example_response: |
            {
              "success": true,
              "data": [
                {
                  "id": "01923c8b-...-acc1",
                  "bank_id": "00300000-0000-8fff-8fff-000000000001",
                  "bank_code": "bca",
                  "bank_name": "Bank BCA",
                  "account_number": "1234567890",
                  "account_holder": "Budi Santoso",
                  "is_default": true,
                  "is_active": true,
                  "created_at": "2026-06-08T10:00:00Z",
                  "updated_at": "2026-06-08T10:00:00Z"
                }
              ],
              "pagination": {
                "page": 1, "limit": 20, "total": 1,
                "total_pages": 1, "has_next": false, "has_prev": false
              },
              "message": "success"
            }
        - 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.

            **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.

            **Error**:
            - **400** kalau `bank_id` kosong/format invalid, atau bank found tapi `is_active=false`, atau `account_number`/`account_holder` kosong/terlalu panjang (>50 / >100).
            - **404** kalau bank dengan id tsb tidak ada di tabel `banks` (UC bubble status dari `bank_repository.GetByID`).
            - **409** kalau `(tenant, bank, account_number)` triplet sudah ada (partial unique `uq_ba_tenant_bank_number`).

            **Response** = `IDPresent` (cuma `{id}`). Untuk tampilkan field lengkap di FE, panggil `GET /finance/bank-accounts/{id}` setelahnya.
          example_body: |
            {
              "bank_id": "00300000-0000-8fff-8fff-000000000001",
              "account_number": "1234567890",
              "account_holder": "Budi Santoso",
              "is_default": true
            }
          example_response: |
            {
              "success": true,
              "data": { "id": "01923c8b-...-acc1" },
              "message": "bank account created"
            }
        - 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`.

            Set `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`).

            **Response** = `IDPresent` (cuma `{id}`). Tarik via GET kalau butuh full payload.

            **404** kalau bukan milik tenant. **409** kalau update bikin `(tenant, bank, account_number)` collide.
          example_body: |
            # ganti default ke rekening ini
            { "is_default": true }

            # rename holder
            { "account_holder": "Budi Santoso S.E." }
          example_response: |
            {
              "success": true,
              "data": { "id": "01923c8b-...-acc1" },
              "message": "bank account updated"
            }
        - 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.

            **`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.

            Kalau ini satu-satunya rekening (default, count=1), delete diperbolehkan — tenant balik ke state tanpa rekening.

            **404** kalau bukan milik tenant. Response body kosong (`data: null`).
        - 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.

            **404** kalau bukan milik tenant. **Response** = full `Present` dengan bank info ter-enrich (rekening yang baru jadi default).
    - 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"`.

        Untuk pricing per-outlet, lihat section **Service Prices**.
      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"`.
          example_body: |
            {
              "outlet_id": "01923c8b-1234-7000-a000-000000000100",
              "name": "Cuci Kering",
              "description": "Reguler 2 hari",
              "unit_of_measure": "kg",
              "tags": "standard,reguler"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-000000000400",
                "tenant_id": "01923c8b-1234-7000-a000-000000000001",
                "outlet_id": "01923c8b-1234-7000-a000-000000000100",
                "type": "service",
                "name": "Cuci Kering",
                "description": "Reguler 2 hari",
                "unit_of_measure": "kg",
                "tags": "standard,reguler",
                "is_active": true,
                "created_at": "2026-05-19T10:00:00Z",
                "updated_at": "2026-05-19T10:00:00Z"
              },
              "message": "created successfully"
            }
        - 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: |
            {
              "description": "Reguler 1 hari",
              "unit_of_measure": "kg"
            }
        - 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"`.

        Untuk pricing per-outlet, lihat section **Product Prices**.
      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: |
            {
              "outlet_id": "01923c8b-1234-7000-a000-000000000100",
              "name": "Pewangi Lavender 500ml",
              "description": "Botol semprot",
              "unit_of_measure": "botol",
              "tags": "fragrance,laundry-add-on"
            }
        - 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"`.

        Untuk pricing per-driver (mis. ongkir per-km), lihat section **Driver Prices**.

        Catatan: status real-time driver (sedang antar / standby) **belum** ada di service ini. Saat ini hanya katalog. Future enhancement → tabel `driver_session` + endpoint dispatch.
      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: |
            {
              "outlet_id": "01923c8b-1234-7000-a000-000000000100",
              "name": "Driver Motor 1",
              "description": "Motor matic, jangkauan 5km",
              "unit_of_measure": "trip",
              "tags": "motor,city"
            }
        - 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 & 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.

        **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).

        **Dua arah komunikasi yang dipakai**:
        1. **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).
        2. **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`.

        **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).

        **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).

        **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:
        - `payload.actions` jadi **kosong/hilang** → FE sembunyikan tombol.
        - `payload.body` berubah ke teks settled (mis. "Kamu telah menerima undangan…", "Kamu telah menyetujui harga…").
        - `payload.metadata.state` di-set: invite → `accepted`/`rejected`; harga → `approved`/`rejected`.
        - **Tidak** kirim ulang email/push; `read`/status notif tetap; `category` tetap sama (notif tetap di thread/filter yang sama).

        **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.

        ## Push Notification (FCM) — alur lengkap

        Push 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.

        **Alur end-to-end:**
        1. **App ambil token** — Firebase SDK `getToken()` di device (Android/iOS/web). Token bisa berubah; app harus re-register saat SDK kasih token baru.
        2. **App daftar token ke wacca** — `POST /api/v1/customer/device-tokens` (customer) atau `POST /api/v1/tenant/device-tokens` (owner/staff), body `{ "token": "<fcm token>", "platform": "android|ios|web" }`. Disimpan di tabel lokal `device_tokens` per account.
        3. **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).
        4. **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`.
        5. **Logout** — app panggil `DELETE /api/v1/{customer|tenant}/device-tokens` (body `{ "token": "<fcm token>" }`) supaya device itu berhenti dapat push.

        **⚠️ 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.

        **Troubleshooting (baca `channels[]` di inbox notif-service):**
        | gejala | arti | aksi |
        |---|---|---|
        | `fcm.status = sent` | push terkirim ke FCM | OK |
        | `fcm` error **400 `INVALID_ARGUMENT` / "registration token is not a valid FCM registration token"** | **pipeline & auth FCM SEHAT**; token device salah/kedaluwarsa/palsu | app refresh token & re-register; jangan pakai token contoh |
        | `fcm` error **401/403 / `UNAUTHENTICATED`** | kredensial server bermasalah (service account / project) | ops: cek `FCM_CREDENTIALS_FILE` + `FCM_PROJECT_ID` di notification-service |
        | tidak ada channel `fcm` sama sekali | account belum punya device token terdaftar | app harus register token dulu (langkah 2) |

        Channel `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`.

        **Publisher gating** — kontrol via env wacca-service (set di `/opt/wacca-service/.env-runtime`):
        - `NOTIFICATION_PLATFORM` — id platform wacca di notification-service. **Kosong = publisher no-op** (semua trigger di-skip silently, wacca tetap jalan tanpa notif).
        - `NOTIFICATION_EXCHANGE` — default `notification.requests`.
        - `NOTIFICATION_SOURCE_SERVICE` — default `wacca-service` (label di payload).
        - `RABBITMQ_URL` — reused (sama dengan consumer `account.events`).

        Publisher fire-and-forget — broker outage tidak break domain operation.

        **Kategori (`category`) notif yang dipublish wacca:**
        - **Customer-side** (`target_type=user`):
          - `order_price_proposed` — owner ajukan harga (**interaktif** Setujui/Tolak, **mandatory**)
          - `order_accepted` — owner konfirmasi pesanan
          - `order_rejected` — owner tolak (**mandatory**)
          - `order_ready` — siap diambil / sedang diantar
          - `order_completed` — selesai
          - `order_pickup_dispatched` / `order_pickup_arrived` — status kurir jemput
          - `order_rating_prompt` — ajakan rating (opt-in, boleh di-mute)
        - **Owner-side** (`target_type=group`, group_id=tenant_id — shared read-state semua tenant_member):
          - `order_new` — pesanan baru masuk (**mandatory**, SLA owner)
          - `order_price_approved` — customer setuju harga
          - `order_price_rejected` — customer tolak harga (**mandatory**, cancellation cascade)
          - `order_cancelled` — customer batalkan (**mandatory**, revenue loss)
          - `order_receipt_confirmed` — customer konfirmasi terima pesanan
          - `member_invite_accepted` / `member_invite_rejected` — staff respon undangan
        - **Invite-flow** (`target_type=user` ke invitee, bukan owner):
          - `member_invited` — undangan join (**interaktif** Terima/Tolak, **mandatory**)

        ## Contoh response `GET /api/v1/notifications`

        > Dibaca dari **notification-service** (`https://notification.ikavia.com`, header `x-platform: wacca`). Bentuk umum: `{ "success": true, "data": [ <record> ], "meta": { "total", "limit", "offset" } }`. Tiap record punya spine (`id`, `target_id`, `category`, `status`, `read`, …) + `channels[]` (status kirim per-channel) + `payload` (pesan asli verbatim) + `responses[]`.

        **1) `member_invited` — PENDING** (masih ada tombol Terima/Tolak; FCM `failed` karena token device palsu/kedaluwarsa, email tetap terkirim → status agregat `partial`):
        ```json
        {
          "id": "member_invited:019e93e1-c407-772e-aada-3ebf708a2968:1780597507102761874",
          "platform": "wacca", "target_id": "40519b23-80c1-4491-a5d4-ee3055b58bed",
          "target_type": "user", "category": "member_invited", "status": "partial", "read": false,
          "channels": [
            { "type": "inapp", "status": "sent", "attempts": 0 },
            { "type": "email", "status": "sent", "attempts": 1, "sent_at": "2026-06-04T18:25:09Z" },
            { "type": "fcm", "status": "failed", "attempts": 3, "error": "fcm API error (status 400): INVALID_ARGUMENT — registration token is not a valid FCM registration token" }
          ],
          "payload": {
            "title": "Undangan bergabung",
            "body": "Kamu diundang bergabung di Bersih Cepat Laundry sebagai staff. Terima untuk mulai.",
            "actions": [ { "key": "member_accept", "label": "Terima" }, { "key": "member_reject", "label": "Tolak" } ],
            "metadata": { "invitation_id": "019e93e1-c407-772e-aada-3ebf708a2968", "role_name": "staff", "tenant_name": "Bersih Cepat Laundry" }
          }
        }
        ```

        **2) `member_invited` — SETTLED (accepted)** (notif yang SAMA setelah invitee terima → `actions` hilang, `body` berubah, `metadata.state="accepted"`):
        ```json
        {
          "id": "member_invited:019e93e1-c407-772e-aada-3ebf708a2968:1780597507102761874",
          "platform": "wacca", "target_id": "40519b23-80c1-4491-a5d4-ee3055b58bed",
          "target_type": "user", "category": "member_invited", "status": "sent", "read": true,
          "channels": [ { "type": "inapp", "status": "sent" }, { "type": "email", "status": "sent" } ],
          "payload": {
            "title": "Undangan diterima",
            "body": "Kamu telah menerima undangan bergabung di Bersih Cepat Laundry sebagai staff.",
            "metadata": { "invitation_id": "019e93e1-c407-772e-aada-3ebf708a2968", "role_name": "staff", "tenant_name": "Bersih Cepat Laundry", "state": "accepted" }
          }
        }
        ```
        (Rejected: sama tapi `title`="Undangan ditolak", `body`="Kamu menolak undangan…", `metadata.state="rejected"`.)

        **3) `order_price_proposed` — PENDING** (tombol Setujui/Tolak; FCM `sent` karena token device valid → status agregat `sent`):
        ```json
        {
          "id": "order_price_proposed:7e1f0a2b-1111-2222-3333-444455556666",
          "platform": "wacca", "target_id": "22222222-2222-2222-2222-222222222222",
          "target_type": "user", "category": "order_price_proposed", "status": "sent", "read": false,
          "channels": [
            { "type": "inapp", "status": "sent", "attempts": 0 },
            { "type": "email", "status": "sent", "attempts": 1 },
            { "type": "fcm", "status": "sent", "attempts": 1, "sent_at": "2026-06-04T19:24:35Z" }
          ],
          "payload": {
            "title": "Harga pesanan sudah tersedia",
            "body": "Pesanan WC-001 sudah ditimbang. Total Rp50.000. Setujui untuk lanjut diproses.",
            "actions": [ { "key": "approve_price", "label": "Setujui" }, { "key": "reject_price", "label": "Tolak" } ],
            "metadata": { "order_id": "7e1f0a2b-1111-2222-3333-444455556666", "order_number": "WC-001" }
          }
        }
        ```

        **4) `order_price_proposed` — SETTLED (approved)** (setelah customer setujui → `actions` hilang, `metadata.state="approved"`):
        ```json
        {
          "id": "order_price_proposed:7e1f0a2b-1111-2222-3333-444455556666",
          "platform": "wacca", "target_id": "22222222-2222-2222-2222-222222222222",
          "target_type": "user", "category": "order_price_proposed", "status": "sent", "read": false,
          "channels": [ { "type": "inapp", "status": "sent" }, { "type": "email", "status": "sent" }, { "type": "fcm", "status": "sent" } ],
          "payload": {
            "title": "Harga disetujui",
            "body": "Kamu telah menyetujui harga pesanan WC-001. Pesanan sedang diproses.",
            "metadata": { "order_id": "7e1f0a2b-1111-2222-3333-444455556666", "order_number": "WC-001", "state": "approved" }
          }
        }
        ```
        (Rejected: `title`="Harga ditolak", `body`="Kamu menolak harga pesanan…", `metadata.state="rejected"`.)

        **5) Inbox kosong** (`{ "data": [] }` — bukan `{}`; kalau dapat `{}`/timeout, base URL atau header `x-platform: wacca` belum di-set di FE):
        ```json
        { "success": true, "data": [], "meta": { "total": 0, "limit": 20, "offset": 0 } }
        ```
      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).

            **⚠️ 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: `<instanceID>:APA91b...` (mis. `dkZ9x7Qe7mD8aP:APA91bF8...`).

            **Status code:**
            - **`200`** — token baru terdaftar (atau token milik akun lain dipindah ke akun ini, mis. shared device).
            - **`400`** — token jelas malformed: ada prefix `fcm:`, mengandung whitespace, berupa URL (`://`), atau terlalu pendek (<100 char). Validasi sengaja konservatif — token FCM yang sah tidak akan ditolak.
            - **`409`** — token **sama** sudah terdaftar untuk **akun ini** (duplikat). App boleh perlakukan 409 sebagai "sudah terdaftar, aman" (tidak perlu retry).

            Sama 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.
          example_body: |
            {
              "token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr...",
              "platform": "android"
            }
          example_response: |
            {
              "success": true,
              "data": null,
              "message": "device token registered"
            }
        - 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.
          example_body: |
            {
              "token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr..."
            }
        - 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: |
            {
              "token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr...",
              "platform": "ios"
            }
        - name: Unregister push token (customer)
          method: DELETE
          path: /api/v1/customer/device-tokens
          auth: JWT Bearer
          description: Sama persis dengan tenant unregister.
          example_body: |
            {
              "token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr..."
            }
    - 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## \U0001F9ED 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: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-0000000000aa",
                "status": "accepted",
                "estimated_total": 87000,
                "final_total": null,
                "discount_amount": 0,
                "pickup_fee": 0,
                "tax_amount": 0,
                "updated_at": "2026-05-28T10:05:00Z"
              },
              "message": "success"
            }
        - name: Reject order
          method: POST
          path: /api/v1/tenant/orders/{id}/reject
          auth: JWT Bearer
          description: |
            Owner menolak order → `cancelled` + alasan. `reason` wajib.

            **`reason_code` (issue #43 GAP J, opsional)** — preset alasan untuk analitik. 4 nilai whitelist:
            - `capacity_full` — outlet penuh / antrian panjang
            - `service_unavailable` — service yang dipilih tidak tersedia
            - `out_of_radius` — alamat customer di luar jangkauan
            - `other` — alasan lain (gunakan bila tidak masuk 3 di atas)

            Response include `reject_reason_code` (snake_case, kosong kalau tidak diisi). Dipakai owner mobile (Figma 01b) untuk dashboard alasan reject teragregasi.
          example_body: |
            {
              "reason": "Outlet penuh, tidak bisa terima order hari ini",
              "reason_code": "capacity_full"
            }
          example_response: |
            {
              "success": true,
              "data": { "id": "...", "status": "cancelled", "reject_reason": "Outlet penuh, ...", "reject_reason_code": "capacity_full", "updated_at": "..." },
              "message": "success"
            }
        - 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`.

            Semua field body **opsional**:
            - `subtotal` — nilai jasa final; default = Σ amount order_list (termasuk addon di bawah).
            - `discount_amount` / `pickup_fee` — default = nilai existing di order.
            - `tax_code` — default `"PPN"`.
            - `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.

            Perhitungan: `tax = round(subtotal × rate)`, `final_total = subtotal − discount + pickup_fee + tax`.
          example_body: |
            {
              "subtotal": 87000,
              "discount_amount": 0,
              "pickup_fee": 0,
              "tax_code": "PPN",
              "addons": [ { "item_id": "01923c8b-...-pewangi", "quantity": 1 } ]
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "...",
                "status": "price_proposed",
                "estimated_total": 87000,
                "final_total": 96570,
                "discount_amount": 0,
                "pickup_fee": 0,
                "tax_amount": 9570,
                "updated_at": "..."
              },
              "message": "success"
            }
        - 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.
          example_body: |
            {
              "target": "ready_pickup"
            }
          example_response: |
            {
              "success": true,
              "data": { "id": "...", "status": "ready_pickup", "final_total": 96570, "tax_amount": 9570, "updated_at": "..." },
              "message": "success"
            }
        - 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").

            Tidak terikat ke `order.status` — boleh dipanggil kapan saja dalam state pickup, selama status pickup belum dimulai.

            **400** kalau `delivery_method` bukan `pickup`.
            **409** kalau `pickup_status` sudah ter-set (sudah `dispatched` atau `arrived`) — **tidak idempoten**, panggilan ke-2 ditolak.
          example_response: |
            {
              "success": true,
              "data": { "id": "...", "status": "accepted", "pickup_status": "dispatched", "pickup_dispatched_at": "2026-06-10T09:00:00Z" },
              "message": "success"
            }
        - 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.

            **400** kalau `delivery_method` bukan `pickup`.
            **409** kalau `pickup_status` belum `dispatched` atau sudah `arrived` (idempotency-aware).
        - 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).

            Body 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).

            Foto 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.
        - 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.

            Minimal salah satu field harus ada: `items[]`, `photo_media_ids[]`, atau `note`.

            **Field**:
            - `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`).
            - `photo_media_ids[]` — attach foto verifikasi (dari media-service upload). Reuse `verification-photos` POST.
            - `note` — catatan global verifikasi (disimpan di `order.verification_note`).

            **Response** `VerifyResultPresent` ringkas:
            - `verified_items` — jumlah baris yang quantity-nya di-update sukses.
            - `attached_photos` — jumlah foto yang berhasil di-attach.
            - `verification_note` — note final.
            - `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.

            **Beda dengan `verify-complete`**: `verification` = "kerjain verifikasi + finalize otomatis kalau lengkap" (1 call). `verify-complete` = hanya gate finalize tanpa update apapun.
          example_body: |
            {
              "items": [
                { "order_list_id": "01923c8b-...-ol1", "quantity": 3.2, "note": "berat aktual setelah timbang" },
                { "order_list_id": "01923c8b-...-ol2", "quantity": 1.0, "actual_pcs": 5 }
              ],
              "photo_media_ids": ["01923c8b-...-photo1", "01923c8b-...-photo2"],
              "note": "Cuci ekspress, kain putih dipisah"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "order_id": "01923c8b-...-order",
                "verified_items": 2,
                "attached_photos": 2,
                "verification_note": "Cuci ekspress, kain putih dipisah",
                "completed": true
              },
              "message": "success"
            }
        - 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-<order_id>`) — panggil dua kali tidak menggandakan revenue. Tidak mengubah status fulfilment.

            **`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).
          example_body: |
            {
              "received_amount": 100000
            }
          example_response: |
            {
              "success": true,
              "data": { "id": "...", "status": "completed", "final_total": 96570, "tax_amount": 9570, "updated_at": "..." },
              "message": "success"
            }
    - id: order-flow-customer
      title: Order Flow — Customer
      description: "Aksi customer atas order miliknya. Sebagian besar gated by `order.status`.\n\n## \U0001F9ED 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`.

            **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`.
          example_response: |
            {
              "success": true,
              "data": { "id": "...", "status": "processing", "final_total": 96570, "tax_amount": 9570, "updated_at": "..." },
              "message": "success"
            }
        - 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`.

            **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`.
          example_body: |
            {
              "reason": "Harga terlalu tinggi dari estimasi"
            }
          example_response: |
            {
              "success": true,
              "data": { "id": "...", "status": "cancelled", "reject_reason": "Harga terlalu tinggi ...", "updated_at": "..." },
              "message": "success"
            }
    - 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 & tenant sama-sama bisa list. Dipakai sebagai evidence kalau ada komplain (lihat [Customer — Complaint](#customer-complaint)).
      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.
          example_body: |
            {
              "media_ids": [
                "01923c8b-1234-7000-a000-0000000000m1",
                "01923c8b-1234-7000-a000-0000000000m2"
              ],
              "stage": "pickup",
              "note": "Pakaian diterima — 1 noda di kerah kemeja"
            }
        - 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.

        Response field `reference_id` berisi outlet id dan `reference_type` selalu `"outlet"` (auto-set oleh use-case).
      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: |
            {
              "success": true,
              "data": [
                {
                  "id": "01923c8b-1234-7000-a000-000000000200",
                  "tenant_id": "01923c8b-1234-7000-a000-000000000001",
                  "reference_id": "01923c8b-1234-7000-a000-000000000100",
                  "reference_type": "outlet",
                  "media_id": "media_8f3e9a2b",
                  "created_at": "2026-05-19T10:00:00Z",
                  "updated_at": "2026-05-19T10:00:00Z"
                }
              ],
              "pagination": { "page": 1, "limit": 20, "total": 1, "total_pages": 1, "has_next": false, "has_prev": false }
            }
        - 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.

            **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`).
          example_body: |
            {
              "reference_id": "01923c8b-1234-7000-a000-000000000100",
              "reference_type": "outlet",
              "media_id": "media_8f3e9a2b"
            }
        - 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).
          example_body: |
            {
              "media_id": "media_new_id"
            }
        - 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.
    - 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`.

        Field `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"}`).
      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: |
            {
              "reference_id": "01923c8b-1234-7000-a000-000000000100",
              "name": "Regular hours",
              "description": "Senin-Jumat",
              "opening_hours": "08:00",
              "closing_hours": "22:00"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-000000000300",
                "tenant_id": "01923c8b-1234-7000-a000-000000000001",
                "reference_id": "01923c8b-1234-7000-a000-000000000100",
                "reference_type": "outlet",
                "name": "Regular hours",
                "description": "Senin-Jumat",
                "opening_hours": "08:00",
                "closing_hours": "22:00",
                "is_active": true,
                "created_at": "2026-05-19T10:00:00Z",
                "updated_at": "2026-05-19T10:00:00Z"
              },
              "message": "created successfully"
            }
        - 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: |
            {
              "closing_hours": "23:00"
            }
        - 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.
          example_response: |
            {
              "success": true,
              "data": [
                { "day": "MON", "opening_hours": "08:00", "closing_hours": "20:00", "is_active": true },
                { "day": "TUE", "opening_hours": "08:00", "closing_hours": "20:00", "is_active": true }
              ],
              "message": "success"
            }
        - 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).

            Create-or-update 7 hari sekaligus untuk satu outlet — match by `day`. Body array config per-hari. Idempoten.
    - 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**.

        **Fields**:
        - `timezone` — IANA zone untuk interpretasi `open_time`/`close_time` (default `Asia/Jakarta`, disimpan di `outlet.timezone`).
        - `is_open` — apakah outlet operasi pada hari itu. `false` → `open_time`/`close_time` boleh omit.
        - `open_time` / `close_time` — string `HH:MM` (atau `null` saat tutup).
        - `currently_open` (response only) — apakah outlet **saat ini** open: kombinasi jam mingguan + operational-status override (lihat section bawah).
      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.
          example_response: |
            {
              "success": true,
              "data": {
                "outlet_id": "01923c8b-1234-7000-a000-000000000004",
                "timezone": "Asia/Jakarta",
                "currently_open": true,
                "days": [
                  { "day": "MON", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                  { "day": "TUE", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                  { "day": "WED", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                  { "day": "THU", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                  { "day": "FRI", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                  { "day": "SAT", "is_open": true,  "open_time": "09:00", "close_time": "18:00" },
                  { "day": "SUN", "is_open": false, "open_time": null,    "close_time": null }
                ]
              },
              "message": "success"
            }
        - 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`).

            **400** input invalid (timezone bukan IANA / format jam salah / day duplikat).
            **403** caller bukan owner/manager (capability `catalog:write` required).
          example_body: |
            {
              "timezone": "Asia/Jakarta",
              "days": [
                { "day": "MON", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                { "day": "TUE", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                { "day": "WED", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                { "day": "THU", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                { "day": "FRI", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                { "day": "SAT", "is_open": true,  "open_time": "09:00", "close_time": "18:00" },
                { "day": "SUN", "is_open": false }
              ]
            }
    - 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.

        Field `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.
      endpoints:
        - name: Get operational status
          method: GET
          path: /api/v1/tenant/outlet/{id}/operational-status
          auth: JWT Bearer
          description: Status outlet sekarang.
          example_response: |
            {
              "success": true,
              "data": {
                "outlet_id": "01923c8b-1234-7000-a000-000000000004",
                "status": "temporarily_closed",
                "reason": "Banjir, akses terhambat",
                "closed_at": "2026-06-02T08:30:00Z"
              },
              "message": "success"
            }
        - 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.

            **400** status tak dikenal atau reason kosong saat temporarily_closed.
            **403** caller tidak punya `outlet:manage`.
          example_body: |
            {
              "status": "temporarily_closed",
              "reason": "Banjir, akses terhambat"
            }
    - id: outlet
      title: Outlet
      description: |
        Cabang fisik milik tenant. Tenant bisa punya banyak outlet (cabang).

        Selain 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`).
      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: |
            {
              "success": true,
              "data": [
                {
                  "id": "01923c8b-1234-7000-a000-000000000100",
                  "tenant_id": "01923c8b-1234-7000-a000-000000000001",
                  "name": "Bersih Cepat — Cabang Pusat",
                  "description": "Cabang flagship",
                  "profile_picture_id": "01923c8b-1234-7000-a000-000000000101",
                  "mobile_number": "+62812xxxxxxx",
                  "address": "Jl. Merdeka 10",
                  "location": "Depan Indomaret",
                  "latitude": "-6.175392",
                  "longitude": "106.827153",
                  "province_id": 31,
                  "city_id": 3171,
                  "subdistrict_id": 317101,
                  "is_opening": true,
                  "driver_available": true,
                  "delivery_fee_per_km": 2000,
                  "delivery_max_distance_km": 5,
                  "timezone": "Asia/Jakarta",
                  "is_active": true,
                  "created_at": "2026-05-19T10:00:00Z",
                  "updated_at": "2026-05-19T10:00:00Z"
                }
              ],
              "pagination": { "page": 1, "limit": 20, "total": 1, "total_pages": 1, "has_next": false, "has_prev": false },
              "message": "success"
            }
        - 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`.

            **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.

            **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).

            **Composite (semua optional)** — kalau di-omit, perilaku sama dengan create lama (cuma info dasar):
            - `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 < close_time`. Hari yang tidak dikirim di-treat `is_open=false`. Timezone diambil dari field top-level `timezone`.
            - `services[]` — service yang dibuat sekaligus, `{name, description, unit_of_measure, tags, rate}`. `rate > 0`.
            - `products[]` — product yang dibuat sekaligus, `{name, description, unit_of_measure, tags, estimation, rate}`. `estimation` = label turnaround bebas (mis. "24 Jam"). `rate > 0`.

            **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.

            **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.

            **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`.
          example_body: |
            {
              "name": "Bersih Cepat — Cabang Pusat",
              "description": "Cabang flagship",
              "profile_picture_id": "media_5e8c1f2a",
              "mobile_number": "+62812xxxxxxx",
              "address": "Jl. Merdeka 10",
              "location": "Depan Indomaret",
              "latitude": "-6.175392",
              "longitude": "106.827153",
              "province_id": 31,
              "city_id": 3171,
              "subdistrict_id": 317101,
              "timezone": "Asia/Jakarta",
              "is_opening": false,
              "driver_available": true,
              "delivery_fee_per_km": 2000,
              "delivery_max_distance_km": 5,

              "operating_hours": {
                "days": [
                  { "day": "MON", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                  { "day": "TUE", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                  { "day": "WED", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                  { "day": "THU", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                  { "day": "FRI", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                  { "day": "SAT", "is_open": true,  "open_time": "09:00", "close_time": "18:00" },
                  { "day": "SUN", "is_open": false }
                ]
              },

              "services": [
                {
                  "name": "Cuci Reguler",
                  "description": "Cuci + setrika standar",
                  "unit_of_measure": "kg",
                  "tags": "reguler",
                  "rate": 7000
                }
              ],

              "products": [
                {
                  "name": "Selimut King",
                  "description": "Cuci khusus selimut besar",
                  "unit_of_measure": "pcs",
                  "tags": "khusus",
                  "estimation": "48 Jam",
                  "rate": 35000
                }
              ]
            }
          example_response: |
            {
              "success": true,
              "data": {
                "outlet": {
                  "id": "01923c8b-1234-7000-a000-000000000100",
                  "name": "Bersih Cepat — Cabang Pusat",
                  "timezone": "Asia/Jakarta",
                  "delivery_fee_per_km": 2000,
                  "delivery_max_distance_km": 5,
                  "is_opening": false,
                  "driver_available": true,
                  "is_active": true,
                  "summary": { "total_orders": 0, "total_income": 0 },
                  "created_at": "2026-06-02T07:50:00Z",
                  "updated_at": "2026-06-02T07:50:00Z"
                },
                "operating_hours": {
                  "outlet_id": "01923c8b-1234-7000-a000-000000000100",
                  "timezone": "Asia/Jakarta",
                  "currently_open": true,
                  "days": [
                    { "day": "MON", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                    { "day": "TUE", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                    { "day": "WED", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                    { "day": "THU", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                    { "day": "FRI", "is_open": true,  "open_time": "08:00", "close_time": "20:00" },
                    { "day": "SAT", "is_open": true,  "open_time": "09:00", "close_time": "18:00" },
                    { "day": "SUN", "is_open": false, "open_time": null,    "close_time": null }
                  ]
                },
                "services": [
                  {
                    "id": "01923c8b-...-svc1",
                    "outlet_id": "01923c8b-1234-7000-a000-000000000100",
                    "type": "service",
                    "name": "Cuci Reguler",
                    "description": "Cuci + setrika standar",
                    "tags": "reguler",
                    "is_active": true,
                    "price": {
                      "id": "01923c8b-...-prc1",
                      "unit_of_measure": "kg",
                      "rate": 7000,
                      "is_active": true
                    },
                    "created_at": "2026-06-02T07:50:00Z",
                    "updated_at": "2026-06-02T07:50:00Z"
                  }
                ],
                "products": [
                  {
                    "id": "01923c8b-...-prd1",
                    "outlet_id": "01923c8b-1234-7000-a000-000000000100",
                    "type": "product",
                    "name": "Selimut King",
                    "description": "Cuci khusus selimut besar",
                    "tags": "khusus",
                    "estimation": "48 Jam",
                    "is_active": true,
                    "price": {
                      "id": "01923c8b-...-prc2",
                      "unit_of_measure": "pcs",
                      "rate": 35000,
                      "is_active": true
                    },
                    "created_at": "2026-06-02T07:50:00Z",
                    "updated_at": "2026-06-02T07:50:00Z"
                  }
                ]
              },
              "message": "outlet created successfully"
            }
        - 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 & FE). Untuk toggle `driver_available` gunakan `PATCH .../open-drivers` dan `PATCH .../close-drivers`.
          example_body: |
            {
              "description": "Cabang flagship — buka 24 jam akhir pekan",
              "driver_available": false
            }
        - 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.
        - 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.

            Gunakan saat kasir mulai shift / outlet buka pintu. Tidak mengubah `is_active`.
        - name: Close outlet
          method: PATCH
          path: /api/v1/tenant/outlet/{outlet-id}/close-outlet
          auth: JWT Bearer
          description: |
            Set `is_opening = false`. Idempotent.

            Gunakan saat kasir akhir shift / outlet tutup pintu. Untuk archive permanen pakai `DELETE`.
        - 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.
        - name: Close drivers
          method: PATCH
          path: /api/v1/tenant/outlet/{outlet-id}/close-drivers
          auth: JWT Bearer
          description: |
            Set `driver_available = false`. Idempotent.
        - 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`.

            `outlet.total_outlet` selalu `1` (karena endpoint ini per-outlet); `outlet_active`/`outlet_non_active` = 1 sesuai status outlet.
          example_response: |
            {
              "success": true,
              "data": {
                "total_debit": 0,
                "total_orders": 42,
                "total_income": 1500000,
                "outlet": {
                  "total_outlet": 1,
                  "outlet_active": 1,
                  "outlet_non_active": 0
                }
              },
              "message": "Success"
            }
    - id: payment-methods
      title: Payment Methods
      description: |
        Master metode pembayaran shared platform. Sumber data:
        - **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.
        - **Offline** (internal wacca): `cash` (kasir) dan `cod` (Cash on Delivery), di-seed lewat migration `20260609120200`.

        Field penting:
        - `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.
        - `is_offline` — `true` untuk `cash`/`cod`; metode offline tidak butuh redirect/webhook payment-service.
        - `is_active` — toggle visibility di dropdown checkout.

        **Permission**:
        - List = open ke semua JWT authenticated (dipakai checkout customer dan kasir tenant).
        - Create + Update = **admin platform only**, gate `wacca-admin:*`. Tenant biasa tidak boleh add/edit master.

        **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.

        **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.
      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 & is_offline=false` di checkout customer).
          example_response: |
            {
              "success": true,
              "data": [
                { "id": "01923c8b-...-pm-cash", "name": "Cash",       "code": "cash",          "provider": "wacca",   "channel": "cash",          "is_offline": true,  "is_active": true },
                { "id": "01923c8b-...-pm-cod",  "name": "COD",        "code": "cod",           "provider": "wacca",   "channel": "cod",           "is_offline": true,  "is_active": true },
                { "id": "01923c8b-...-pm-qris", "name": "QRIS",       "code": "qris",          "provider": "Midtrans","channel": "qris",          "is_offline": false, "is_active": true },
                { "id": "01923c8b-...-pm-bca",  "name": "BCA VA",     "code": "bank_transfer", "provider": "Midtrans","channel": "bank_transfer", "subchannel": "bca", "is_offline": false, "is_active": true }
              ],
              "message": "success"
            }
        - 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).

            `code` di-set server-side berdasar `channel` (kecuali offline).
          example_body: |
            {
              "name": "OVO",
              "provider": "Midtrans",
              "channel": "ovo"
            }
        - 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).
          example_body: |
            { "is_active": false }
    - id: payments-revenue
      title: Payments & 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`.

        Endpoint di sini **bukan** JWT — diverifikasi shared-secret. Kalau secret/key belum di-set di env, endpoint balas `503` (dinonaktifkan, fail-safe).
      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`.

            **Idempoten** by `event_id` — event yang sama dikirim ulang tidak diproses dua kali. Event disimpan mentah di `payment_events`, lalu diproyeksikan ke `outlet_revenue`:
            - `payment.paid` → baris revenue baru (channel `digital`, settlement `pending`); `net = amount − pajak order`.
            - `payment.settled` → baris di-update `settled` (cair).
            - `payment.refunded` → baris dinolkan & ditandai `refunded`.
            - `payment.failed` → hanya dicatat di `payment_events`, tanpa proyeksi.

            Pajak diambil dari snapshot di order (tidak dihitung ulang) → konsisten dengan tagihan final.

            Respons `status`: `processed` | `duplicate` | `recorded_no_order` (event tersimpan tapi order tak ditemukan → proyeksi ditunda).
          example_body: |
            {
              "event_id": "evt_01J9XYZ",
              "event": "payment.paid",
              "payment_ref": "pay_abc123",
              "order_id": "01923c8b-1234-7000-a000-0000000000aa",
              "amount": 96570,
              "method": "qris",
              "channel": "digital",
              "paid_at": "2026-05-28T10:00:00Z"
            }
          example_response: |
            {
              "success": true,
              "data": { "event_id": "evt_01J9XYZ", "status": "processed", "projected": true },
              "message": "ok"
            }
    - 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.
      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.

            `action`:
            - `approve_expired_price` — order `price_proposed` yang tak direspons customer → `processing`.
            - `complete_expired_delivery` — order `ready_pickup`/`delivering` lewat ambang → `completed`.

            Filter (semua opsional): `older_than_hours` (default 24), `outlet_ids`, `order_ids`, `dry_run` (preview tanpa eksekusi). Respons mencantumkan setiap transisi (`items`).
          example_body: |
            {
              "action": "approve_expired_price",
              "older_than_hours": 24,
              "outlet_ids": [],
              "order_ids": [],
              "dry_run": true
            }
          example_response: |
            {
              "success": true,
              "data": {
                "action": "approve_expired_price",
                "dry_run": true,
                "matched": 1,
                "advanced": 0,
                "items": [
                  { "order_id": "01923c8b-1234-7000-a000-0000000000aa", "from": "price_proposed", "to": "processing" }
                ]
              },
              "message": "success"
            }
    - 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"`.

        Tidak ada concept "harga global" di endpoint ini — selalu per-outlet. Untuk price list / katalog cross-outlet, kombinasikan query side-by-side dari sisi caller.
      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.
          example_body: |
            {
              "outlet_service_id": "01923c8b-1234-7000-a000-000000000400",
              "name": "Cuci Kering Reguler",
              "description": "1-2 hari",
              "unit_of_measure": "kg",
              "rate": 8000
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-000000000500",
                "tenant_id": "01923c8b-1234-7000-a000-000000000001",
                "item_id": "01923c8b-1234-7000-a000-000000000400",
                "name": "Cuci Kering Reguler",
                "description": "1-2 hari",
                "unit_of_measure": "kg",
                "rate": 8000,
                "type": "service",
                "tags": "by-outlet",
                "is_active": true,
                "created_at": "2026-05-19T10:00:00Z",
                "updated_at": "2026-05-19T10:00:00Z"
              },
              "message": "created successfully"
            }
        - 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: |
            {
              "rate": 9000,
              "description": "1 hari"
            }
        - 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`.
      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: |
            {
              "outlet_product_id": "01923c8b-1234-7000-a000-000000000410",
              "name": "Pewangi Lavender 500ml",
              "unit_of_measure": "botol",
              "rate": 25000
            }
        - 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`.

        Use case ongkir umumnya pakai `rate` per trip atau per km — bebas; service tidak validate satuan.
      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: |
            {
              "outlet_driver_id": "01923c8b-1234-7000-a000-000000000420",
              "name": "Antar Reguler 0-5km",
              "unit_of_measure": "trip",
              "rate": 10000
            }
        - 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.

        ### Dua skenario auth

        1. **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.

        2. **Google OAuth preauth** — kalau client sudah login via Google (di account-service sebelumnya, dapat Google JWT), kirim token Google itu di `Authorization: Bearer <google_token>`. `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).

        Field opsional `timezone` (mis. `Asia/Jakarta`) + `language` (`id`/`en`) di-forward ke account-service untuk lokalisasi notif email.
      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).

            **Path Google preauth**: kirim Google JWT di `Authorization: Bearer <token>`. Middleware `OptionalJWTAuth` verify → claim `sub` jadi `PreauthAccountID`. Body `email`/`password`/`display_name` **diabaikan** di path ini.

            Field 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).

            **Errors**:
            - **400 `email is required` / `password is required` / `display_name is required`** — path email/password only.
            - **400 `email already exists`** — account-service balas 409 (email duplikat upstream).
            - **400 `invalid account`** — `PreauthAccountID` (kalau ada) bukan UUID valid.
            - **500 `account registrar not configured`** — `account_service_client` belum di-config (server bug).
            - **502** — account-service unreachable atau response invalid (`missing user id` / `invalid user id`).
            - **401/503** dari middleware kalau Bearer token dikirim tapi invalid (401) atau key store kosong (503).
          example_body: |
            {
              "email": "siti@example.com",
              "password": "secret-pass-123",
              "display_name": "Siti Nurhaliza",
              "timezone": "Asia/Jakarta",
              "language": "id",
              "mobile_number": "+62811xxxxxxx",
              "address": "Jl. Merdeka 10",
              "location": "Depan Indomaret",
              "latitude": "-6.175392",
              "longitude": "106.827153"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "01923c8b-1234-7000-a000-000000000010",
                "email": "siti@example.com",
                "access_token": "eyJ...",
                "refresh_token": "eyJ...",
                "expires_in": 900,
                "profile": {
                  "id": "01923c8b-1234-7000-a000-000000000010",
                  "organization_id": "0db3bcc1-8e2e-4519-9a41-d4f1d314136a",
                  "name": "Siti Nurhaliza",
                  "address": "Jl. Merdeka 10",
                  "location": "Depan Indomaret",
                  "latitude": "-6.175392",
                  "longitude": "106.827153",
                  "is_active": true
                }
              },
              "message": "customer registered successfully"
            }
        - 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.

            **Token & 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.

            **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.

            **Errors**:
            - **400 `business_name is required`** — selalu wajib.
            - **400 `email is required` / `password is required` / `display_name is required`** — path email/password only.
            - **400 `email already exists`** — account-service balas 409.
            - **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.
            - **500** — `account_service_client` tidak ter-config.
            - **502** — account-service unreachable / response invalid / `CreateOrganization` gagal.
          example_body: |
            {
              "email": "budi@laundry.id",
              "password": "secret-pass-123",
              "display_name": "Budi Santoso",
              "timezone": "Asia/Jakarta",
              "language": "id",
              "business_name": "Bersih Cepat Laundry",
              "nationality_number": "3201xxxxxxxxxxxx",
              "mobile_number": "+62812xxxxxxx",
              "description": "Cabang flagship"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "01923c8b-1234-7000-a000-000000000020",
                "org_id": "01923c8b-1234-7000-a000-000000000099",
                "email": "budi@laundry.id",
                "access_token": "eyJ...",
                "refresh_token": "eyJ...",
                "expires_in": 900,
                "tenant": {
                  "id": "01923c8b-1234-7000-a000-000000000001",
                  "organization_id": "01923c8b-1234-7000-a000-000000000099",
                  "business_name": "Bersih Cepat Laundry",
                  "owner_name": "Budi Santoso",
                  "nationality_number": "3201xxxxxxxxxxxx",
                  "mobile_number": "+62812xxxxxxx",
                  "is_active": true
                }
              },
              "message": "tenant registered successfully"
            }
    - 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.

        Internally 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.

        Tidak ada GET/UPDATE/DELETE di sini — gunakan endpoint `outlet-services` dan `service-prices` masing-masing setelah setup.
      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.
          example_body: |
            {
              "outlet_id": "01923c8b-1234-7000-a000-000000000100",
              "name": "Cuci Kering Reguler",
              "description": "1-2 hari",
              "unit_of_measure": "kg",
              "tags": "standard,reguler",
              "rate": 8000
            }
          example_response: |
            {
              "success": true,
              "data": {
                "service": {
                  "id": "01923c8b-1234-7000-a000-000000000400",
                  "outlet_id": "01923c8b-1234-7000-a000-000000000100",
                  "type": "service",
                  "name": "Cuci Kering Reguler",
                  "description": "1-2 hari",
                  "unit_of_measure": "kg",
                  "tags": "standard,reguler",
                  "is_active": true,
                  "created_at": "2026-05-21T10:00:00Z",
                  "updated_at": "2026-05-21T10:00:00Z"
                },
                "price": {
                  "id": "01923c8b-1234-7000-a000-000000000500",
                  "outlet_service_id": "01923c8b-1234-7000-a000-000000000400",
                  "name": "Cuci Kering Reguler",
                  "description": "1-2 hari",
                  "unit_of_measure": "kg",
                  "rate": 8000,
                  "is_active": true,
                  "created_at": "2026-05-21T10:00:00Z",
                  "updated_at": "2026-05-21T10:00:00Z"
                }
              },
              "message": "service setup created successfully"
            }
        - 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.
        - 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.
      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: |
            {
              "outlet_id": "01923c8b-1234-7000-a000-000000000100",
              "name": "Pewangi Lavender 500ml",
              "description": "Botol semprot",
              "unit_of_measure": "botol",
              "tags": "fragrance",
              "rate": 25000
            }
          example_response: |
            {
              "success": true,
              "data": {
                "product": {
                  "id": "01923c8b-1234-7000-a000-000000000410",
                  "outlet_id": "01923c8b-1234-7000-a000-000000000100",
                  "type": "product",
                  "name": "Pewangi Lavender 500ml",
                  "unit_of_measure": "botol",
                  "tags": "fragrance",
                  "is_active": true,
                  "created_at": "2026-05-21T10:00:00Z",
                  "updated_at": "2026-05-21T10:00:00Z"
                },
                "price": {
                  "id": "01923c8b-1234-7000-a000-000000000510",
                  "outlet_product_id": "01923c8b-1234-7000-a000-000000000410",
                  "name": "Pewangi Lavender 500ml",
                  "unit_of_measure": "botol",
                  "rate": 25000,
                  "is_active": true,
                  "created_at": "2026-05-21T10:00:00Z",
                  "updated_at": "2026-05-21T10:00:00Z"
                }
              },
              "message": "product setup created successfully"
            }
    - 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.
      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: |
            {
              "outlet_id": "01923c8b-1234-7000-a000-000000000100",
              "name": "Antar Reguler 0-5km",
              "description": "Motor matic",
              "unit_of_measure": "trip",
              "tags": "motor,city",
              "rate": 10000
            }
          example_response: |
            {
              "success": true,
              "data": {
                "driver": {
                  "id": "01923c8b-1234-7000-a000-000000000420",
                  "outlet_id": "01923c8b-1234-7000-a000-000000000100",
                  "type": "driver",
                  "name": "Antar Reguler 0-5km",
                  "unit_of_measure": "trip",
                  "tags": "motor,city",
                  "is_active": true,
                  "created_at": "2026-05-21T10:00:00Z",
                  "updated_at": "2026-05-21T10:00:00Z"
                },
                "price": {
                  "id": "01923c8b-1234-7000-a000-000000000520",
                  "outlet_driver_id": "01923c8b-1234-7000-a000-000000000420",
                  "name": "Antar Reguler 0-5km",
                  "unit_of_measure": "trip",
                  "rate": 10000,
                  "is_active": true,
                  "created_at": "2026-05-21T10:00:00Z",
                  "updated_at": "2026-05-21T10:00:00Z"
                }
              },
              "message": "driver setup created successfully"
            }
    - 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.

        Untuk ringkasan per-outlet (bukan tenant-level) gunakan `GET /tenant/outlet/{outlet-id}/summary` di section Outlet.
      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: |
            {
              "success": true,
              "data": {
                "total_outlet": 5,
                "outlet_active": 4,
                "outlet_non_active": 1
              },
              "message": "Success"
            }
        - 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.

            - `total_income` — net revenue (settled + pending) setelah PPN dipotong.
            - `settled_income` — net yang sudah cair/lunas.
            - `pending_income` — net digital menunggu settlement (cair H+1).
            - `cash_income` / `digital_income` — pecahan net per kanal.
            - `gross_income` — total dibayar customer (termasuk PPN).
            - `tax_collected` — total PPN pass-through (bukan pendapatan outlet).
            - `total_debit` — `0`, placeholder untuk feature withdrawal/billing.
          example_response: |
            {
              "success": true,
              "data": {
                "total_debit": 0,
                "total_income": 11250000,
                "settled_income": 9800000,
                "pending_income": 1450000,
                "cash_income": 4200000,
                "digital_income": 7050000,
                "gross_income": 12487500,
                "tax_collected": 1237500
              },
              "message": "Success"
            }
        - name: Orders summary
          method: GET
          path: /api/v1/tenant/summary/orders
          auth: JWT Bearer
          description: Total jumlah order tenant.
          example_response: |
            {
              "success": true,
              "data": {
                "total_orders": 1234
              },
              "message": "Success"
            }
        - 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.
          example_response: |
            {
              "success": true,
              "data": {
                "total_debit": 0,
                "total_orders": 1234,
                "total_income": 12500000,
                "outlet": {
                  "total_outlet": 5,
                  "outlet_active": 4,
                  "outlet_non_active": 1
                }
              },
              "message": "Success"
            }
    - 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-<order_id>`). Saldo debt nempel di tenant.

        **Gate baru COD**: kalau saldo hutang aktif > 0, owner **tidak boleh** terima/create order COD baru sampai dibayar lewat `/cod-debt/repay`. Cash flow lain (qris/bank_transfer) tidak terdampak.
      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).
          example_response: |
            {
              "success": true,
              "data": {
                "tenant_id": "01923c8b-1234-7000-a000-000000000001",
                "outstanding": 45000,
                "billing_mode": "per_order",
                "threshold": 50000,
                "can_accept_cod": true
              },
              "message": "success"
            }
        - 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.
          example_body: |
            {
              "amount": 25000,
              "note": "TRX-20260601-001 transfer bank"
            }
    - 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`.

        Walk-in COD ikut akrual komisi (lihat [Tenant — COD Commission Debt](#tenant-cod-commission)).
      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.

            **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.

            **`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.

            **403 cod-gated** kalau payment_method=cod dan tenant punya saldo hutang COD aktif (lihat `/cod-debt`).
          example_body: |
            {
              "idempotency_key": "01923c8b-7777-7000-aaaa-1234567890ab",
              "outlet_id": "01923c8b-1234-7000-a000-000000000004",
              "guest_name": "Bu Ani",
              "guest_phone": "08123456789",
              "payment_method_id": "01923c8b-...-pm-cod",
              "tax_code": "PPN11",
              "discount": 5000,
              "items": [
                { "item_id": "01923c8b-...-svc1", "quantity": 2.5, "unit_of_measure": "kg" },
                { "item_id": "01923c8b-...-prd1", "quantity": 1, "unit_of_measure": "pcs" }
              ],
              "note": "Cuci express selesai sore ini"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-0000000000w1",
                "order_number": "WCC-260601-W01",
                "outlet_id": "01923c8b-1234-7000-a000-000000000004",
                "customer_id": null,
                "guest_name": "Bu Ani",
                "guest_phone": "08123456789",
                "payment_method": "cod",
                "final_total": 32500,
                "status": "processing",
                "receipt_url": "/receipts/WCC-260601-W01.pdf"
              },
              "message": "walk-in order created"
            }
        - 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 = <customer_id>`; data guest (`guest_name`, `guest_phone`) tetap disimpan untuk audit.

            **Guard**:
            - **400** kalau order bukan walk-in (tidak ada `guest_name`).
            - **400** kalau order sudah ter-link (`customer_id != NULL`) — cegah owner menimpa akun lain.
            - **400** kalau `customer_id` tidak valid (UUID invalid atau bukan account aktif).
            - **404** kalau order tidak ditemukan / bukan milik tenant.

            Response = 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).
          example_body: |
            {
              "customer_id": "01923c8b-1234-7000-a000-000000000010"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-0000000000w1",
                "order_number": "WCC-260601-W01",
                "customer_id": "01923c8b-1234-7000-a000-000000000010",
                "guest_name": "Bu Ani",
                "guest_phone": "08123456789",
                "status": "processing"
              },
              "message": "account linked successfully"
            }
    - 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.

        Workflow:
        1. Login → cek `GET /tenant` → 400 "you are not registered as a tenant" kalau belum di-bootstrap.
        2. `POST /tenant` dengan `business_name` → state berubah menjadi tenant aktif.
        3. `PUT /tenant` untuk update profil partial.
      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: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-000000000001",
                "organization_id": "0db3bcc1-8e2e-4519-9a41-d4f1d314136a",
                "business_name": "Bersih Cepat Laundry",
                "owner_name": "Budi Santoso",
                "nationality_number": "3201xxxxxxxxxxxx",
                "mobile_number": "+62812xxxxxxx",
                "is_active": true,
                "created_at": "2026-05-19T10:00:00Z",
                "updated_at": "2026-05-19T10:00:00Z"
              },
              "message": "success",
              "request_id": "uuid"
            }
        - name: Create tenant
          method: POST
          path: /api/v1/tenant/profile
          auth: JWT Bearer
          description: |
            Bootstrap tenant pertama kali. `organization_id` otomatis dari JWT.

            **400 'your account already registered'** kalau tenant untuk org ini sudah ada (re-cek via GET /tenant dulu).
          example_body: |
            {
              "business_name": "Bersih Cepat Laundry",
              "owner_name": "Budi Santoso",
              "nationality_number": "3201xxxxxxxxxxxx",
              "mobile_number": "+62812xxxxxxx"
            }
          example_response: |
            {
              "success": true,
              "data": { "id": "...", "organization_id": "...", "business_name": "Bersih Cepat Laundry", "is_active": true, ... },
              "message": "tenant created successfully",
              "request_id": "uuid"
            }
        - 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: |
            {
              "owner_name": "Budi Santoso S.E.",
              "mobile_number": "+628999999999"
            }
    - 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`).

        **Sumber data per-field di response:**
        - `name` — `accounts.display_name` (event-synced dari account-service, **read-only** di wacca; field `name` di body ignored saat create/update).
        - `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.
        - `address`/`location`/`latitude`/`longitude` — `address` table (default address, `reference_type=customer`).
        - `profile_picture_id` — belum dipersist (returns empty/zero).

        Workflow: panggil `POST /customer/profile` saat onboarding (idempotent — memastikan default address ada); selanjutnya `GET`/`PUT` sesuai kebutuhan.
      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).
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-000000000010",
                "organization_id": "0db3bcc1-8e2e-4519-9a41-d4f1d314136a",
                "name": "Siti Nurhaliza",
                "profile_picture_id": "00000000-0000-0000-0000-000000000000",
                "address": "Jl. Merdeka 10",
                "location": "Jakarta Pusat",
                "latitude": "-6.175392",
                "longitude": "106.827153",
                "mobile_number": "+62811xxxxxxx",
                "is_active": true,
                "created_at": "2026-05-19T10:00:00Z",
                "updated_at": "2026-05-29T08:00:00Z"
              },
              "message": "success",
              "request_id": "uuid"
            }
        - 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.

            **400 'name is required'** bila `name` kosong/whitespace.
          example_body: |
            {
              "name": "Siti Nurhaliza",
              "address": "Jl. Merdeka 10",
              "location": "Jakarta Pusat",
              "latitude": "-6.175392",
              "longitude": "106.827153",
              "mobile_number": "+62811xxxxxxx"
            }
        - name: Update profile
          method: PUT
          path: /api/v1/customer/profile
          auth: JWT Bearer
          description: |
            Partial update. **Writable**:
            - `address`/`location`/`latitude`/`longitude` → upsert ke default `address` row.
            - `mobile_number` → upsert ke `customer_profile.mobile_phone`. Empty/whitespace dilewati (gak hapus nilai existing — perlu endpoint khusus untuk clear).

            **Read-only (diterima tapi no-op)**: `name` (display_name dari accounts), `profile_picture_id`, `is_active`. Mutasi identity inti dilakukan di account-service.

            Selalu 200 (termasuk untuk update identitas-only).
          example_body: |
            {
              "address": "Jl. Sudirman 5, Apt 12",
              "latitude": "-6.208763",
              "longitude": "106.845599"
            }
    - 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)).

        Data statis (read-only). Tidak ada filter selain parent ID.

        **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.

        Endpoint authenticated (JWT) — sama gating dengan endpoint domain lain. ID adalah integer (bukan UUID) karena merefer ke nomor kode wilayah BPS.
      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: |
            {
              "success": true,
              "data": [
                { "id": 31, "name": "DKI Jakarta" },
                { "id": 32, "name": "Jawa Barat" },
                { "id": 33, "name": "Jawa Tengah" }
              ],
              "message": "success"
            }
        - 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: |
            {
              "success": true,
              "data": [
                { "id": 3171, "province_id": 31, "name": "Kota Jakarta Pusat" },
                { "id": 3172, "province_id": 31, "name": "Kota Jakarta Utara" }
              ],
              "message": "success"
            }
        - 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: |
            {
              "success": true,
              "data": [
                { "id": 317101, "city_id": 3171, "name": "Gambir" },
                { "id": 317102, "city_id": 3171, "name": "Sawah Besar" }
              ],
              "message": "success"
            }
    - id: tenant-member
      title: Tenant Member (Karyawan)
      description: |
        Manajemen karyawan tenant: undang/daftarkan akun, beri **role**, dan
        tugaskan ke **outlet** tertentu. Dua tabel mendasarinya:

        - `tenant_member` — karyawan di bawah satu tenant, punya `role_id`.
        - `outlet_member` — junction yang menugaskan member ke outlet spesifik.

        **Model akses berbasis role:**
        - `admin` / `manager` → akses **implisit ke semua outlet** tenant; tidak
          butuh baris `outlet_member` (`outlet_ids` diabaikan saat assign).
        - `staff` (operator/viewer) → akses **eksplisit**; harus punya baris
          `outlet_member` per outlet yang ditugaskan.

        **Aturan kepemilikan (penting):** setiap `outlet_id` yang ditugaskan wajib
        milik tenant pemanggil. Menugaskan member ke outlet milik tenant lain
        (atau outlet yang sudah dihapus) ditolak `400` — mencegah eskalasi hak.
        Menugaskan ke outlet milik sendiri yang sedang non-aktif tetap diizinkan
        (penugasan staf adalah pembukuan internal, lepas dari status buka/tutup).

        Akun login dikelola di **account-service**; endpoint di sini memetakan
        akun tersebut ke tenant + outlet. Role yang valid diambil dari
        `GET /api/v1/tenant/roles`.

        **Identitas (`display_name`/`email`) bersumber dari mirror `accounts`**
        (event-synced dari account-service via RabbitMQ). Tidak lagi disimpan
        lokal di `tenant_member`. Saat Create/Invite/Register, server seed mirror
        saat itu juga supaya list langsung punya nama; selanjutnya event accounts
        yang menjaga supaya tidak basi. Anggota lama yang event-nya belum tiba
        bisa muncul dengan `display_name`/`email` kosong sementara — bukan error.

        **Role lintas-tenant ditolak:** `role_id` yang `tenant_id`-nya milik
        tenant lain ditolak `400 invalid role_id` di Create / Invite / Register /
        Update. Role sistem (`tenant_id NULL`, mis. `admin`/`manager`/`staff`)
        shared dan tetap assignable oleh semua tenant.

        **Owner tenant disembunyikan dari list & summary.** Akun yang bootstrap
        tenant (`tenant_detail.owner_account_id`) bukan "staff", jadi:
        - `GET /api/v1/tenant/member` (list) — baris owner tidak muncul, **tidak
          peduli query param** (`?account_id=<owner>` juga balas list kosong
          karena exclude di-apply unconditional).
        - `GET /summary` — semua 3 badge (active/on_leave/inactive) tidak
          menghitung owner. Headcount tenant aktif **turun by 1** dibanding
          sebelum perubahan ini.

        Untuk resolve owner via REST: pakai `GET /api/v1/tenant/profile`
        (`owner_account_id` + `owner_name`) atau `GET /api/v1/tenant/member/{id}`
        kalau ID `tenant_member`-nya sudah diketahui.

        **Hak akses per role (RBAC):** role bukan cuma soal assignment outlet — ia
        juga menggate aksi di seluruh API tenant. Matrix lengkap, outlet-scope,
        dan rollout (`RBAC_ENFORCE`) ada di section **RBAC — Hak Akses Karyawan**.
      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).

            Badge **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.

            **`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.

            **Owner tenant tidak dihitung** di semua badge — owner = bootstrap akun, bukan staf.

            **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`.
          example_response: |
            {
              "success": true,
              "data": { "total": 8, "active": 6, "on_leave": 1, "inactive": 1, "invited": 2 },
              "message": "success"
            }
        - 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.

            **Identitas per mode:** `assign` & `invite` pakai **`user_id`** (account UUID,
            dari `GET /api/v1/accounts/search` di frontend). `register` pakai **`email`**
            (akun baru dibuat). Email **tidak lagi** diterima untuk assign/invite.

            **`mode`** (required, `oneof=assign invite register`):
            - **`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.
            - **`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.
            - **`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).
              - `account.display_name` **required** untuk mode=register.
              - `account.password` boleh kosong → server **auto-generate temp password**, kembalikan **sekali** di `data.temp_password` (owner wajib catat — tidak bisa di-recall).

            **Field per mode**:
            - **`user_id`** (uuid) — **WAJIB untuk `assign` & `invite`** (diabaikan untuk register).
            - **`email`** — **WAJIB untuk `register`** (diabaikan untuk assign/invite).
            - `mobile_number`, `profile_picture_id`, `role_id` (**optional**), `outlet_ids` (optional) — common ke semua mode.
            - `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.
            - `outlet_ids` diabaikan untuk role admin/manager (akses implisit ke semua outlet, lihat [Update member](#tenant-member) untuk rule `outlet:all`).
            - **Role lintas-tenant ditolak**: `role_id` yang `tenant_id`-nya milik tenant lain → 400 `invalid role_id`. Role sistem (`tenant_id NULL`) shared.

            **Response shape** (`CreateMemberPresent`):
            - `assign` → `member` populated.
            - `invite` → `invitation_id` populated; **`member` tidak ada** sampai invitee accept. Pesan menjelaskan "user needs to accept invitation".
            - `register` → `user_id` + `member`; `temp_password` muncul **hanya** kalau server generate (password owner dikosongkan).
          example_body: |
            # mode=assign — user sudah di org (pakai user_id dari /accounts/search)
            {
              "mode": "assign",
              "user_id": "37d00210-91c7-40b6-ab71-122c334a45ea",
              "role_id": "01900000-0000-7fff-8fff-000000000012",
              "outlet_ids": ["01923c8b-aaaa-7000-a000-0000000000a4"]
            }

            # mode=invite — kirim undangan (pakai user_id; email di-resolve server-side)
            {
              "mode": "invite",
              "user_id": "37d00210-91c7-40b6-ab71-122c334a45ea",
              "role_id": "01900000-0000-7fff-8fff-000000000012",
              "mobile_number": "081298765432",
              "outlet_ids": ["01923c8b-aaaa-7000-a000-0000000000a4"]
            }

            # mode=register — bikin akun baru + temp password auto-generate
            {
              "mode": "register",
              "email": "rina@laundry.id",
              "role_id": "01900000-0000-7fff-8fff-000000000012",
              "mobile_number": "081234567890",
              "outlet_ids": ["01923c8b-aaaa-7000-a000-0000000000a4"],
              "account": {
                "display_name": "Rina Wijaya",
                "password": "",
                "timezone": "Asia/Jakarta",
                "language": "id"
              }
            }

            # mode=register — minimal (role_id & outlet_ids di-omit; default ke role "staff", no outlet)
            {
              "mode": "register",
              "email": "rina@laundry.id",
              "account": { "display_name": "Rina Wijaya" }
            }
          example_response: |
            # mode=assign / invite (sudah di org) / register — member populated
            {
              "success": true,
              "data": {
                "mode": "register",
                "message": "user registered and assigned to tenant and outlets",
                "user_id": "01923c8b-2222-7000-a000-000000000002",
                "member": {
                  "id": "01923c8b-3333-7000-a000-000000000003",
                  "account_id": "01923c8b-2222-7000-a000-000000000002",
                  "display_name": "Rina Wijaya",
                  "email": "rina@laundry.id",
                  "role_id": "01900000-0000-7fff-8fff-000000000012",
                  "role_name": "staff",
                  "mobile_number": "081234567890",
                  "is_active": true,
                  "status": "active",
                  "outlets": [{ "id": "01923c8b-aaaa-7000-a000-0000000000a4", "name": "Cabang Pusat" }],
                  "created_at": "2026-06-03T08:00:00Z",
                  "updated_at": "2026-06-03T08:00:00Z"
                },
                "temp_password": "kQ7s-mE4t-9pX"
              },
              "message": "success"
            }

            # mode=invite — user belum accept
            {
              "success": true,
              "data": {
                "mode": "invite",
                "message": "user needs to accept invitation before outlet assignment",
                "invitation_id": "01923c8b-4444-7000-a000-000000000004"
              },
              "message": "success"
            }
        - 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.

            **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:
            - `id` — invitation ID (BUKAN tenant_member ID).
            - `account_id` — invitee.
            - `invitation_id` — sama dengan `id`, di-expose terpisah untuk kejelasan.
            - `expired: true` kalau melewati `expires_at` (owner bisa resend; lihat endpoint Resend invitation).
            - `status: "invited"`.

            Filter `?status=invited` → return **HANYA** pending invitations (paged in-memory).
            Filter `?status=active|on_leave|inactive` → tetap real-members-only (overlay tidak di-apply).

            **Owner tenant tidak muncul di list.** Akun pembuat tenant (`tenant_detail.owner_account_id`) di-exclude unconditional — bahkan `?account_id=<owner>` balas list kosong. Untuk resolve owner pakai `GET /api/v1/tenant/profile`.

            **Tab Semua/Aktif/Cuti/Nonaktif/Diundang** di UI:
            - tab Semua → tidak kirim `is_active`/`status` (overlay invited aktif)
            - tab Aktif → `status=active` (atau `is_active=true` untuk inklusi cuti)
            - tab Cuti → `status=on_leave`
            - tab Nonaktif → `status=inactive` (atau `is_active=false`)
            - tab Diundang → `status=invited` (hanya pending)
          query_params:
            - name: page
              type: int
              default: "1"
              description: Halaman 1-based. Nilai `<1` di-clamp ke `1`.
            - name: limit
              type: int
              default: "20"
              description: 'Jumlah per halaman. Clamp: `<1 → 20`, `>100 → 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: |
            {
              "success": true,
              "data": [
                {
                  "id": "01923c8b-4444-7000-a000-000000000004",
                  "account_id": "01923c8b-2222-7000-a000-000000000007",
                  "display_name": "Andi (Diundang)",
                  "email": "andi@laundry.id",
                  "role_id": "01900000-0000-7fff-8fff-000000000012",
                  "role_name": "staff",
                  "mobile_number": "",
                  "is_active": false,
                  "status": "invited",
                  "invitation_id": "01923c8b-4444-7000-a000-000000000004",
                  "expired": false,
                  "outlets": [
                    { "id": "01923c8b-aaaa-7000-a000-0000000000a4", "name": "Cabang Pusat" }
                  ],
                  "created_at": "2026-06-04T11:30:00Z",
                  "updated_at": "2026-06-04T11:30:00Z"
                },
                {
                  "id": "01923c8b-3333-7000-a000-000000000003",
                  "account_id": "01923c8b-2222-7000-a000-000000000002",
                  "display_name": "Rina Wijaya",
                  "email": "rina@laundry.id",
                  "role_id": "01900000-0000-7fff-8fff-000000000012",
                  "role_name": "staff",
                  "mobile_number": "081234567890",
                  "is_active": true,
                  "status": "active",
                  "invitation_id": "",
                  "expired": false,
                  "outlets": [
                    { "id": "01923c8b-aaaa-7000-a000-0000000000a4", "name": "Cabang Pusat" }
                  ],
                  "created_at": "2026-05-28T10:00:00Z",
                  "updated_at": "2026-05-28T10:00:00Z"
                }
              ],
              "pagination": { "page": 1, "limit": 20, "total": 2, "total_pages": 1, "has_next": false, "has_prev": false },
              "message": "success"
            }
        - 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.
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-3333-7000-a000-000000000003",
                "account_id": "01923c8b-2222-7000-a000-000000000002",
                "display_name": "Rina Wijaya",
                "email": "rina@laundry.id",
                "role_id": "01900000-0000-7fff-8fff-000000000012",
                "role_name": "staff",
                "profile_picture_id": null,
                "mobile_number": "081234567890",
                "is_active": true,
                "status": "active",
                "outlets": [
                  { "id": "01923c8b-aaaa-7000-a000-0000000000a4", "name": "Cabang Pusat" }
                ],
                "created_at": "2026-05-28T10:00:00Z",
                "updated_at": "2026-05-28T10:00:00Z"
              },
              "message": "success"
            }
        - 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.

            **Field editable di sini:**
            - `role_id` — ganti role (`staff`/`manager`/`admin`/custom).
            - `mobile_number` — nomor telepon karyawan (denormalized di `tenant_member`).
            - `profile_picture_id` — avatar.
            - `status` — transisi status karyawan, salah satu dari `active` | `on_leave` | `inactive`. **Menggantikan** field `is_active` lama + 3 endpoint sub-action:
              - `status=active` → `is_active=true`, badge UI "Aktif".
              - `status=on_leave` (cuti) → `is_active=true` (masih bisa login), badge UI "Cuti".
              - `status=inactive` (nonaktif) → `is_active=false`, login diblokir.
              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`.
            - `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).

            **Read-only di endpoint ini** (tidak akan berubah meskipun dikirim):
            - `email` — identitas mirror dari `accounts` (event-synced dari account-service); mutasi dilakukan di account-service.
            - `display_name` — saat ini juga read-only dari account-service (rencana extend untuk editable lokal tracked di design doc, belum tersedia).

            **Rules role-outlet:**
            - Role **all-outlet** (admin/manager — capability `outlet:all`):
              - `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.
              - `outlet_ids: []` atau tidak dikirim → `200`, semua `outlet_member` lama dibersihkan (akses implisit ke semua outlet tetap berlaku).
            - Role **staff** (`outlet:all` tidak punya): `outlet_ids` divalidasi kepemilikannya (outlet asing → `400`, assignment lama dibiarkan utuh).
          example_body: |
            # ubah role + tambah outlet
            {
              "role_id": "01900000-0000-7fff-8fff-000000000012",
              "mobile_number": "081299990000",
              "outlet_ids": [
                "01923c8b-aaaa-7000-a000-0000000000a4",
                "01923c8b-aaaa-7000-a000-0000000000a5"
              ]
            }

            # set cuti
            { "status": "on_leave" }

            # nonaktifkan
            { "status": "inactive" }

            # aktifkan kembali (dari cuti atau inactive)
            { "status": "active" }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-3333-7000-a000-000000000003",
                "account_id": "01923c8b-2222-7000-a000-000000000002",
                "display_name": "Rina Wijaya",
                "email": "rina@laundry.id",
                "role_id": "01900000-0000-7fff-8fff-000000000012",
                "role_name": "staff",
                "profile_picture_id": null,
                "mobile_number": "081299990000",
                "is_active": true,
                "status": "active",
                "outlets": [
                  { "id": "01923c8b-aaaa-7000-a000-0000000000a4", "name": "Cabang Pusat" },
                  { "id": "01923c8b-aaaa-7000-a000-0000000000a5", "name": "Cabang Selatan" }
                ],
                "created_at": "2026-05-28T10:00:00Z",
                "updated_at": "2026-05-28T11:00:00Z"
              },
              "message": "member updated successfully"
            }
        - 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.

            Urutannya: **(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.

            Respons 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).

            **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.

            **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.)

            **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.
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-3333-7000-a000-000000000003",
                "account_id": "01923c8b-2222-7000-a000-000000000002",
                "display_name": "Rina Wijaya",
                "email": "rina@laundry.id",
                "role_id": "01900000-0000-7fff-8fff-000000000012",
                "role_name": "staff",
                "is_active": true,
                "status": "active",
                "outlets": [{ "id": "01923c8b-aaaa-7000-a000-0000000000a4", "name": "Cabang Pusat" }],
                "created_at": "2026-06-04T11:30:00Z",
                "updated_at": "2026-06-04T11:30:00Z"
              },
              "message": "invitation accepted"
            }
        - 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.

            **404** kalau invitation tidak ada / sudah accepted/rejected. Body tidak diperlukan.
          example_response: |
            {
              "success": true,
              "data": null,
              "message": "invitation resent"
            }
        - 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.

            **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.

            Setelah reject, owner boleh kirim ulang invite (unique constraint `WHERE status='pending'` saja). **404/409/410** sama dengan accept.
          example_response: |
            {
              "success": true,
              "data": null,
              "message": "invitation rejected"
            }
        - 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`:

            - `return` (default) — temp password dikembalikan **sekali** di response (`temp_password` + `expires_at`); owner share manual ke staff.
            - `email` — account-service kirim email reset langsung ke staff; response tidak punya `temp_password`.

            `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.

            **403** kalau caller bukan admin/manager. **404** kalau member bukan milik tenant.
          example_body: |
            {
              "delivery": "return",
              "force_change_on_next_login": true
            }
          example_response: |
            {
              "success": true,
              "data": {
                "user_id": "01923c8b-2222-7000-a000-000000000002",
                "delivery": "return",
                "temp_password": "kQ7s-mE4t-9pX",
                "expires_at": "2026-06-08T10:00:00Z",
                "must_change_on_next_login": true
              },
              "message": "password reset successfully"
            }
        - name: Remove member
          method: DELETE
          path: /api/v1/tenant/member/{id}
          auth: JWT Bearer
          description: |
            Keluarkan member: hapus dari organisasi (account-service), lalu
            soft-delete `tenant_member` + seluruh `outlet_member`-nya secara
            atomik. `404` bila bukan milik tenant ini.
          example_response: |
            {
              "success": true,
              "data": null,
              "message": "member removed successfully"
            }
    - 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.

        Field `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".
      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`.
          example_response: |
            {
              "success": true,
              "data": {
                "tenant_is_active": true,
                "outlet_is_active": true,
                "service_is_active": true,
                "product_is_active": false,
                "driver_is_active": false,
                "bank_is_active": false,
                "setup_done": true
              },
              "message": "Success"
            }
        - 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.

            Idempoten — kalau sudah `setup_done=true`, set ulang aman.

            **204 No Content** sukses (no response body).
            **400 'tenant and outlet must be active before completing setup'** kalau belum cukup.
            **403** kalau caller tidak punya capability `tenant:write` (owner/manager only).
    - 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:
        - Apply `coupon_code` di [`POST /customer/orders/coupon-preview`](#customer-orders) dan POST order (mechanism=`code`).
        - Klaim dulu lewat [`POST /customer/promo/{id}/claim`](#customer-promo) lalu kirim `customer_coupon_id` saat checkout (mechanism=`claim`).
        - Auto-evaluated saat order (mechanism=`automatic`) — server pilih promo terbaik.

        **Lifecycle status** (`status` field) — 5 nilai dengan state machine:
        ```
        draft → scheduled (kalau valid_from di masa depan saat publish)
              ↓
              active (kalau valid_from ≤ now < valid_end saat publish)
              ↓
              paused (manual via Pause; resume balik ke active/scheduled)
              ↓
              ended (manual via End/Cancel, atau auto saat valid_end lewat — via cron admin)
        ```
        **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.

        **Promo type** (`promo_type`, 5 nilai) — masing-masing punya field nilai sendiri:
        - `percentage` — `discount_percent_value` (0–100), `discount_percent_max` opsional (cap rupiah).
        - `fixed_amount` — `discount_fixed_value` (nominal rupiah).
        - `free_service` — gratiskan service yang ada di `free_service_ids[]` (tidak butuh nilai uang).
        - `shipping_discount` — `shipping_discount_type` (`percentage`/`fixed`) + `shipping_discount_value`; `shipping_max_radius_km` opsional batas jarak antar.
        - `bundle` — `bundle_discount_value`; berlaku saat semua `bundle_service_ids[]` ada di order.

        **Mechanism** (`mechanism`, 3 nilai):
        - `code` — customer ketik `coupon_code` saat checkout (unique per tenant + lower-case via partial unique index `WHERE deleted_at IS NULL`).
        - `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)`).
        - `automatic` — tanpa input customer; server evaluasi semua promo `automatic` eligible saat checkout dan pilih yang memberi diskon terbesar.

        **Scope & requirement**:
        - `outlet_scope_type`: `all_outlets` (semua outlet tenant) atau `selected` (kirim `outlet_ids[]`, di-store M2M `promo_outlet`).
        - `service_requirement_mode`: `all_services` | `selected_services` (`required_service_ids[]`) | `service_combo` (semua service di list wajib ada di order).
        - `customer_criteria`: `all` | `new_user` (dengan `new_user_days` opsional sebagai batas hari sejak register).

        **Kuota & budget**:
        - `maximum_use` — total pemakaian semua customer.
        - `max_use_per_customer` — limit per-customer (audit lewat `coupon_apply.account_id` + `customer_coupon`).
        - `budget_limit` (nullable) — total nominal subsidi maksimum; tiap apply tambah `budget_used`. Kalau `budget_used + new_discount > budget_limit` → tolak.

        **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.

        **`is_global`** — flag promo platform-wide (di-manage admin platform); tenant biasa tidak bisa toggle.
      endpoints:
        - name: List promos
          method: GET
          path: /api/v1/tenant/promo
          auth: JWT Bearer
          description: |
            List promo milik tenant (paginasi). Query opsional:
            - `status` — `draft`/`scheduled`/`active`/`paused`/`ended`
            - `promo_type` — salah satu 5 nilai
            - `mechanism` — `code`/`claim`/`automatic`
            - `q` — search by name / coupon_code (ILIKE)
            - `page`, `limit` (default 1/20)

            Response 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).
        - 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: |
            {
              "success": true,
              "data": {
                "active_count": 3,
                "scheduled_count": 1,
                "ended_count": 8,
                "draft_count": 2,
                "total_subsidy_this_month": 245000
              },
              "message": "success"
            }
        - 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`).

            `outlet_ids[]`, `required_service_ids[]`, `free_service_ids[]`, `bundle_service_ids[]` di-validasi sebagai milik tenant ini.
          example_body: |
            # Percentage code-based, scope selected outlets, service requirement all
            {
              "promo_type": "percentage",
              "mechanism": "code",
              "name": "Welcome 15%",
              "coupon_code": "WELCOME15",
              "description": "Diskon pengguna baru",
              "discount_percent_value": 15,
              "discount_percent_max": 30000,
              "min_order_amount": 50000,
              "maximum_use": 500,
              "max_use_per_customer": 1,
              "budget_limit": 10000000,
              "outlet_scope_type": "selected",
              "outlet_ids": ["01923c8b-...-0004", "01923c8b-...-0005"],
              "customer_criteria": "new_user",
              "new_user_days": 30,
              "valid_from": "2026-07-01T00:00:00Z",
              "valid_end": "2026-12-31T23:59:59Z"
            }

            # Bundle automatic, harus mengandung 2 service tertentu
            {
              "promo_type": "bundle",
              "mechanism": "automatic",
              "name": "Cuci + Setrika hemat 20rb",
              "bundle_discount_value": 20000,
              "bundle_service_ids": ["01923c8b-...-svc-cuci", "01923c8b-...-svc-setrika"],
              "service_requirement_mode": "service_combo",
              "outlet_scope_type": "all_outlets",
              "maximum_use": 0,
              "valid_from": "2026-06-01T00:00:00Z",
              "valid_end": "2026-09-30T23:59:59Z"
            }
        - 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:
            - `id` (opsional) — kalau diisi, update promo existing lalu publish.
            - `publish` (bool) — kalau `false`, sama dengan Create/Update biasa (tetap `draft`).

            Setelah publish, status ke `scheduled` (kalau `valid_from > now`) atau `active`.
        - 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:
            - `allowed_actions[]` — daftar action yang valid di state ini (mis. `["pause", "end"]` untuk `active`).
            - `info_banner` — string warning kontekstual (mis. "Budget hampir habis").
            - `performa` — `{conversion_count, total_discount_amount, reward_value}` agregat untuk dashboard.
        - 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.
        - 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.
        - 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.
        - 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:
            - `service_ids` (comma-separated UUID)
            - `require_all` (`true`/`false`) — `true` = outlet harus punya SEMUA service, `false` = minimal 1.

            Response per-outlet: `has_services[]`, `missing_services[]`, `is_eligible`, `unavailable_reason`.
    - 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.
      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:
            - `scheduled` → `active` kalau `valid_from ≤ now`.
            - `active` → `ended` kalau `valid_end < now`.
            Idempoten. Aman dipanggil tiap menit.
    - 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).

        **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).

        **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).
      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.

            **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`.
          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: |
            {
              "success": true,
              "data": [
                {
                  "id": "01923c8b-1234-7000-a000-0000000000aa",
                  "order_number": "WCC-260528-001",
                  "customer_id": "01923c8b-1234-7000-a000-000000000010",
                  "customer_name": "Siti Nurhaliza",
                  "outlet_id": "01923c8b-1234-7000-a000-000000000004",
                  "total_amount": 77800,
                  "note": "Pisahkan pakaian putih",
                  "status": "price_proposed",
                  "items": [
                    {
                      "id": "01923c8b-1234-7000-a000-0000000000ab",
                      "item_id": "01923c8b-1234-7000-a000-000000000020",
                      "name": "Cuci Reguler",
                      "unit_of_measure": "kg",
                      "estimated_quantity": 3,
                      "quantity": 3.2,
                      "amount": 22400,
                      "is_addon": false,
                      "status": "pending"
                    }
                  ],
                  "created_at": "2026-05-28T08:00:00Z",
                  "updated_at": "2026-05-28T08:35:00Z"
                }
              ],
              "pagination": {
                "page": 1,
                "limit": 20,
                "total": 1,
                "total_pages": 1,
                "has_next": false,
                "has_prev": false
              },
              "message": "success"
            }
        - 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` & `is_addon`.

            `final_total` rendered `null` sebelum owner set final-price (pre `price_proposed`). `quantity` selalu render sebagai angka (termasuk `0`), tidak `null` (fix `c312294`).

            **404** kalau bukan milik tenant. RBAC outlet-scope enforce: staff yang outlet-nya bukan order ini juga dapat 404.
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-1234-7000-a000-0000000000aa",
                "order_number": "WCC-260528-001",
                "customer_id": "01923c8b-1234-7000-a000-000000000010",
                "customer_name": "Siti Nurhaliza",
                "customer_phone": "+62811xxxxxxx",
                "outlet_id": "01923c8b-1234-7000-a000-000000000004",
                "delivery_method": "pickup",
                "total_amount": 77800,
                "estimated_total": 77800,
                "final_total": 81000,
                "discount_amount": 18200,
                "pickup_fee": 5000,
                "tax_amount": 8000,
                "note": "Pisahkan pakaian putih",
                "status": "processing",
                "items": [
                  {
                    "id": "01923c8b-1234-7000-a000-0000000000ab",
                    "item_id": "01923c8b-1234-7000-a000-000000000020",
                    "name": "Cuci Reguler",
                    "unit_of_measure": "kg",
                    "estimated_quantity": 3,
                    "quantity": 3.2,
                    "amount": 22400,
                    "is_addon": false,
                    "status": "processing"
                  },
                  {
                    "id": "01923c8b-1234-7000-a000-0000000000ac",
                    "item_id": "01923c8b-1234-7000-a000-000000000021",
                    "name": "Pewangi Extra",
                    "unit_of_measure": "pcs",
                    "estimated_quantity": 0,
                    "quantity": 1,
                    "amount": 5000,
                    "is_addon": true,
                    "status": "processing"
                  }
                ],
                "created_at": "2026-05-28T08:00:00Z",
                "updated_at": "2026-05-28T09:10:00Z"
              },
              "message": "success"
            }
        - 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).

            400 kalau transisi status tidak valid menurut state machine.
          example_body: |
            {
              "note": "Customer minta jemput sore"
            }
    - 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.
      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`).
        - 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.
        - 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.
          example_body: |
            {
              "quantity": 3.5
            }
        - 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).

            Path **berubah** dari `/final-service-quantity` (lama) → `/set-final-quantity` (sekarang). FE wajib pakai path baru; lama akan 404.

            Body opsional `note` di-append ke `order_list.note` (delimiter `" | "`).
          example_body: |
            {
              "final_quantity": 3.2,
              "note": "Berat aktual sesuai struk"
            }
          example_response: |
            {
              "success": true,
              "data": null,
              "message": "final quantity recorded"
            }
    - id: tenant-rating
      title: Tenant — Outlet & Service Ratings
      description: |
        Owner / staff melihat rating yang masuk ke outlet & service mereka. **Read-only** — owner tidak bisa edit/hapus rating customer (single endpoint mutasi: `/read` untuk mark-as-read state).

        Per-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.

        **Sentiment label** server-derived dari `scale`:
        - `positive` — scale ≥4
        - `neutral` — scale = 3
        - `negative` — scale ≤2

        ## ⚠️ Known gaps (tracked di [PR #59](https://github.com/Ikavia/wacca-service/pull/59))

        - **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 <= 2`. **Mitigasi FE**: 2× request paralel (`?star=1` + `?star=2`) lalu merge, atau hide chip sampai backend siap.
        - **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.
        - **Future "Balas Ulasan" / "Tandai Ditangani"**: Figma siapkan CTA area tapi marked "coming soon". Backend belum ada endpoint reply/handle. Defer.
      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.
          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:
            - `reviewer_name` (dari accounts mirror)
            - `likes[]` (aspect chips capital case)
            - `photos[]` (array media_id)
            - `helpful_count` (aggregate dari rating_helpful)
            - `sentiment` (positive/neutral/negative — derived dari scale)
            - `is_read` (state owner)
            - `order_context: {order_id, order_number, services[], total_weight, total_price}`
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-...-rating",
                "tenant_id": "01923c8b-...-tenant",
                "outlet_id": "01923c8b-...-outlet",
                "customer_id": "01923c8b-...-customer",
                "order_id": "01923c8b-...-order",
                "scale": 5,
                "review": "Pelayanan sangat memuaskan, baju bersih dan wangi!",
                "likes": ["Bersih", "Wangi", "Ramah"],
                "is_read": false,
                "sentiment": "positive",
                "reviewer_name": "Budi Santoso",
                "helpful_count": 12,
                "order_context": {
                  "order_id": "01923c8b-...-order",
                  "order_number": "W2401",
                  "services": [
                    { "item_id": "...", "name": "Cuci Reguler", "weight": 2.2, "amount": 22000 },
                    { "item_id": "...", "name": "Setrika", "weight": 1.0, "amount": 16600 }
                  ],
                  "total_weight": 3.2,
                  "total_price": 38600
                },
                "photos": ["01923c8b-...-photo1"],
                "created_at": "2026-05-20T08:00:00Z"
              },
              "message": "success"
            }
        - 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.
        - name: Outlet ratings summary
          method: GET
          path: /api/v1/tenant/outlet/outlet-ratings/summary
          auth: JWT Bearer
          description: |
            Aggregate untuk dashboard / list header. Field:
            - `average` — avg score 0-5
            - `count` — total rating
            - `unread_count` — yang belum di-mark read owner (badge "Belum dibaca")
            - `distribution[]` — 5 entry (star 5→1), tiap entry `{star, count, percent}` untuk render bar chart distribusi
          query_params:
            - name: outlet_id
              type: string
              description: Optional — scope ke 1 outlet. Kosong = semua outlet tenant.
          example_response: |
            {
              "success": true,
              "data": {
                "average": 4.8,
                "count": 47,
                "unread_count": 3,
                "distribution": [
                  { "star": 5, "count": 35, "percent": 75 },
                  { "star": 4, "count": 7,  "percent": 15 },
                  { "star": 3, "count": 3,  "percent": 6 },
                  { "star": 2, "count": 1,  "percent": 2 },
                  { "star": 1, "count": 1,  "percent": 2 }
                ]
              },
              "message": "success"
            }
        - 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/*`)
        **dan** outlet mana yang boleh disentuh. RBAC bersifat **dinamis per-tenant**:

        - **Role template sistem** (`admin` / `manager` / `staff`) — dibagikan ke
          semua tenant, **tidak bisa diubah/dihapus**. Owner di-bootstrap sebagai
          `admin` saat tenant dibuat.
        - **Role custom** — tiap tenant bisa **membuat role sendiri** + memilih
          capability-nya (mis. `kasir`, `supervisor`), lalu menugaskannya ke member.

        **Capability** adalah unit izin (mis. `catalog:write`). Daftar capability
        bersifat tetap (di-define di kode, tiap key menggate route nyata) — yang
        dinamis adalah **role mana punya capability mana**. Ambil daftar lengkap +
        arti tiap capability dari `GET /api/v1/tenant/capabilities`.

        ## Capability & fitur (untuk frontend)

        Frontend sebaiknya gate menu dari **capability** (atau `features` turunannya),
        bukan dari nama role — supaya tetap sinkron saat role di-custom. Capability:

        | Capability | Cakupan | Template default |
        |---|---|---|
        | `order:fulfill` | proses order (accept/reject/final-price/advance/COD) | staff, manager, admin |
        | `catalog:write` | item, harga, promo, jam-operasi, media | manager, admin |
        | `outlet:manage` | edit outlet, buka/tutup, driver | manager, admin |
        | `outlet:lifecycle` | buat / hapus outlet | admin |
        | `member:read` | lihat daftar karyawan | manager, admin |
        | `member:write` | kelola karyawan (assign/invite/update/aktif-nonaktif/hapus) | admin |
        | `role:manage` | kelola role custom + izinnya | admin |
        | `tenant:write` | edit profil tenant | manager, admin |
        | `tenant:config` | sync | admin |
        | `outlet:all` | akses implisit SEMUA outlet (tanpa penugasan per-outlet) | manager, admin |

        ## Outlet-scope

        - Role dengan `outlet:all` → akses implisit ke **semua** outlet.
        - Role tanpa `outlet:all` (mis. `staff`) → hanya boleh **mengoperasikan
          order** untuk outlet yang
          ditugaskan ke dirinya; aksi pada order outlet lain → `403`.
        - **Baca tetap tenant-wide** untuk semua role.

        ## Penegakan & response

        - Aksi tanpa capability → `403` `insufficient role`.
        - Aksi pada outlet di luar assignment → `403` `no access to this outlet`.
        - Token platform-admin (`*`) **melewati semua gate**.
        - Route baca + bootstrap (`POST /api/v1/tenant/profile`) tidak di-gate.

        ## Rollout (audit-mode)

        Dikontrol env **`RBAC_ENFORCE`** (default `false`): audit-mode hanya **log**
        keputusan deny (`[rbac] AUDIT would-deny …`) tanpa memblokir; `true` =
        enforce (`403`). Aktifkan setelah verifikasi tiap tenant punya member admin
        yang resolvable.
      endpoints:
        - name: My access
          method: GET
          path: /api/v1/tenant/me
          auth: JWT Bearer
          description: |
            Ringkasan akses pemanggil — role, capability, `features` (key menu),
            status all-outlet, dan outlet yang ditugaskan. Dipakai frontend untuk
            menyembunyikan menu. Berlaku juga untuk non-member (`is_member=false`,
            capability kosong).
          example_response: |
            {
              "success": true,
              "data": {
                "account_id": "01923c8b-1111-7000-a000-000000000001",
                "is_member": true,
                "is_active": true,
                "role": "kasir",
                "is_all_outlets": false,
                "capabilities": ["order:fulfill"],
                "features": ["orders"],
                "outlet_ids": ["01923c8b-aaaa-7000-a000-0000000000a4"]
              },
              "message": "success"
            }
        - name: Capability catalog
          method: GET
          path: /api/v1/tenant/capabilities
          auth: JWT Bearer
          description: |
            Katalog statis semua capability: `key`, `label`, `description`, dan
            `features` (key menu). Dipakai frontend untuk menafsirkan capability
            sebuah role + membangun editor role.
          example_response: |
            {
              "success": true,
              "data": [
                { "key": "catalog:write", "label": "Kelola katalog & harga", "description": "Buat/ubah/hapus item, harga, promo, jam operasi, dan media outlet.", "features": ["pricing","items","promo","operations","media"] },
                { "key": "order:fulfill", "label": "Proses order", "description": "Terima/tolak order, harga final, advance, COD.", "features": ["orders"] }
              ],
              "message": "success"
            }
        - name: List roles
          method: GET
          path: /api/v1/tenant/roles
          auth: JWT Bearer
          description: |
            Role template sistem + role custom milik tenant, masing-masing dengan
            `capabilities` + `is_system`. `id`-nya dipakai sebagai `role_id` saat
            assign/register/invite member.
          example_response: |
            {
              "success": true,
              "data": [
                { "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" },
                { "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" }
              ],
              "message": "success"
            }
        - 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`
            tidak boleh sama dengan role sistem; tiap capability harus ada di
            katalog (`400` kalau tidak dikenal).
          example_body: |
            {
              "name": "kasir",
              "capabilities": ["order:fulfill"]
            }
          example_response: |
            {
              "success": true,
              "data": { "id": "01923c8b-7777-7000-a000-000000000077", "name": "kasir", "is_system": false, "tenant_id": "01923c8b-...", "capabilities": ["order:fulfill"], "created_at": "...", "updated_at": "..." },
              "message": "role created"
            }
        - 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.
          example_response: |
            {
              "success": true,
              "data": {
                "id": "01923c8b-7777-7000-a000-000000000077",
                "name": "kasir",
                "is_system": false,
                "tenant_id": "01923c8b-1234-7000-a000-000000000001",
                "capabilities": ["order:fulfill", "catalog:write"],
                "created_at": "2026-05-29T10:00:00Z",
                "updated_at": "2026-05-30T08:00:00Z"
              },
              "message": "success"
            }
        - 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
            = ganti seluruhnya). Role sistem → `403`. Butuh `role:manage`.
            **Lockout-guard:** menurunkan `role:manage` ditolak `409` bila membuat
            tenant tak punya member aktif yang bisa kelola role.
          example_body: |
            {
              "capabilities": ["order:fulfill", "catalog:write"]
            }
          example_response: |
            {
              "success": true,
              "data": { "id": "01923c8b-7777-7000-a000-000000000077", "name": "kasir", "is_system": false, "capabilities": ["order:fulfill","catalog:write"], "created_at": "...", "updated_at": "..." },
              "message": "role updated"
            }
        - 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
            member → `409` (reassign dulu). Butuh `role:manage`.
          example_response: |
            {
              "success": true,
              "data": null,
              "message": "role deleted"
            }
screens:
    - id: chat-list
      icon: "\U0001F4AC"
      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.
      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).
      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: "\U0001F4CD"
      title: Daftar Alamat
      description: |
        Daftar alamat customer dengan badge DEFAULT pada alamat utama. Urut `is_default DESC, created_at DESC` — default selalu di atas.

        Figma: [1477:130](https://www.figma.com/design/N2kzy9KHqHVFp3tg58uIqt/Wacca-Laundry?node-id=1477-130).
      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.

        Alamat **pertama** otomatis di-default (tanpa perlu set `is_default=true`).

        Figma: [1477:199](https://www.figma.com/design/N2kzy9KHqHVFp3tg58uIqt/Wacca-Laundry?node-id=1477-199).
      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).

        Figma: [1477:199](https://www.figma.com/design/N2kzy9KHqHVFp3tg58uIqt/Wacca-Laundry?node-id=1477-199) (sama dengan Form Tambah, pre-filled).
      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: "\U0001F5D1️"
      title: Konfirmasi Hapus
      description: |
        Bottom-sheet konfirmasi hapus alamat. **Soft delete** — data tetap tersimpan; pesanan aktif yang refer ke alamat ini tidak terdampak.

        Figma: [1477:249](https://www.figma.com/design/N2kzy9KHqHVFp3tg58uIqt/Wacca-Laundry?node-id=1477-249).
      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.
      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: "\U0001F39F️"
      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.
      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: "\U0001F3F7️"
      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".
      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).
    - id: customer-promo-detail
      icon: "\U0001F4DC"
      title: Customer — Detail Voucher
      description: |
        Halaman detail satu voucher yang sudah diklaim (dari "Promo Saya"). Tampilkan T&C, kuota tersisa, scope (outlet/service), dan tombol "Pakai Sekarang" yang deeplink ke list outlet eligible.
      platform:
        - Mobile
      calls:
        - method: GET
          path: /api/v1/customer/promo/{id}
          purpose: Detail customer_coupon + T&C 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.

        Banner reward "Berikan ulasan, dapatkan poin!" tampil di atas list — saat ini static (backend points wallet belum ada, lihat known gap).
      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: "\U0001F4CB"
      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.
      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&limit=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.
      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).
    - id: customer-rating-helpful
      icon: "\U0001F44D"
      title: Customer — Vote Helpful di Review Orang Lain
      description: "Saat customer baca review orang di outlet detail / profile customer lain, bisa tap \U0001F44D 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 \U0001F44D (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 \U0001F44D (icon sudah berisi)"
          auth: required
          notes: 'Idempoten. Response: {rating_id, helpful_count, voted: false}.'
    - id: owner-notification-bootstrap
      icon: "\U0001F514"
      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.
      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.
        - 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: "\U0001F4EC"
      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.

        **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).

        **Render aturan** (per record):
        - `payload.actions[]` ada → tampilkan tombol (`approve_price`/`reject_price`, `member_accept`/`member_reject`)
        - `payload.actions[]` kosong + `payload.metadata.state` ada → render badge settled (sudah disetujui/ditolak/diterima)
        - `channels[].fcm.status` → diagnose kenapa push gak nyampe (lihat troubleshoot table di atas)
      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).
        - method: GET
          path: https://notification.ikavia.com/api/v1/notifications?limit=20&offset=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.
        - 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.
      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`.
        - 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"`.
        - 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`).
      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.
    - id: owner-orders-inbox
      icon: "\U0001F4E5"
      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.
      platform:
        - Mobile
        - Web
      calls:
        - method: GET
          path: /api/v1/tenant/orders?status=pending&page=1&limit=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: "\U0001F6F5"
      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).
      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 & 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.

        **Verify gate berbeda berdasarkan delivery_method**:
        - `pickup` → cukup `pickup-arrived`; verify-complete TIDAK perlu.
        - `self_dropoff` → wajib panggil verify-complete sebelum final-price.
      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: "\U0001F501"
      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.
      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'}.
            Catatan: target=completed dari delivering DIBLOKIR (customer yang confirm-receipt). Dari ready_pickup → completed boleh (serah-terima outlet).
        - 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: "\U0001F6D2"
      title: Customer — Checkout / Buat Order
      description: |
        Customer pilih items, optional preview kupon, lalu commit POST order.
      platform:
        - Mobile
      calls:
        - method: GET
          path: /api/v1/payment-methods
          purpose: Load dropdown metode pembayaran (filter is_active=true & is_offline=false untuk online)
          trigger: On mount checkout
          auth: required
        - method: GET
          path: /api/v1/customer/orders/pickup-quote?outlet_id={id}&pickup_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: "\U0001F4CD"
      title: Customer — Tracking Order
      description: |
        Customer lihat progress order — status header + timeline visual + pickup-status (kalau pickup). Listen push notif untuk auto-refresh.
      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: "\U0001F4B0"
      title: Customer — Setujui / Tolak Harga Final
      description: |
        Notif `order_price_proposed` masuk → customer buka detail → setuju / tolak. Tolak harga = batal order dengan reject_reason.
      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: "\U0001F4E6"
      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.

        Untuk `ready_pickup` (customer ambil di outlet), owner yang advance ke completed — FE customer tidak perlu hit endpoint ini.
      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: "\U0001F6AB"
      title: Customer — Batal Order
      description: |
        Cancel order milik customer sebelum dikerjakan (`pending` atau `price_proposed`). Setelah `processing`, harus lewat alur komplain (lihat [Complaint](#complaint)).
      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: "\U0001F3EA"
      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.

        **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.
      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: "\U0001F4B3"
      title: Customer — Dropdown Pilih Pembayaran (Checkout)
      description: |
        Dropdown di halaman checkout customer untuk pilih metode bayar. Filter:
        - `is_active=true` (selalu)
        - `is_offline=false` (online via payment-service), kecuali kalau outlet/customer support COD → tampilkan juga `cod`.

        Grouping by `code` (mis. semua QRIS aggregator jadi 1 item "QRIS"); detail provider muncul di subtitle.

        Customer pilih → FE simpan `id` (UUID) → kirim sebagai `payment_method_id` saat POST /customer/orders.
      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: "\U0001F9FE"
      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).
      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: "\U0001F3EA"
      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.
      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).
            payment_method_id wajib UUID metode is_offline=true; metode online ditolak 400.
    - id: owner-walkin-link-account
      icon: "\U0001F517"
      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.

        Guard: 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`).
      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.
    - id: tenant-promo-list
      icon: "\U0001F3AF"
      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".
      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&page=1&limit=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`).

        **Pola "Save Draft" vs "Save & Publish"** — FE punya 2 tombol di akhir form:
        - "Simpan Draft" → `POST /tenant/promo` (status tetap `draft`)
        - "Simpan & Publish" → `POST /tenant/promo/save-and-publish` dengan `publish=true` (langsung ke `scheduled`/`active`).

        Saat edit (`id` ada), pakai `save-and-publish` dengan `id` di body untuk update+publish atomic, atau `PUT /tenant/promo/{id}` untuk update saja.
      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}&require_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 & Publish'
          auth: required
          notes: 'Body include `publish: true` + `id` (opsional, untuk update existing).'
    - id: tenant-promo-detail
      icon: "\U0001F4CB"
      title: Owner — Detail Promo + Action Buttons
      description: |
        Halaman detail satu promo. FE render tombol action dari `allowed_actions[]`. State machine:
        - `draft` → ["publish", "delete"]
        - `scheduled` → ["pause", "cancel", "publish-now"]
        - `active` → ["pause", "end"]
        - `paused` → ["resume", "cancel"]
        - `ended` → ["duplicate", "delete"]

        `info_banner` tampilkan warning kontekstual (mis. "Budget hampir habis").
      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 & 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).

        Render card per review dengan avatar 2-huruf inisial (FE derive dari nama), tanggal+order#, 5-star, comment, badge "Belum dibaca" kalau `is_read=false`.
      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}&star={n}&page=1&limit=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).
            Card per row: tap → navigate ke detail screen.
    - id: tenant-rating-detail
      icon: "\U0001F50D"
      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=\U0001F60A hijau / neutral=\U0001F610 kuning / negative=\U0001F61E 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.
        - 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: "\U0001F310 wacca-web-admin"
      type: client
      color: '#0ea5e9'
    - id: wacca
      label: "\U0001F9FA wacca-service"
      type: service
      color: '#4f46e5'
    - id: account
      label: "\U0001F510 account-service"
      type: service
      color: '#360185'
    - id: pg
      label: "\U0001F418 PostgreSQL"
      type: data
      color: '#10b981'
    - id: jwt
      label: "\U0001F511 JWT (RS256)"
      type: data
      color: '#f59e0b'
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: "\U0001F9FA"
    primary_color: '#4f46e5'
