๐Ÿ“‹ Overview

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

๐Ÿงบ Domain model

Tenant โ†’ Outlet โ†’ Items โ†’ Prices

๐Ÿงพ Response envelope

snake_case JSON + UUIDv7 + request_id

๐Ÿ” Permission model

JWT permission claim + per-resource gate

๐Ÿข Multi-tenancy

Org boundary diturunkan dari JWT

๐Ÿšฆ Outlet status

Dua flag terpisah โ€” is_active vs is_opening

๐Ÿ—‚ Items vs Prices

Kenapa tiga endpoint terpisah untuk satu tabel

Base URL

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

๐Ÿงบ Domain model

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

๐Ÿงพ Response envelope

Success:

{ "success": true, "data": { ... }, "message": "...", "request_id": "uuid" }

Success (paginated list):

{
  "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):

{ "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.

๐Ÿ” Permission model

Tiap resource family digate via RequirePermission middleware:

Resource familyPermission required
/api/v1/tenant + semua child (outlet, outlet-*, *-prices)wacca-tenant:*
/api/v1/customerwacca-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.

๐Ÿข Multi-tenancy

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.

๐Ÿšฆ Outlet status

FieldArtiCara set
is_activeOutlet ada (belum di-archive)Saat DELETE โ†’ soft-delete. Tidak di-toggle manual.
is_openingSedang buka secara real-timePATCH /tenant/outlet/{id}/open-outlet โ†” /close-outlet
driver_availableDriver 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).

๐Ÿ—‚ Items vs Prices

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

๐Ÿ”„ JWT Bearer Authentication Flow

Step-by-step authentication menggunakan JWT Bearer

1
Login โ–พ

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.

2
Pastikan org aktif sesuai konteks โ–พ

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.

3
Bootstrap tenant (sekali per organisasi) โ–พ

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.

4
Refresh saat 401 โ–พ

Access token TTL ~15 menit. Saat 401, panggil POST /auth/refresh di account-service untuk dapat token baru (atau pakai cookie .ikavia.com).

๐Ÿ”„ Service Flow Diagram

Visualisasi alur data antara services

GET /api/v1/chats

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 Parameters

ParamTypeRequiredDescription
outlet_id string optional 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"
}
GET /api/v1/chats/{id}

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.

POST /api/v1/chats

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 Request 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"
}
POST /api/v1/customer/complaints

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 Request Body
{
  "order_id": "01923c8b-1234-7000-a000-0000000000aa",
  "category": "result_quality",
  "description": "Setelah dicuci masih ada bau apek di kemeja putih"
}
GET /api/v1/customer/complaints

Komplain milik customer (paginasi page/limit, filter status).

GET /api/v1/customer/complaints/{id}

Detail komplain + thread balasan. 404 kalau bukan milik customer.

POST /api/v1/customer/complaints/{id}/photos

Lampirkan foto bukti pada komplain (mis. hasil cuci).

POST /api/v1/customer/complaints/{id}/escalate

Customer eskalasi ke admin platform โ€” biasanya setelah owner reject. Status โ†’ escalated. 409 kalau state tidak izinkan transisi.

POST /api/v1/customer/complaints/{id}/confirm

Customer setujui resolusi owner (resolved โ†’ confirmed). Komplain ditutup.

POST /api/v1/customer/complaints/{id}/reopen

Buka kembali komplain yang auto_closed (R-CHAT.07 option A). Hanya satu kali. Status โ†’ in_discussion. 409 kalau status bukan auto_closed.

GET /api/v1/tenant/complaints

Komplain di tenant saat ini (paginasi). Filter status (salah satu dari status valid), outlet_id. Status filter tidak dikenal โ†’ 400.

GET /api/v1/tenant/complaints/{id}

Detail komplain + thread + foto + order context.

POST /api/v1/tenant/complaints/{id}/acknowledge

Owner accept untuk handle (open โ†’ in_discussion).

POST /api/v1/tenant/complaints/{id}/discuss

Owner balas โ€” pesan masuk ke thread. Set status โ†’ awaiting_customer (mulai countdown 24h auto-close).

Example Request Body
{
  "message": "Mohon maaf, kami akan jemput ulang gratis besok pagi"
}
POST /api/v1/tenant/complaints/{id}/resolve

Owner tandai komplain selesai (status โ†’ resolved). Customer perlu confirm untuk close final.

Example Request Body
{
  "resolution_type": "rewash",
  "note": "Sudah di-cuci ulang, customer terima jemput hari ini"
}
POST /api/v1/tenant/complaints/{id}/reject

Owner tolak komplain (rejected). Customer masih boleh escalate (R-CHAT.06).

Example Request Body
{
  "note": "Berdasarkan foto, kondisi sudah sesuai standar"
}
POST /api/v1/tenant/complaints/{id}/escalate

Owner escalate ke admin platform (mis. butuh dispute resolution dari pihak ketiga). Status โ†’ escalated.

POST /api/v1/admin/complaints/auto-close

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.

GET /api/v1/customer/address

Paginasi alamat milik customer (page/limit, default 1/20). Diurutkan is_default DESC, created_at DESC โ€” default selalu pertama.

Query Parameters

ParamTypeRequiredDescription
page integer optional Halaman 1-based (default: 1)
limit integer optional Jumlah per halaman (default: 20)
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"
}
POST /api/v1/customer/address

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 Request 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"
}
GET /api/v1/customer/address/{id}

Detail satu alamat. 403 kalau alamat bukan milik customer pemanggil.

PUT /api/v1/customer/address/{id}

Partial update โ€” kirim hanya field yang berubah. 403 kalau bukan milik customer. 400 'no fields to update' kalau body kosong.

Example Request Body
{
  "category": "Rumah Baru",
  "mobile_phone": "+62813xxxxxxx"
}
DELETE /api/v1/customer/address/{id}

Soft delete. 403 kalau bukan milik customer.

PATCH /api/v1/customer/address/{id}/set-default

Jadikan alamat ini default (sekaligus un-set default lain). Transactional. 403 kalau bukan milik customer.

GET /api/v1/customer/outlets

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 }
}
GET /api/v1/customer/outlets/{id}

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"
}
GET /api/v1/customer/outlets/{id}/promo

Pindah dokumentasi ke Customer โ€” Promo & Klaim Voucher. Path lama /promos (plural) sudah 404 โ€” pakai singular /promo. Response shape diperluas dengan promo_type, mechanism, claimed, dst.

GET /api/v1/customer/favorites

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"
}
POST /api/v1/customer/favorites

Favoritkan outlet. Idempoten. 404 bila outlet tidak ada.

Example Request Body
{ "outlet_id": "01923c8b-...-0004" }
Example Response
{ "success": true, "message": "outlet favorited" }
DELETE /api/v1/customer/favorites/{outlet_id}

Hapus favorit (soft delete). 404 bila belum difavoritkan.

Example Response
{ "success": true, "message": "outlet unfavorited" }
GET /api/v1/customer/item-services

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 Parameters

ParamTypeRequiredDescription
page int optional (default: 1)
limit int optional (default: 20)
outlet_id string optional Scope ke 1 outlet
search string optional Search nama item
GET /api/v1/customer/item-services/{id}

Detail satu service + harga + outlet info.

GET /api/v1/customer/item-products

Barang dagang (deterjen, pewangi, dll) cross-outlet.

GET /api/v1/customer/item-products/{id}

Detail satu product.

GET /api/v1/customer/item-drivers

Resource antar-jemput (kurir) yang ditawarkan outlet.

GET /api/v1/customer/item-drivers/{id}

Detail satu driver resource.

POST /api/v1/customer/orders

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). 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. 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 Request 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"
}
GET /api/v1/customer/orders

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 }
}
GET /api/v1/customer/orders/pickup-quote

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 Parameters

ParamTypeRequiredDescription
outlet_id string required UUID outlet asal jemput
pickup_address_id string optional 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"
}
POST /api/v1/customer/orders/coupon-preview

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 Request 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"
}
GET /api/v1/customer/orders/{id}/timeline

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"
}
POST /api/v1/customer/orders/{id}/cancel

Customer batalkan order miliknya. Hanya valid pada state tertentu (mis. pending/price_proposed). 409 kalau order sudah masuk fase processing/completed.

POST /api/v1/customer/orders/{id}/confirm-receipt

Customer konfirmasi sudah menerima pesanan (close lifecycle). Beda dengan approve-price (lihat Order Flow โ€” Customer).

GET /api/v1/customer/orders/{id}

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

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"
}
GET /api/v1/customer/outlets/{id}/promo

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"
}
GET /api/v1/customer/promo

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"
}
GET /api/v1/customer/promo/{id}

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.

POST /api/v1/customer/promo/{id}/claim

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"
}
POST /api/v1/customer/outlet-ratings

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 Request 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.
GET /api/v1/customer/outlet-ratings

Outlet rating yang dibuat customer (paginasi page/limit).

Query Parameters

ParamTypeRequiredDescription
page integer optional (default: 1)
limit integer optional (default: 20)
GET /api/v1/customer/outlet-ratings/{id}

Detail satu rating. 404 kalau bukan milik customer.

POST /api/v1/customer/service-ratings

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 Request 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"
}
GET /api/v1/customer/service-ratings

Service rating yang dibuat customer (paginasi page/limit).

GET /api/v1/customer/service-ratings/{id}

Detail satu service rating. 404 kalau bukan milik customer.

GET /api/v1/customer/outlet-ratings/pending

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"
}
GET /api/v1/customer/outlet-ratings/summary

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"
}
POST /api/v1/customer/outlet-ratings/{id}/helpful

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.

DELETE /api/v1/customer/outlet-ratings/{id}/helpful

Tarik kembali vote "merasa terbantu". Idempoten โ€” kalau belum pernah vote, no-op. Counter helpful_count berkurang.

GET /api/v1/finance/banks

Default: active-only (untuk dropdown tenant). Pass ?include_inactive=true untuk admin lihat seluruh non-soft-deleted (active + inactive) โ€” biasanya untuk reaktivasi.

Query Parameters

ParamTypeRequiredDescription
include_inactive bool optional 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"
}
GET /api/v1/finance/banks/{id}

Detail satu master bank. Admin-gated (*). 404 bila id tidak ada atau sudah soft-deleted.

POST /api/v1/finance/banks

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 Request 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"
}
PUT /api/v1/finance/banks/{id}

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 Request Body
# deactivate
{ "is_active": false }

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

# rekey
{ "code": "bsi-baru" }
DELETE /api/v1/finance/banks/{id}

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.

GET /api/v1/finance/bank-accounts

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 Parameters

ParamTypeRequiredDescription
page int optional Halaman 1-based (default: 1)
limit int optional Jumlah per halaman (default: 20)
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"
}
GET /api/v1/finance/bank-accounts/{id}

Detail satu rekening + bank info ter-enrich. 404 kalau bukan milik tenant ini (cek ownership di UC).

POST /api/v1/finance/bank-accounts

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 Request 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"
}
PUT /api/v1/finance/bank-accounts/{id}

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 Request 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"
}
DELETE /api/v1/finance/bank-accounts/{id}

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

POST /api/v1/finance/bank-accounts/{id}/select

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

GET /api/v1/tenant/outlet/item-services

List service items milik tenant.

Query Parameters

ParamTypeRequiredDescription
page int optional Default 1.
limit int optional Default 20.
outlet_id string optional Filter by outlet ID.
tags string optional Substring match terhadap tags.
is_active boolean optional
POST /api/v1/tenant/outlet/item-services

Buat layanan baru. type auto-set "service".

Example Request 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"
}
GET /api/v1/tenant/outlet/item-services/{service-id}

Detail item layanan.

PUT /api/v1/tenant/outlet/item-services/{service-id}

Partial update.

Example Request Body
{
  "description": "Reguler 1 hari",
  "unit_of_measure": "kg"
}
DELETE /api/v1/tenant/outlet/item-services/{service-id}

Soft delete. Harga terkait (di tabel price) tidak ikut ter-cascade โ€” bersihkan manual via DELETE /service-prices/{id}.

GET /api/v1/tenant/outlet/item-products

List product items milik tenant.

Query Parameters

ParamTypeRequiredDescription
page int optional
limit int optional
outlet_id string optional
tags string optional
is_active boolean optional
POST /api/v1/tenant/outlet/item-products

Buat produk baru. type auto-set "product".

Example Request Body
{
  "outlet_id": "01923c8b-1234-7000-a000-000000000100",
  "name": "Pewangi Lavender 500ml",
  "description": "Botol semprot",
  "unit_of_measure": "botol",
  "tags": "fragrance,laundry-add-on"
}
GET /api/v1/tenant/outlet/item-products/{product-id}

Detail item produk.

PUT /api/v1/tenant/outlet/item-products/{product-id}

Partial update.

DELETE /api/v1/tenant/outlet/item-products/{product-id}

Soft delete.

GET /api/v1/tenant/outlet/item-drivers

List driver items milik tenant.

Query Parameters

ParamTypeRequiredDescription
page int optional
limit int optional
outlet_id string optional
tags string optional
is_active boolean optional
POST /api/v1/tenant/outlet/item-drivers

Buat driver baru. type auto-set "driver".

Example Request Body
{
  "outlet_id": "01923c8b-1234-7000-a000-000000000100",
  "name": "Driver Motor 1",
  "description": "Motor matic, jangkauan 5km",
  "unit_of_measure": "trip",
  "tags": "motor,city"
}
GET /api/v1/tenant/outlet/item-drivers/{driver-id}

Detail item driver.

PUT /api/v1/tenant/outlet/item-drivers/{driver-id}

Partial update.

DELETE /api/v1/tenant/outlet/item-drivers/{driver-id}

Soft delete.

POST /api/v1/tenant/device-tokens

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 Request Body
{
  "token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr...",
  "platform": "android"
}
Example Response
{
  "success": true,
  "data": null,
  "message": "device token registered"
}
DELETE /api/v1/tenant/device-tokens

Hapus FCM token (mis. saat logout). Body berisi token yang mau dihapus. Idempoten โ€” token tidak ada โ†’ tetap 200/204.

Example Request Body
{
  "token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr..."
}
POST /api/v1/customer/device-tokens

Sama persis dengan tenant register; lihat di atas.

Example Request Body
{
  "token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr...",
  "platform": "ios"
}
DELETE /api/v1/customer/device-tokens

Sama persis dengan tenant unregister.

Example Request Body
{
  "token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr..."
}
POST /api/v1/tenant/orders/{id}/accept

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"
}
POST /api/v1/tenant/orders/{id}/reject

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 Request 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"
}
POST /api/v1/tenant/orders/{id}/final-price

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 Request 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"
}
POST /api/v1/tenant/orders/{id}/advance

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 Request Body
{
  "target": "ready_pickup"
}
Example Response
{
  "success": true,
  "data": { "id": "...", "status": "ready_pickup", "final_total": 96570, "tax_amount": 9570, "updated_at": "..." },
  "message": "success"
}
POST /api/v1/tenant/orders/{id}/pickup-dispatch

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"
}
POST /api/v1/tenant/orders/{id}/pickup-arrived

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

POST /api/v1/tenant/orders/{id}/verify-complete

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) TIDAK divalidasi di sini โ€” owner punya kontrol terpisah lewat verification-photos endpoint. Yang dicek hanya order_list.verified_at per baris.

POST /api/v1/tenant/orders/{id}/verification

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 Request 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"
}
POST /api/v1/tenant/orders/{id}/confirm-cod

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 Request Body
{
  "received_amount": 100000
}
Example Response
{
  "success": true,
  "data": { "id": "...", "status": "completed", "final_total": 96570, "tax_amount": 9570, "updated_at": "..." },
  "message": "success"
}
POST /api/v1/customer/orders/{id}/approve-price

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"
}
POST /api/v1/customer/orders/{id}/reject-price

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 Request Body
{
  "reason": "Harga terlalu tinggi dari estimasi"
}
Example Response
{
  "success": true,
  "data": { "id": "...", "status": "cancelled", "reject_reason": "Harga terlalu tinggi ...", "updated_at": "..." },
  "message": "success"
}
POST /api/v1/tenant/orders/{id}/verification-photos

Owner/kasir lampirkan foto bukti (1+ media_id) ke order. 404 kalau order bukan milik tenant.

Example Request Body
{
  "media_ids": [
    "01923c8b-1234-7000-a000-0000000000m1",
    "01923c8b-1234-7000-a000-0000000000m2"
  ],
  "stage": "pickup",
  "note": "Pakaian diterima โ€” 1 noda di kerah kemeja"
}
GET /api/v1/tenant/orders/{id}/verification-photos

Foto verifikasi pada order tenant (urutan kronologis).

GET /api/v1/customer/orders/{id}/verification-photos

Customer lihat foto verifikasi order miliknya. 404 kalau bukan milik customer.

GET /api/v1/tenant/outlet/outlet-images

List semua media milik tenant. Filter via query param.

Query Parameters

ParamTypeRequiredDescription
page int optional Default 1.
limit int optional Default 20.
outlet_id string optional 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 }
}
POST /api/v1/tenant/outlet/outlet-images

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 Request Body
{
  "reference_id": "01923c8b-1234-7000-a000-000000000100",
  "reference_type": "outlet",
  "media_id": "media_8f3e9a2b"
}
GET /api/v1/tenant/outlet/outlet-images/{media-id}

Detail single media binding.

PUT /api/v1/tenant/outlet/outlet-images/{media-id}

Swap media_id (rebind binding existing ke file media-service yang berbeda). Tidak menghapus file lama di media-service (hanya update reference).

Example Request Body
{
  "media_id": "media_new_id"
}
DELETE /api/v1/tenant/outlet/outlet-images/{media-id}

Soft delete binding (tidak menyentuh file di media-service). Caller bertanggung jawab cleanup file di media-service sendiri kalau memang ingin hapus.

GET /api/v1/tenant/outlet/outlet-operations

List semua jam operasional milik tenant. Filter via query.

Query Parameters

ParamTypeRequiredDescription
page int optional Default 1.
limit int optional Default 20.
outlet_id string optional Filter by outlet ID.
is_active boolean optional Filter by is_active.
POST /api/v1/tenant/outlet/outlet-operations

Tambah jam operasional. reference_type auto-set ke "outlet".

Example Request 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"
}
GET /api/v1/tenant/outlet/outlet-operations/{operation-id}

Detail satu jam operasional.

PUT /api/v1/tenant/outlet/outlet-operations/{operation-id}

Partial update. Kosongkan field yang tidak berubah.

Example Request Body
{
  "closing_hours": "23:00"
}
DELETE /api/v1/tenant/outlet/outlet-operations/{operation-id}

Soft delete jam operasional.

GET /api/v1/tenant/outlet/{id}/outlet-operation-setups

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"
}
PUT /api/v1/tenant/outlet/{id}/outlet-operation-setups

โš  Legacy โ€” pakai endpoint baru PUT /tenant/outlet/{id}/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.

GET /api/v1/tenant/outlet/{id}/operating-hours

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"
}
PUT /api/v1/tenant/outlet/{id}/operating-hours

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 Request 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 }
  ]
}
GET /api/v1/tenant/outlet/{id}/operational-status

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"
}
PUT /api/v1/tenant/outlet/{id}/operational-status

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 Request Body
{
  "status": "temporarily_closed",
  "reason": "Banjir, akses terhambat"
}
GET /api/v1/tenant/outlet

List outlet milik tenant saat ini (paginated).

Query Parameters

ParamTypeRequiredDescription
page int optional Default 1.
limit int optional Default 20.
outlet_id string optional Filter ke outlet tertentu (tetap tenant-scoped โ€” outlet milik tenant lain tidak akan muncul).
is_active boolean optional 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"
}
POST /api/v1/tenant/outlet

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

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 Request 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"
}
GET /api/v1/tenant/outlet/{outlet-id}

Detail outlet by id. 404 kalau bukan milik tenant ini.

PUT /api/v1/tenant/outlet/{outlet-id}

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 Request Body
{
  "description": "Cabang flagship โ€” buka 24 jam akhir pekan",
  "driver_available": false
}
DELETE /api/v1/tenant/outlet/{outlet-id}

Soft delete (deleted_at terisi). Outlet hilang dari list. Tidak ada endpoint restore via REST โ€” kontak admin / SQL kalau perlu un-archive.

PATCH /api/v1/tenant/outlet/{outlet-id}/open-outlet

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.

PATCH /api/v1/tenant/outlet/{outlet-id}/close-outlet

Set is_opening = false. Idempotent.

Gunakan saat kasir akhir shift / outlet tutup pintu. Untuk archive permanen pakai DELETE.

PATCH /api/v1/tenant/outlet/{outlet-id}/open-drivers

Set driver_available = true. Hanya berhasil kalau outlet punya minimal satu driver aktif. Idempotent.

PATCH /api/v1/tenant/outlet/{outlet-id}/close-drivers

Set driver_available = false. Idempotent.

GET /api/v1/tenant/outlet/{outlet-id}/summary

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"
}
GET /api/v1/payment-methods

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"
}
POST /api/v1/payment-methods

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 Request Body
{
  "name": "OVO",
  "provider": "Midtrans",
  "channel": "ovo"
}
PUT /api/v1/payment-methods/{id}

Partial update. Field editable: name, description, is_active. Code/provider/channel tidak editable (ubah identity โ†’ buat baru).

Example Request Body
{ "is_active": false }
POST /api/v1/payments/webhook

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 Request 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"
}
POST /api/v1/admin/orders/auto-advance

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 Request 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"
}
GET /api/v1/tenant/outlet/service-prices

List harga layanan milik tenant. Filter via query.

Query Parameters

ParamTypeRequiredDescription
page int optional
limit int optional
outlet_service_id string optional Filter by item id (service).
tags string optional
is_active boolean optional
POST /api/v1/tenant/outlet/service-prices

Tambah harga untuk satu service-item. outlet_service_id harus refer ke item dengan type="service". Use case resolve outlet otomatis dari item.

Example Request 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"
}
GET /api/v1/tenant/outlet/service-prices/{price-id}

Detail satu harga service.

PUT /api/v1/tenant/outlet/service-prices/{price-id}

Partial update.

Example Request Body
{
  "rate": 9000,
  "description": "1 hari"
}
DELETE /api/v1/tenant/outlet/service-prices/{price-id}

Soft delete harga.

GET /api/v1/tenant/outlet/product-prices

List harga produk milik tenant.

Query Parameters

ParamTypeRequiredDescription
page int optional
limit int optional
outlet_product_id string optional
tags string optional
is_active boolean optional
POST /api/v1/tenant/outlet/product-prices

Tambah harga product. outlet_product_id harus refer ke item type="product".

Example Request Body
{
  "outlet_product_id": "01923c8b-1234-7000-a000-000000000410",
  "name": "Pewangi Lavender 500ml",
  "unit_of_measure": "botol",
  "rate": 25000
}
GET /api/v1/tenant/outlet/product-prices/{price-id}

Detail satu harga produk.

PUT /api/v1/tenant/outlet/product-prices/{price-id}

Partial update.

DELETE /api/v1/tenant/outlet/product-prices/{price-id}

Soft delete.

GET /api/v1/tenant/outlet/driver-prices

List harga driver milik tenant.

Query Parameters

ParamTypeRequiredDescription
page int optional
limit int optional
outlet_driver_id string optional
tags string optional
is_active boolean optional
POST /api/v1/tenant/outlet/driver-prices

Tambah harga driver. outlet_driver_id harus refer ke item type="driver".

Example Request Body
{
  "outlet_driver_id": "01923c8b-1234-7000-a000-000000000420",
  "name": "Antar Reguler 0-5km",
  "unit_of_measure": "trip",
  "rate": 10000
}
GET /api/v1/tenant/outlet/driver-prices/{price-id}

Detail satu harga driver.

PUT /api/v1/tenant/outlet/driver-prices/{price-id}

Partial update.

DELETE /api/v1/tenant/outlet/driver-prices/{price-id}

Soft delete.

POST /api/v1/register/customer

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 Request 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"
}
POST /api/v1/register/tenant

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 Request 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"
}
POST /api/v1/tenant/outlet/service-setups

Buat service item + price pair dalam 1 request. rate mengisi record By Outlet; record By Platform dihitung otomatis dari setting platform percent/cap.

Example Request 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"
}
GET /api/v1/tenant/outlet/service-setups/{id}

Detail service-setup record by id (jejak setup yang pernah dibuat).

PUT /api/v1/tenant/outlet/service-setups/{id}

Update setup record. Partial โ€” kirim hanya field yang berubah pada item / price pair-nya.

DELETE /api/v1/tenant/outlet/service-setups/{id}

Soft-delete setup record (item + harga turunannya tetap di tabel masing-masing).

POST /api/v1/tenant/outlet/product-setups

Buat product item + price pair dalam 1 request.

Example Request 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"
}
POST /api/v1/tenant/outlet/driver-setups

Buat driver item + price pair dalam 1 request.

Example Request 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"
}
GET /api/v1/tenant/summary/outlet

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"
}
GET /api/v1/tenant/summary/income

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"
}
GET /api/v1/tenant/summary/orders

Total jumlah order tenant.

Example Response
{
  "success": true,
  "data": {
    "total_orders": 1234
  },
  "message": "Success"
}
GET /api/v1/tenant/summary

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"
}
GET /api/v1/tenant/cod-debt

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"
}
POST /api/v1/tenant/cod-debt/repay

Catat pembayaran (full / parsial) terhadap saldo. Setelah saldo turun di bawah threshold, can_accept_cod jadi true lagi. Response = status terbaru.

Example Request Body
{
  "amount": 25000,
  "note": "TRX-20260601-001 transfer bank"
}
POST /api/v1/tenant/walk-in

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) โ€” 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 Request 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"
}
POST /api/v1/tenant/walk-in/{id}/link-account

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 Request 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"
}
GET /api/v1/tenant/profile

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"
}
POST /api/v1/tenant/profile

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 Request 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"
}
PUT /api/v1/tenant/profile

Partial update โ€” kirim hanya field yang berubah. Field tidak dikirim = tetap.

Example Request Body
{
  "owner_name": "Budi Santoso S.E.",
  "mobile_number": "+628999999999"
}
GET /api/v1/customer/profile

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"
}
POST /api/v1/customer/profile

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 Request Body
{
  "name": "Siti Nurhaliza",
  "address": "Jl. Merdeka 10",
  "location": "Jakarta Pusat",
  "latitude": "-6.175392",
  "longitude": "106.827153",
  "mobile_number": "+62811xxxxxxx"
}
PUT /api/v1/customer/profile

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 Request Body
{
  "address": "Jl. Sudirman 5, Apt 12",
  "latitude": "-6.208763",
  "longitude": "106.845599"
}
GET /api/v1/locations/provinces

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"
}
GET /api/v1/locations/provinces/{id}/cities

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"
}
GET /api/v1/locations/cities/{id}/subdistricts

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"
}
GET /api/v1/tenant/member/summary

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"
}
POST /api/v1/tenant/member

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 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 Request 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"
}
GET /api/v1/tenant/member

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 Parameters

ParamTypeRequiredDescription
page int optional Halaman 1-based. Nilai <1 di-clamp ke 1. (default: 1)
limit int optional Jumlah per halaman. Clamp: <1 โ†’ 20, >100 โ†’ 100. (default: 20)
outlet_id string optional Filter member dengan outlet_member aktif ke outlet ini
account_id string optional Filter UUID akun
role_id string optional Filter UUID role
is_active bool optional Filter access gate (true/false). Tidak dikirim = semua. on_leave masuk is_active=true
status string optional 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.
search string optional Search match display_name/email (dari mirror accounts) atau mobile_number (dari tenant_member), case-insensitive.
sort_by string optional Field sort: created_at, updated_at, atau display_name (di-resolve ke accounts.display_name). (default: created_at)
sort_order string optional asc atau desc (default: 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"
}
GET /api/v1/tenant/member/{id}

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"
}
PATCH /api/v1/tenant/member/{id}

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 Request 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"
}
POST /api/v1/tenant/invitations/{id}/accept

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"
}
POST /api/v1/tenant/invitations/{id}/resend

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"
}
POST /api/v1/tenant/invitations/{id}/reject

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"
}
POST /api/v1/tenant/member/{id}/reset-password

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 Request 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"
}
DELETE /api/v1/tenant/member/{id}

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"
}
GET /api/v1/tenant/provisioning

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"
}
POST /api/v1/tenant/provisioning/done

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

GET /api/v1/tenant/promo

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

GET /api/v1/tenant/promo/summary

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"
}
POST /api/v1/tenant/promo

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 Request 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"
}
POST /api/v1/tenant/promo/save-and-publish

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.

GET /api/v1/tenant/promo/{id}

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.
PUT /api/v1/tenant/promo/{id}

Partial update โ€” kirim field yang berubah saja. Kalau status active, beberapa field di-lock (mis. promo_type, mechanism, coupon_code).

DELETE /api/v1/tenant/promo/{id}

Soft delete. Hanya boleh dari status draft atau ended โ€” promo aktif/scheduled harus di-cancel dulu.

PATCH /api/v1/tenant/promo/{id}/publish

Dari draft โ†’ scheduled/active (tergantung valid_from). 409 kalau dari state lain. published_at di-stamp.

PATCH /api/v1/tenant/promo/{id}/pause

Dari active/scheduled โ†’ paused. paused_at di-stamp. Customer tidak bisa apply selama paused.

PATCH /api/v1/tenant/promo/{id}/resume

Dari paused โ†’ kembali ke active/scheduled (sesuai tanggal saat ini).

PATCH /api/v1/tenant/promo/{id}/cancel

Dari scheduled/active/paused โ†’ ended lebih awal. Permanen.

PATCH /api/v1/tenant/promo/{id}/end

Akhiri promo sekarang (sebelum valid_end). Status โ†’ ended.

POST /api/v1/tenant/promo/{id}/duplicate

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.

GET /api/v1/tenant/promo/outlets/available

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.

POST /api/v1/admin/promo/transition-status

Sapu seluruh promotional_schema dan:

  • scheduled โ†’ active kalau valid_from โ‰ค now.
  • active โ†’ ended kalau valid_end < now.

Idempoten. Aman dipanggil tiap menit.

GET /api/v1/tenant/orders

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 Parameters

ParamTypeRequiredDescription
page int optional Halaman 1-based (default: 1)
limit int optional Jumlah per halaman (default: 20)
outlet_id string optional Filter UUID outlet (tetap tenant-scoped + RBAC outlet-scope)
customer_id string optional Filter UUID customer (= account_id post-F3)
status string optional 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"
}
GET /api/v1/tenant/orders/{id}

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"
}
PUT /api/v1/tenant/orders/{id}

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 โ€” 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 Request Body
{
  "note": "Customer minta jemput sore"
}
PATCH /api/v1/tenant/order-list/{id}/status-finished

Tandai satu baris item selesai. Setelah semua baris finished + state header sesuai, order otomatis advance ke status berikut via Order Flow (advance).

PATCH /api/v1/tenant/order-list/{id}/status-canceled

Batalkan satu baris item (mis. service tidak bisa dilakukan untuk material tertentu). Tidak membatalkan order header โ€” untuk full cancel pakai reject di Order Flow.

PATCH /api/v1/tenant/order-list/{id}/update-quantity

Koreksi quantity estimasi sebuah baris (mis. owner timbang ulang sebelum verifikasi final). Amount line dihitung ulang.

Example Request Body
{
  "quantity": 3.5
}
PATCH /api/v1/tenant/order-list/{id}/set-final-quantity

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.

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 Request Body
{
  "final_quantity": 3.2,
  "note": "Berat aktual sesuai struk"
}
Example Response
{
  "success": true,
  "data": null,
  "message": "final quantity recorded"
}
GET /api/v1/tenant/outlet/outlet-ratings

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 Parameters

ParamTypeRequiredDescription
page int optional Halaman 1-based (default: 1)
limit int optional Jumlah per halaman (default: 20)
outlet_id string optional Filter UUID outlet (kosong = semua outlet tenant)
star int optional Filter rating bintang spesifik (1-5). Filter 'Komplain' (โ‰ค2) belum support โ€” lihat known gap.
GET /api/v1/tenant/outlet/outlet-ratings/{id}

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"
}
POST /api/v1/tenant/outlet/outlet-ratings/{id}/read

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.

GET /api/v1/tenant/outlet/outlet-ratings/summary

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 Parameters

ParamTypeRequiredDescription
outlet_id string optional 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"
}
GET /api/v1/tenant/outlet/service-ratings

Service-level rating (per-item) di tenant ini.

GET /api/v1/tenant/outlet/service-ratings/{id}

Detail service rating + helpful count + reviewer + order context.

GET /api/v1/tenant/me

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"
}
GET /api/v1/tenant/capabilities

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"
}
GET /api/v1/tenant/roles

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"
}
POST /api/v1/tenant/roles

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 Request 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"
}
GET /api/v1/tenant/roles/{id}

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"
}
PUT /api/v1/tenant/roles/{id}

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 Request 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"
}
DELETE /api/v1/tenant/roles/{id}

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"
}

๐Ÿ’ฌ Chat Inbox

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.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/chats List chat room caller On mount tab Chat + on focus refresh required
๐Ÿ’ก Tenant + customer pakai endpoint sama; server deteksi role via JWT permission wacca-tenant:*.
GET /api/v1/chats?outlet_id={id} Filter chat per outlet (kasus customer pilih outlet di header) On switch outlet selector required

โœ‰๏ธ Chat Detail

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

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/chats/{id} Verify visibility + dapat channel_id On mount chat detail required
๐Ÿ’ก 404 kalau bukan visible scope. channel_id โ†’ join chat-service WebSocket.
POST /api/v1/chats Bikin chat baru kalau belum ada (idempotent) On tap 'Chat Outlet' dari halaman outlet detail (customer) required
๐Ÿ’ก Body {outlet_id}. customer_id auto dari JWT.

๐Ÿ“ Daftar Alamat

Daftar alamat customer dengan badge DEFAULT pada alamat utama. Urut is_default DESC, created_at DESC โ€” default selalu di atas.

Figma: 1477:130.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/customer/address Load alamat customer (paginated) On mount required

โž• Form Alamat โ€” Tambah

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.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
POST /api/v1/customer/address Submit alamat baru On tap 'Simpan' required
๐Ÿ’ก Setelah sukses โ†’ kembali ke Daftar Alamat

โœ๏ธ Form Alamat โ€” Edit

Partial update โ€” hanya kirim field yang berubah. Server validasi ownership (403 kalau bukan milik customer).

Figma: 1477:199 (sama dengan Form Tambah, pre-filled).

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/customer/address/{id} Pre-fill form dengan alamat existing On tap card alamat required
PUT /api/v1/customer/address/{id} Submit perubahan On tap 'Simpan' required

๐Ÿ—‘๏ธ Konfirmasi Hapus

Bottom-sheet konfirmasi hapus alamat. Soft delete โ€” data tetap tersimpan; pesanan aktif yang refer ke alamat ini tidak terdampak.

Figma: 1477:249.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
DELETE /api/v1/customer/address/{id} Soft-delete alamat On tap 'Hapus' di konfirmasi required

โญ Jadikan Default

Action di setiap card alamat (non-default) untuk jadikan default. Transactional: server unset semua default lain + set ini = true.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
PATCH /api/v1/customer/address/{id}/set-default Jadikan alamat ini default On tap 'Jadikan Default' required

๐ŸŽŸ๏ธ Customer โ€” Tab "Promo Saya"

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.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/customer/promo?status=active List voucher aktif (default tab) On mount tab Promo Saya required
GET /api/v1/customer/promo?status=upcoming List voucher belum valid_from On switch tab 'Akan Datang' required
GET /api/v1/customer/promo?status=expired List voucher expired/used On switch tab 'Riwayat' required

๐Ÿท๏ธ Customer โ€” Promo Outlet (di Outlet Detail)

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

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/customer/outlets/{id}/promo Load promo aktif outlet (dengan flag claimed per-customer) On mount outlet detail required
๐Ÿ’ก Path singular (PR #56). Field claimed=true โ†’ tombol 'Klaim' di-hide / ganti 'Sudah Diklaim'.
POST /api/v1/customer/promo/{id}/claim Klaim promo (mechanism=claim) ke wallet customer On tap tombol 'Klaim' required
๐Ÿ’ก {id} = promotional_id. 409 โ†’ refetch list (claim sudah ada / kuota habis).

๐Ÿ“œ Customer โ€” Detail Voucher

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.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/customer/promo/{id} Detail customer_coupon + T&C lengkap On mount detail voucher required
๐Ÿ’ก {id} = customer_coupon.id, BUKAN promotional_id.

โญ Customer โ€” Tab "Belum Ulas"

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

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/customer/outlet-ratings/pending List order completed yang belum di-rating (untuk card per row) On mount tab Belum Ulas required
๐Ÿ’ก Response: PendingPresent[] dengan order_id, order_number, outlet_name, service_names[], created_at.
GET /api/v1/customer/outlet-ratings/summary Count "Riwayat (N)" di tab sebelah On mount + after submit rating required
๐Ÿ’ก Pakai field count.

๐Ÿ“‹ Customer โ€” Tab "Riwayat" Ulasan

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.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/customer/outlet-ratings/summary Header stat (avg + helpful received) On mount tab Riwayat required
๐Ÿ’ก Field: average, count, helpful_received.
GET /api/v1/customer/outlet-ratings?page=1&limit=20 List review customer (enriched outlet_name, service_names, photos, helpful_count) On mount + pagination scroll required

โœ๏ธ Customer โ€” Form Ulasan

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.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
POST /api/v1/customer/outlet-ratings Submit review baru On tap 'Kirim Ulasan' setelah validasi client required
๐Ÿ’ก 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).

๐Ÿ‘ Customer โ€” Vote Helpful di Review Orang Lain

Saat customer baca review orang di outlet detail / profile customer lain, bisa tap ๐Ÿ‘ untuk mark "merasa terbantu". Toggle: tap lagi = unvote. Counter naik/turun realtime.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
POST /api/v1/customer/outlet-ratings/{id}/helpful Vote helpful di review orang lain On tap ๐Ÿ‘ (icon belum berisi) required
๐Ÿ’ก Idempoten โ€” vote 2x = no-op. Response: {rating_id, helpful_count, voted: true}.
DELETE /api/v1/customer/outlet-ratings/{id}/helpful Tarik kembali vote On tap ๐Ÿ‘ (icon sudah berisi) required
๐Ÿ’ก Idempoten. Response: {rating_id, helpful_count, voted: false}.

๐Ÿ”” Owner โ€” Bootstrap Notifikasi (App Mount + Login)

Setup sekali per session: pastikan FCM token terdaftar di wacca + JWT siap untuk hit notification-service. Logout flow: unregister token dulu, baru clear JWT.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
POST /api/v1/tenant/device-tokens Register FCM token owner ke wacca After login + on every FCM token refresh (Firebase SDK onTokenRefresh) required
๐Ÿ’ก 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.
DELETE /api/v1/tenant/device-tokens Unregister token sebelum clear JWT On tap Logout (sebelum hapus session) required
๐Ÿ’ก Body {token}. Idempoten (200 walau token sudah ga ada).

๐Ÿ“ฌ Owner โ€” Inbox Tab + Badge

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)
Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET https://notification.ikavia.com/api/v1/notifications/unread-count Badge angka di bottom-tab Notifikasi On app mount + polling 30s di foreground required
๐Ÿ’ก 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).
GET https://notification.ikavia.com/api/v1/notifications?limit=20&offset=0 Daftar inbox (latest first) On open tab + pull-to-refresh + infinite scroll required
๐Ÿ’ก 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.
POST https://notification.ikavia.com/api/v1/notifications/{id}/read Tandai notif sudah dibaca (shared state โ€” staff lain juga ke-mark) On user tap notif row required
POST https://notification.ikavia.com/api/v1/notifications/read-all Mark-all-as-read On tap 'Tandai semua dibaca' required

โœ‹ Owner โ€” Tap Tombol Aksi (Approve Price / Accept Invite)

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.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
POST /api/v1/tenant/orders/{order_id}/respond-price Tap "Setujui"/"Tolak" pada notif order_price_proposed (action.key = approve_price|reject_price) On tap action button di notif order_price_proposed (customer-side notif โ€” owner tidak terima ini, ini untuk reference customer flow) required
๐Ÿ’ก Body sesuai action: {action: "approve"|"reject", reason?}. Resolve order_id dari payload.metadata.order_id.
POST /api/v1/tenant/invitations/{invitation_id}/accept Tap "Terima" pada notif member_invited (action.key = member_accept) On tap action button di notif member_invited required
๐Ÿ’ก 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".
POST /api/v1/tenant/invitations/{invitation_id}/reject Tap "Tolak" pada notif member_invited (action.key = member_reject) On tap action button di notif member_invited required

โš™๏ธ Owner โ€” Setelan Notifikasi (opsional)

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

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET https://notification.ikavia.com/api/v1/preferences/options Katalog channel + kategori yang bisa di-mute On mount setelan notifikasi required
๐Ÿ’ก Authoritative โ€” apa pun yang muncul di sini pasti diterima PUT preferences.
GET https://notification.ikavia.com/api/v1/preferences Load preferensi saat ini (muted map kategoriโ†’channels) On mount setelan required
PUT https://notification.ikavia.com/api/v1/preferences Replace seluruh preferensi (bukan merge) On tap 'Simpan' required
๐Ÿ’ก 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.

๐Ÿ“ฅ Owner โ€” Inbox Order Masuk

Daftar order baru (status=pending) โ€” owner accept / reject. Polling tiap 10โ€“15 detik (atau pakai push notif order_new) untuk update unread badge.

Mobile Web

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/tenant/orders?status=pending&page=1&limit=20 List order pending untuk owner On mount + interval polling (15s) atau on push notif order_new required
POST /api/v1/tenant/orders/{id}/accept Owner terima order On tap 'Terima' required
๐Ÿ’ก โ†’ status=accepted. Lanjut ke screen pickup atau verify-final-price.
POST /api/v1/tenant/orders/{id}/reject Owner tolak order pending (HANYA saat status=pending) On tap 'Tolak' + isi alasan required
๐Ÿ’ก Body {reason:'...'}. Setelah accepted, batal lewat komplain.

๐Ÿ›ต Owner โ€” Tracking Pickup (Driver Jemput)

Hanya untuk order delivery_method=pickup. Owner mark driver berangkat dan tiba di lokasi customer. Mengaktifkan SetFinalPrice gate (Anomali #1 / GAP A).

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
POST /api/v1/tenant/orders/{id}/pickup-dispatch Catat driver berangkat On tap 'Driver Berangkat' required
๐Ÿ’ก Push notif ke customer 'Driver sedang menuju lokasi'.
POST /api/v1/tenant/orders/{id}/pickup-arrived Catat driver tiba + pickup selesai On tap 'Pesanan Terambil' required
๐Ÿ’ก Push notif ke customer 'Pesanan sudah diambil'.

โš–๏ธ Owner โ€” Timbang & Set Harga Final

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

API Calls

MethodEndpointPurposeTriggerAuth
PATCH /api/v1/tenant/order-list/{id}/set-final-quantity Set berat aktual per baris service (auto-set order_list.verified_at) On tap 'Simpan' di form timbang per baris required
๐Ÿ’ก Body {final_quantity:3.2, note?:'...'}. Panggil sekali per order_list service.
POST /api/v1/tenant/orders/{id}/verification-photos Upload foto pakaian/struk timbangan (opsional, audit) On tap 'Foto' lalu kirim required
POST /api/v1/tenant/orders/{id}/verify-complete Kunci verifikasi (HANYA self_dropoff). 409 kalau ada baris belum di-set-final-quantity. On tap 'Lanjut Set Harga' (kalau self_dropoff) required
๐Ÿ’ก Untuk pickup, skip langsung ke final-price.
POST /api/v1/tenant/orders/{id}/final-price Ajukan harga final ke customer (hitung PPN dari setting, fallback 0 kalau belum ter-setting) On tap 'Ajukan Harga' required
๐Ÿ’ก โ†’ status=price_proposed. Customer akan terima notif untuk approve/reject.

๐Ÿ” Owner โ€” Proses Order (Advance Status)

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.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/tenant/orders/{id} Detail order untuk render UI (status, items, total, payment_method) On mount + after each action required
POST /api/v1/tenant/orders/{id}/advance Maju ke status berikut On tap 'Siap Dijemput' / 'Antar Sekarang' / 'Selesai' required
๐Ÿ’ก Body {target:'ready_pickup'|'delivering'|'completed'}. Catatan: target=completed dari delivering DIBLOKIR (customer yang confirm-receipt). Dari ready_pickup โ†’ completed boleh (serah-terima outlet).
POST /api/v1/tenant/orders/{id}/confirm-cod Konfirmasi uang tunai diterima (idempoten per order) On tap 'Sudah Dibayar Tunai' required
๐Ÿ’ก Tidak mengubah fulfilment status. Boleh dipanggil pre- atau post-completed.

๐Ÿ›’ Customer โ€” Checkout / Buat Order

Customer pilih items, optional preview kupon, lalu commit POST order.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/payment-methods Load dropdown metode pembayaran (filter is_active=true & is_offline=false untuk online) On mount checkout required
GET /api/v1/customer/orders/pickup-quote?outlet_id={id}&pickup_address_id={id} Preview pickup fee untuk delivery_method=pickup On select delivery_method=pickup + alamat required
POST /api/v1/customer/orders/coupon-preview Preview diskon kupon sebelum apply On input kode kupon + tap 'Cek' required
๐Ÿ’ก Response {subtotal, discount_amount, final_total, is_free_shipping}. Untuk free_shipping, FE flip label 'Diskon' โ†’ 'Gratis Ongkir'.
POST /api/v1/customer/orders Submit order On tap 'Bayar' / 'Pesan' required
๐Ÿ’ก Body wajib payment_method_id (UUID, bukan string legacy). Optional addons[], photo_media_ids[].

๐Ÿ“ Customer โ€” Tracking Order

Customer lihat progress order โ€” status header + timeline visual + pickup-status (kalau pickup). Listen push notif untuk auto-refresh.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/customer/orders/{id} Detail order (status, payment_method_id, pickup_status, dst) On mount + on push notif required
GET /api/v1/customer/orders/{id}/timeline 8-step progress array siap render (created โ†’ accepted โ†’ pickup_dispatched/arrived โ†’ price_proposed โ†’ processing โ†’ handover โ†’ completed) On mount + after each push notif required
๐Ÿ’ก Lebih murah daripada parse status sendiri. Step status enum: done|pending|skipped. Variant 'ready_pickup' atau 'delivering' di step handover.

๐Ÿ’ฐ Customer โ€” Setujui / Tolak Harga Final

Notif order_price_proposed masuk โ†’ customer buka detail โ†’ setuju / tolak. Tolak harga = batal order dengan reject_reason.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
POST /api/v1/customer/orders/{id}/approve-price Setuju harga final On tap 'Setuju' di card harga required
๐Ÿ’ก โ†’ status=processing (auto). Owner langsung mulai laundry.
POST /api/v1/customer/orders/{id}/reject-price Tolak harga final + alasan On tap 'Tolak' + input alasan required
๐Ÿ’ก Body {reason:'...'}. โ†’ status=cancelled, cancelled_by='customer'.

๐Ÿ“ฆ Customer โ€” Konfirmasi Penerimaan

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.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
POST /api/v1/customer/orders/{id}/confirm-receipt Tutup lifecycle order On tap 'Sudah Saya Terima' (visible kalau status=delivering) required

๐Ÿšซ Customer โ€” Batal Order

Cancel order milik customer sebelum dikerjakan (pending atau price_proposed). Setelah processing, harus lewat alur komplain (lihat Complaint).

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
POST /api/v1/customer/orders/{id}/cancel Batalkan order On tap 'Batalkan Pesanan' + konfirmasi required
๐Ÿ’ก โ†’ status=cancelled. 409 kalau status sudah processing ke atas.

๐Ÿช Setting Alamat Outlet

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.

Web

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/locations/provinces Load dropdown provinsi On mount form required
GET /api/v1/locations/provinces/{id}/cities Cascade โ€” load kota saat province dipilih On change province dropdown required
GET /api/v1/locations/cities/{id}/subdistricts Cascade โ€” load kecamatan saat city dipilih On change city dropdown required
POST /api/v1/tenant/outlet Submit outlet baru (mode create) On tap 'Simpan' required
๐Ÿ’ก Sertakan province_id, city_id, subdistrict_id, latitude, longitude
PUT /api/v1/tenant/outlet/{outlet-id} Update alamat outlet (mode edit) On tap 'Simpan' di edit form required

๐Ÿ’ณ Customer โ€” Dropdown Pilih Pembayaran (Checkout)

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.

Mobile

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/payment-methods Load semua metode + filter di FE On mount checkout required
๐Ÿ’ก Cache 5โ€“10 menit di client; daftar metode jarang berubah.

๐Ÿงพ Owner โ€” Dropdown Pilih Pembayaran (Walk-in / Kasir)

Dropdown di halaman walk-in / kasir. Filter hanya is_offline=true (cash, cod) โ€” metode online tidak boleh dipakai walk-in (server tolak 400).

Mobile Web

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/payment-methods Load metode, filter is_offline=true di FE On mount walk-in form required

๐Ÿช Owner โ€” Kasir Walk-in

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.

Mobile Web

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/payment-methods Load metode bayar (filter is_offline=true) On mount form required
GET /api/v1/tenant/cod-debt Cek saldo utang COD untuk gate COD On select payment_method=cod (atau on mount) required
๐Ÿ’ก Kalau ada utang aktif, server tolak POST walk-in COD dengan 403. UI bisa pre-disable opsi COD di dropdown.
POST /api/v1/tenant/walk-in Submit walk-in order On tap 'Simpan' required
๐Ÿ’ก 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.

๐Ÿ”— Owner โ€” Link Order Guest ke Akun Customer

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

Mobile Web

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/tenant/customers?search={phone_or_email} Cari customer terdaftar untuk pilih target link On user ketik di search box modal required
๐Ÿ’ก Resolusi customer (akun terdaftar di tenant ini) โ€” lihat section Tenant Customer.
POST /api/v1/tenant/walk-in/{id}/link-account Kaitkan order guest ke customer terdaftar On tap 'Kaitkan' setelah pilih customer di modal required
๐Ÿ’ก 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.

๐ŸŽฏ Owner โ€” Daftar Promo + Summary

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

Mobile Web

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/tenant/promo/summary Header โ€” counter per tab (active/scheduled/ended/draft) + total subsidi bulan ini On mount Promo page required
GET /api/v1/tenant/promo?status=active&page=1&limit=20 List promo per tab On mount + on switch tab + scroll bawah (pagination) required
๐Ÿ’ก Filter tambahan: promo_type, mechanism, q (search by name/code). Tampilkan usage_percentage, budget_percentage sebagai progress bar.

โœจ Owner โ€” Form Buat / Edit Promo

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.

Mobile Web

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/tenant/outlet List outlet milik tenant โ€” untuk picker "outlet_ids[]" On step 'Outlet Scope' kalau pilih 'selected' required
๐Ÿ’ก Atau pakai endpoint helper di bawah kalau sudah pilih service requirement.
GET /api/v1/tenant/promo/outlets/available?service_ids={ids}&require_all=true List outlet + flag eligibility per service yang dibutuhkan promo On step pilih outlet, setelah service requirement diisi required
๐Ÿ’ก Outlet is_eligible=false di-disable di picker dengan reason di tooltip.
GET /api/v1/tenant/outlet-services Picker service untuk required_service_ids[], free_service_ids[], bundle_service_ids[] On step service requirement required
POST /api/v1/tenant/promo Simpan draft On tap 'Simpan Draft' required
๐Ÿ’ก Validasi server: coupon_code unique per tenant lower-case; nilai sesuai promo_type.
POST /api/v1/tenant/promo/save-and-publish Simpan + langsung publish (create atau update) On tap 'Simpan & Publish' required
๐Ÿ’ก Body include publish: true + id (opsional, untuk update existing).

๐Ÿ“‹ Owner โ€” Detail Promo + Action Buttons

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

Mobile Web

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/tenant/promo/{id} Detail + relasi (outlets, services, free_services, bundle_items) + allowed_actions + performa On mount detail required
PATCH /api/v1/tenant/promo/{id}/publish Publish dari draft On tap 'Publish' required
PATCH /api/v1/tenant/promo/{id}/pause Pause active/scheduled On tap 'Pause' required
PATCH /api/v1/tenant/promo/{id}/resume Resume paused On tap 'Resume' required
PATCH /api/v1/tenant/promo/{id}/end Akhiri active sekarang On tap 'Akhiri Sekarang' required
PATCH /api/v1/tenant/promo/{id}/cancel Batal scheduled/active/paused On tap 'Batalkan' required
POST /api/v1/tenant/promo/{id}/duplicate Bikin draft baru dari template promo ini (coupon_code dikosongkan) On tap 'Duplicate' required
DELETE /api/v1/tenant/promo/{id} Soft delete (hanya draft/ended) On tap 'Hapus' dengan confirm dialog required

โญ Owner โ€” Daftar Ulasan & Rating

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.

Mobile Web

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/tenant/outlet Load outlet list untuk render chip "Semua / Pusat / Outlet 1 / Outlet 2" On mount halaman required
๐Ÿ’ก Atau bisa cache hasil tenant-summary.
GET /api/v1/tenant/outlet/outlet-ratings/summary Render summary card (avg + count + distribution 5-bar) On mount + on switch outlet filter required
๐Ÿ’ก Param ?outlet_id={uuid} atau kosong = semua. Render distribution[] sebagai 5 bar chart dengan percent.
GET /api/v1/tenant/outlet/outlet-ratings?outlet_id={id}&star={n}&page=1&limit=20 List review dengan filter On mount + switch outlet/star chip + pagination scroll required
๐Ÿ’ก 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.

๐Ÿ” Owner โ€” Detail Ulasan

Halaman detail satu review. Render reviewer card (avatar+name+order#+date+outlet), 5-star, "Ulasan" text + photo grid, "Detail Pesanan Terkait" card (services/weight/price), sentiment badge dinamis (positive=๐Ÿ˜Š hijau / neutral=๐Ÿ˜ kuning / negative=๐Ÿ˜ž merah).

Auto mark-read on mount supaya badge "Belum dibaca" di list hilang saat user kembali.

Mobile Web

API Calls

MethodEndpointPurposeTriggerAuth
GET /api/v1/tenant/outlet/outlet-ratings/{id} Load detail review lengkap dengan enrichment On mount detail screen required
๐Ÿ’ก Response include reviewer_name, sentiment, helpful_count, photos[], order_context dengan services/weight/price.
POST /api/v1/tenant/outlet/outlet-ratings/{id}/read Mark sudah dibaca owner (badge "Belum dibaca" hilang) On mount detail (best-effort, idempoten) required
๐Ÿ’ก Fire-and-forget โ€” 200 walau sudah ke-mark sebelumnya. After back, list refresh + badge gone.

๐Ÿ” Credentials

Konfigurasi credentials untuk testing API

Authorization: Bearer <value>
๐Ÿ’ก Info: Credentials tersimpan per environment di browser Anda (localStorage) dan tidak dikirim ke server.

Cara Menggunakan

1
Isi credentials di atas
2
Klik "Endpoints" di sidebar untuk memilih API
3
Klik "Try It" pada endpoint yang ingin di-test
4
Pilih metode autentikasi dan klik Send

๐Ÿ” Authentication

Methods and permissions for API access

Authentication Methods

JWT Bearer

Header: Authorization: Bearer <access_token>

Source: account-service

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

Permissions

PermissionDescription
wacca-tenant:* Full access ke /tenant + semua child resource (outlet, items, prices, media, operations).
wacca-customer:* Full access ke /customer.
* Wildcard owner/platform-admin โ€” bypass semua gate.