๐ 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
typemembedakan: 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-pricessemua ke tabelpriceyang 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 tabelaccountsdan read-only di wacca;customer_iddi seluruh sistem =account_id(JWTsub). Alamat konsumen disimpan di tabeladdress(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 kirimnullkalau nil.
๐ Permission model
Tiap resource family digate via RequirePermission middleware:
| Resource family | Permission required |
|---|---|
/api/v1/tenant + semua child (outlet, outlet-*, *-prices) | wacca-tenant:* |
/api/v1/customer | wacca-customer:* |
Wildcard * di-honor sebagai owner/platform-admin override (tidak perlu daftar permission spesifik). Tidak ada bypass khusus untuk service principal โ kalau bot ingin hit endpoint, JWT-nya harus mengandung permission yang sesuai.
๐ข 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
| Field | Arti | Cara set |
|---|---|---|
is_active | Outlet ada (belum di-archive) | Saat DELETE โ soft-delete. Tidak di-toggle manual. |
is_opening | Sedang buka secara real-time | PATCH /tenant/outlet/{id}/open-outlet โ /close-outlet |
driver_available | Driver standby (siap antar) | PATCH /tenant/outlet/{id}/open-drivers โ /close-drivers |
Frontend kasir pakai is_opening untuk allow/disallow order; driver_available untuk toggle delivery option. Logika business "boleh terima order kalau outlet is_opening && is_active" diserahkan ke konsumen (order-service, future).
๐ 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=serviceselalu ter-apply otomatis oleh use_case โ caller tidak perlu kirim manual.
Hal yang sama berlaku untuk price:
service-pricesโ field domainoutlet_service_idproduct-pricesโ field domainoutlet_product_iddriver-pricesโ field domainoutlet_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
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.
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.
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.
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
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
| Param | Type | Required | Description |
|---|---|---|---|
| outlet_id | string | optional | Filter ke outlet tertentu (UUID). |
{
"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"
}
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.
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).
{
"outlet_id": "01923c8b-1234-7000-a000-000000000100"
}
{
"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"
}
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).
{
"order_id": "01923c8b-1234-7000-a000-0000000000aa",
"category": "result_quality",
"description": "Setelah dicuci masih ada bau apek di kemeja putih"
}
Komplain milik customer (paginasi page/limit, filter status).
Detail komplain + thread balasan. 404 kalau bukan milik customer.
Lampirkan foto bukti pada komplain (mis. hasil cuci).
Customer eskalasi ke admin platform โ biasanya setelah owner reject. Status โ escalated. 409 kalau state tidak izinkan transisi.
Customer setujui resolusi owner (resolved โ confirmed). Komplain ditutup.
Buka kembali komplain yang auto_closed (R-CHAT.07 option A). Hanya satu kali. Status โ in_discussion. 409 kalau status bukan auto_closed.
Komplain di tenant saat ini (paginasi). Filter status (salah satu dari status valid), outlet_id. Status filter tidak dikenal โ 400.
Detail komplain + thread + foto + order context.
Owner accept untuk handle (open โ in_discussion).
Owner balas โ pesan masuk ke thread. Set status โ awaiting_customer (mulai countdown 24h auto-close).
{
"message": "Mohon maaf, kami akan jemput ulang gratis besok pagi"
}
Owner tandai komplain selesai (status โ resolved). Customer perlu confirm untuk close final.
{
"resolution_type": "rewash",
"note": "Sudah di-cuci ulang, customer terima jemput hari ini"
}
Owner tolak komplain (rejected). Customer masih boleh escalate (R-CHAT.06).
{
"note": "Berdasarkan foto, kondisi sudah sesuai standar"
}
Owner escalate ke admin platform (mis. butuh dispute resolution dari pihak ketiga). Status โ escalated.
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.
Paginasi alamat milik customer (page/limit, default 1/20). Diurutkan is_default DESC, created_at DESC โ default selalu pertama.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | Halaman 1-based (default: 1) |
| limit | integer | optional | Jumlah per halaman (default: 20) |
{
"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"
}
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).
{
"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
}
{
"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"
}
Detail satu alamat. 403 kalau alamat bukan milik customer pemanggil.
Partial update โ kirim hanya field yang berubah. 403 kalau bukan milik customer. 400 'no fields to update' kalau body kosong.
{
"category": "Rumah Baru",
"mobile_phone": "+62813xxxxxxx"
}
Soft delete. 403 kalau bukan milik customer.
Jadikan alamat ini default (sekaligus un-set default lain). Transactional. 403 kalau bukan milik customer.
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 denganrating >= min_ratingyang muncul. Outlet baru tanpa review punyarating=0, jadi akan tersembunyi kalaumin_rating > 0. Nilai non-numeric โ filter diabaikan.min_price/max_priceโ float; outlet harus punya minimal 1 service dengan hargaBy Platformaktif yang masuk rentang. Diterapkan ke service saja (bukan product/driver), match pola gatingcustomer/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.
{
"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 }
}
Detail satu outlet (struktur sama dgn item list โ incl. rating, distance_km, min_price, services/products/drivers).
{
"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"
}
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.
Daftar outlet yang difavoritkan customer (1 query JOIN, ringkasan outlet).
{
"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"
}
Favoritkan outlet. Idempoten. 404 bila outlet tidak ada.
{ "outlet_id": "01923c8b-...-0004" }
{ "success": true, "message": "outlet favorited" }
Hapus favorit (soft delete). 404 bila belum difavoritkan.
{ "success": true, "message": "outlet unfavorited" }
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
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | (default: 1) |
| limit | int | optional | (default: 20) |
| outlet_id | string | optional | Scope ke 1 outlet |
| search | string | optional | Search nama item |
Detail satu service + harga + outlet info.
Barang dagang (deterjen, pewangi, dll) cross-outlet.
Detail satu product.
Resource antar-jemput (kurir) yang ditawarkan outlet.
Detail satu driver resource.
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 dariitems(cross-check; mismatch โ400). Tidak perlu dikirim.delivery_methodโpickup(kurir jemput) atauself_dropoff(antar sendiri; default).pickup_address_idโ opsional bilapickup: 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 kepayment_method_iddi server (lihat di bawah).payment_method_idโ UUID dari Payment Methods. Bila dikirim, lebih diutamakan daripayment_methodstring. Mode rekomendasi FE baru: panggilGET /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.
{
"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 }
]
}
{
"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"
}
Order milik customer (paginasi page/limit). Status mencerminkan posisi di state machine.
{
"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 }
}
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
| Param | Type | Required | Description |
|---|---|---|---|
| outlet_id | string | required | UUID outlet asal jemput |
| pickup_address_id | string | optional | UUID alamat customer. Kalau kosong โ fallback ke default address customer. |
{
"success": true,
"data": {
"distance_km": 2.5,
"rate_per_km": 2000,
"pickup_fee": 5000
},
"message": "success"
}
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.
{
"outlet_id": "01923c8b-...-0004",
"coupon_code": "NEWUSER20",
"items": [
{ "item_id": "01923c8b-...-svc1", "quantity": 3 }
]
}
{
"success": true,
"data": {
"subtotal": 21000,
"discount_amount": 4200,
"final_total": 16800,
"is_free_shipping": false
},
"message": "success"
}
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.
{
"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"
}
Customer batalkan order miliknya. Hanya valid pada state tertentu (mis. pending/price_proposed). 409 kalau order sudah masuk fase processing/completed.
Customer konfirmasi sudah menerima pesanan (close lifecycle). Beda dengan approve-price (lihat Order Flow โ Customer).
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 denganestimated_quantitysebelum outlet timbang; di-replace dengan hasil aktual setelahPATCH .../final-service-quantity(lihat Order Flow โ Owner).
UI bisa render badge "diperkirakan X kg โ ditimbang Y kg" tanpa lookup tambahan.
{
"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"
}
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.
{
"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"
}
Daftar customer_coupon milik customer ini (paginasi). Pisah per-tab via query status:
active(default) โ masih bisa dipakaiupcomingโ promo belum valid_fromexpiredโ sudah lewat / promo end / sudah dipakaiallโ 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).
{
"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"
}
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.
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_customersudah penuh.
{
"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"
}
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_idscale must be between 0 and 5(current behavior โ FE wajib enforce โฅ1)review must be 255 characters or fewerinvalid 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)).
{
"outlet_id": "01923c8b-1234-7000-a000-000000000004",
"scale": 5,
"review": "Kerjanya rapi dan cepat",
"likes": ["Bersih", "Cepat", "Ramah"]
}
{
"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.
Outlet rating yang dibuat customer (paginasi page/limit).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | integer | optional | (default: 1) |
| limit | integer | optional | (default: 20) |
Detail satu rating. 404 kalau bukan milik customer.
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.
{
"item_id": "01923c8b-1234-7000-a000-000000000020",
"scale": 5,
"review": "Hasil setrikaan licin",
"likes": ["wangi", "licin"]
}
{
"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"
}
Service rating yang dibuat customer (paginasi page/limit).
Detail satu service rating. 404 kalau bukan milik customer.
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.
{
"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"
}
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 submithelpful_receivedโ total helpful vote yang DITERIMA customer ini dari customer lain (agregat semua review-nya)
{
"success": true,
"data": {
"average": 4.7,
"count": 12,
"helpful_received": 25
},
"message": "success"
}
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.
Tarik kembali vote "merasa terbantu". Idempoten โ kalau belum pernah vote, no-op. Counter helpful_count berkurang.
Default: active-only (untuk dropdown tenant). Pass ?include_inactive=true untuk admin lihat seluruh non-soft-deleted (active + inactive) โ biasanya untuk reaktivasi.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| include_inactive | bool | optional | Default false. true โ admin overview (semua non-deleted). |
{
"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"
}
Detail satu master bank. Admin-gated (*). 404 bila id tidak ada atau sudah soft-deleted.
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_activetidak 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).
{
"code": "bsi",
"name": "Bank Syariah Indonesia"
}
{
"success": true,
"data": {
"id": "01923c8b-...-0001",
"code": "bsi",
"name": "Bank Syariah Indonesia",
"is_active": true
},
"message": "bank created"
}
Partial update โ kirim hanya field yang berubah.
codere-key boleh; sama validasi format + collision check (409) seperti Create.nameโค50 char.is_active=falseuntuk deactivate (dropdown tenant tidak lihat tapi bank_account existing tetap reference);trueuntuk reaktivasi.
404 kalau id tidak ada. 409 kalau code conflict.
# deactivate
{ "is_active": false }
# rename
{ "name": "Bank Syariah Indonesia (BSI)" }
# rekey
{ "code": "bsi-baru" }
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.
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
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | Halaman 1-based (default: 1) |
| limit | int | optional | Jumlah per halaman (default: 20) |
{
"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"
}
Detail satu rekening + bank info ter-enrich. 404 kalau bukan milik tenant ini (cek ownership di UC).
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_idkosong/format invalid, atau bank found tapiis_active=false, atauaccount_number/account_holderkosong/terlalu panjang (>50 / >100). - 404 kalau bank dengan id tsb tidak ada di tabel
banks(UC bubble status daribank_repository.GetByID). - 409 kalau
(tenant, bank, account_number)triplet sudah ada (partial uniqueuq_ba_tenant_bank_number).
Response = IDPresent (cuma {id}). Untuk tampilkan field lengkap di FE, panggil GET /finance/bank-accounts/{id} setelahnya.
{
"bank_id": "00300000-0000-8fff-8fff-000000000001",
"account_number": "1234567890",
"account_holder": "Budi Santoso",
"is_default": true
}
{
"success": true,
"data": { "id": "01923c8b-...-acc1" },
"message": "bank account created"
}
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.
# ganti default ke rekening ini
{ "is_default": true }
# rename holder
{ "account_holder": "Budi Santoso S.E." }
{
"success": true,
"data": { "id": "01923c8b-...-acc1" },
"message": "bank account updated"
}
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).
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).
List service items milik tenant.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| 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 |
Buat layanan baru. type auto-set "service".
{
"outlet_id": "01923c8b-1234-7000-a000-000000000100",
"name": "Cuci Kering",
"description": "Reguler 2 hari",
"unit_of_measure": "kg",
"tags": "standard,reguler"
}
{
"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"
}
Detail item layanan.
Partial update.
{
"description": "Reguler 1 hari",
"unit_of_measure": "kg"
}
Soft delete. Harga terkait (di tabel price) tidak ikut ter-cascade โ bersihkan manual via DELETE /service-prices/{id}.
List product items milik tenant.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | |
| limit | int | optional | |
| outlet_id | string | optional | |
| tags | string | optional | |
| is_active | boolean | optional |
Buat produk baru. type auto-set "product".
{
"outlet_id": "01923c8b-1234-7000-a000-000000000100",
"name": "Pewangi Lavender 500ml",
"description": "Botol semprot",
"unit_of_measure": "botol",
"tags": "fragrance,laundry-add-on"
}
Detail item produk.
Partial update.
Soft delete.
List driver items milik tenant.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | |
| limit | int | optional | |
| outlet_id | string | optional | |
| tags | string | optional | |
| is_active | boolean | optional |
Buat driver baru. type auto-set "driver".
{
"outlet_id": "01923c8b-1234-7000-a000-000000000100",
"name": "Driver Motor 1",
"description": "Motor matic, jangkauan 5km",
"unit_of_measure": "trip",
"tags": "motor,city"
}
Detail item driver.
Partial update.
Soft delete.
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 prefixfcm:, 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.
{
"token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr...",
"platform": "android"
}
{
"success": true,
"data": null,
"message": "device token registered"
}
Hapus FCM token (mis. saat logout). Body berisi token yang mau dihapus. Idempoten โ token tidak ada โ tetap 200/204.
{
"token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr..."
}
Sama persis dengan tenant register; lihat di atas.
{
"token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr...",
"platform": "ios"
}
Sama persis dengan tenant unregister.
{
"token": "dkZ9x7Qe7mD8aP:APA91bF8sN0wXr..."
}
Owner menerima order. pending โ accepted. 409 kalau status tidak mengizinkan.
{
"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"
}
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 panjangservice_unavailableโ service yang dipilih tidak tersediaout_of_radiusโ alamat customer di luar jangkauanotherโ 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.
{
"reason": "Outlet penuh, tidak bisa terima order hari ini",
"reason_code": "capacity_full"
}
{
"success": true,
"data": { "id": "...", "status": "cancelled", "reject_reason": "Outlet penuh, ...", "reject_reason_code": "capacity_full", "updated_at": "..." },
"message": "success"
}
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}.amount0 โ dihitung dari harga By-Platform ร quantity. Dibuat sbg order_listis_addon=true, ikut terhitung di subtotal.
Perhitungan: tax = round(subtotal ร rate), final_total = subtotal โ discount + pickup_fee + tax.
{
"subtotal": 87000,
"discount_amount": 0,
"pickup_fee": 0,
"tax_code": "PPN",
"addons": [ { "item_id": "01923c8b-...-pewangi", "quantity": 1 } ]
}
{
"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"
}
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.
{
"target": "ready_pickup"
}
{
"success": true,
"data": { "id": "...", "status": "ready_pickup", "final_total": 96570, "tax_amount": 9570, "updated_at": "..." },
"message": "success"
}
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.
{
"success": true,
"data": { "id": "...", "status": "accepted", "pickup_status": "dispatched", "pickup_dispatched_at": "2026-06-10T09:00:00Z" },
"message": "success"
}
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).
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.
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+noteper baris order_list. Reuse logikaSetFinalQuantity(validasi ownership, RBAC outlet-scope, recompute amount, setverified_at).photo_media_ids[]โ attach foto verifikasi (dari media-service upload). Reuseverification-photosPOST.noteโ catatan global verifikasi (disimpan diorder.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โtruekalau setelah call ini SEMUA barisverified_at != NULL(artinya owner bisa lanjut SetFinalPrice tanpa panggilverify-completelagi).false= masih ada baris belum diverifikasi, owner perlu call lagi atau pakaiverify-completesetelah lengkap.
Beda dengan verify-complete: verification = "kerjain verifikasi + finalize otomatis kalau lengkap" (1 call). verify-complete = hanya gate finalize tanpa update apapun.
{
"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"
}
{
"success": true,
"data": {
"order_id": "01923c8b-...-order",
"verified_items": 2,
"attached_photos": 2,
"verification_note": "Cuci ekspress, kain putih dipisah",
"completed": true
},
"message": "success"
}
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).
{
"received_amount": 100000
}
{
"success": true,
"data": { "id": "...", "status": "completed", "final_total": 96570, "tax_amount": 9570, "updated_at": "..." },
"message": "success"
}
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.
{
"success": true,
"data": { "id": "...", "status": "processing", "final_total": 96570, "tax_amount": 9570, "updated_at": "..." },
"message": "success"
}
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.
{
"reason": "Harga terlalu tinggi dari estimasi"
}
{
"success": true,
"data": { "id": "...", "status": "cancelled", "reject_reason": "Harga terlalu tinggi ...", "updated_at": "..." },
"message": "success"
}
Owner/kasir lampirkan foto bukti (1+ media_id) ke order. 404 kalau order bukan milik tenant.
{
"media_ids": [
"01923c8b-1234-7000-a000-0000000000m1",
"01923c8b-1234-7000-a000-0000000000m2"
],
"stage": "pickup",
"note": "Pakaian diterima โ 1 noda di kerah kemeja"
}
Foto verifikasi pada order tenant (urutan kronologis).
Customer lihat foto verifikasi order miliknya. 404 kalau bukan milik customer.
List semua media milik tenant. Filter via query param.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | Default 1. |
| limit | int | optional | Default 20. |
| outlet_id | string | optional | Filter by outlet ID (UUID). |
{
"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 }
}
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).
{
"reference_id": "01923c8b-1234-7000-a000-000000000100",
"reference_type": "outlet",
"media_id": "media_8f3e9a2b"
}
Detail single media binding.
Swap media_id (rebind binding existing ke file media-service yang berbeda). Tidak menghapus file lama di media-service (hanya update reference).
{
"media_id": "media_new_id"
}
Soft delete binding (tidak menyentuh file di media-service). Caller bertanggung jawab cleanup file di media-service sendiri kalau memang ingin hapus.
List semua jam operasional milik tenant. Filter via query.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| 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. |
Tambah jam operasional. reference_type auto-set ke "outlet".
{
"reference_id": "01923c8b-1234-7000-a000-000000000100",
"name": "Regular hours",
"description": "Senin-Jumat",
"opening_hours": "08:00",
"closing_hours": "22:00"
}
{
"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"
}
Detail satu jam operasional.
Partial update. Kosongkan field yang tidak berubah.
{
"closing_hours": "23:00"
}
Soft delete jam operasional.
Return seluruh 7 hari (MONโSUN) time-operation untuk satu outlet. Endpoint praktis untuk UI form setup mingguan โ gak perlu paging atau filter.
{
"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"
}
โ 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.
Ambil 7-hari schedule + currently_open flag. 404 kalau outlet bukan milik tenant.
{
"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"
}
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).
{
"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 }
]
}
Status outlet sekarang.
{
"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"
}
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.
{
"status": "temporarily_closed",
"reason": "Banjir, akses terhambat"
}
List outlet milik tenant saat ini (paginated).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| 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. |
{
"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"
}
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_timeformatHH:MM(kalauis_open=true),open_time < close_time. Hari yang tidak dikirim di-treatis_open=false. Timezone diambil dari field top-leveltimezone.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.
{
"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
}
]
}
{
"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"
}
Detail outlet by id. 404 kalau bukan milik tenant ini.
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.
{
"description": "Cabang flagship โ buka 24 jam akhir pekan",
"driver_available": false
}
Soft delete (deleted_at terisi). Outlet hilang dari list. Tidak ada endpoint restore via REST โ kontak admin / SQL kalau perlu un-archive.
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.
Set is_opening = false. Idempotent.
Gunakan saat kasir akhir shift / outlet tutup pintu. Untuk archive permanen pakai DELETE.
Set driver_available = true. Hanya berhasil kalau outlet punya minimal satu driver aktif. Idempotent.
Set driver_available = false. Idempotent.
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.
{
"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"
}
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).
{
"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"
}
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).
{
"name": "OVO",
"provider": "Midtrans",
"channel": "ovo"
}
Partial update. Field editable: name, description, is_active. Code/provider/channel tidak editable (ubah identity โ buat baru).
{ "is_active": false }
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 (channeldigital, settlementpending);net = amount โ pajak order.payment.settledโ baris di-updatesettled(cair).payment.refundedโ baris dinolkan & ditandairefunded.payment.failedโ hanya dicatat dipayment_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).
{
"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"
}
{
"success": true,
"data": { "event_id": "evt_01J9XYZ", "status": "processed", "projected": true },
"message": "ok"
}
Memajukan order yang "macet" melewati ambang waktu, secara batch. Dirancang untuk dipanggil cron.
action:
approve_expired_priceโ orderprice_proposedyang tak direspons customer โprocessing.complete_expired_deliveryโ orderready_pickup/deliveringlewat ambang โcompleted.
Filter (semua opsional): older_than_hours (default 24), outlet_ids, order_ids, dry_run (preview tanpa eksekusi). Respons mencantumkan setiap transisi (items).
{
"action": "approve_expired_price",
"older_than_hours": 24,
"outlet_ids": [],
"order_ids": [],
"dry_run": true
}
{
"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"
}
List harga layanan milik tenant. Filter via query.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | |
| limit | int | optional | |
| outlet_service_id | string | optional | Filter by item id (service). |
| tags | string | optional | |
| is_active | boolean | optional |
Tambah harga untuk satu service-item. outlet_service_id harus refer ke item dengan type="service". Use case resolve outlet otomatis dari item.
{
"outlet_service_id": "01923c8b-1234-7000-a000-000000000400",
"name": "Cuci Kering Reguler",
"description": "1-2 hari",
"unit_of_measure": "kg",
"rate": 8000
}
{
"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"
}
Detail satu harga service.
Partial update.
{
"rate": 9000,
"description": "1 hari"
}
Soft delete harga.
List harga produk milik tenant.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | |
| limit | int | optional | |
| outlet_product_id | string | optional | |
| tags | string | optional | |
| is_active | boolean | optional |
Tambah harga product. outlet_product_id harus refer ke item type="product".
{
"outlet_product_id": "01923c8b-1234-7000-a000-000000000410",
"name": "Pewangi Lavender 500ml",
"unit_of_measure": "botol",
"rate": 25000
}
Detail satu harga produk.
Partial update.
Soft delete.
List harga driver milik tenant.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | |
| limit | int | optional | |
| outlet_driver_id | string | optional | |
| tags | string | optional | |
| is_active | boolean | optional |
Tambah harga driver. outlet_driver_id harus refer ke item type="driver".
{
"outlet_driver_id": "01923c8b-1234-7000-a000-000000000420",
"name": "Antar Reguler 0-5km",
"unit_of_measure": "trip",
"rate": 10000
}
Detail satu harga driver.
Partial update.
Soft delete.
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_clientbelum 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).
{
"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"
}
{
"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"
}
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_clienttidak ter-config. - 502 โ account-service unreachable / response invalid /
CreateOrganizationgagal.
{
"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"
}
{
"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"
}
Buat service item + price pair dalam 1 request. rate mengisi record By Outlet; record By Platform dihitung otomatis dari setting platform percent/cap.
{
"outlet_id": "01923c8b-1234-7000-a000-000000000100",
"name": "Cuci Kering Reguler",
"description": "1-2 hari",
"unit_of_measure": "kg",
"tags": "standard,reguler",
"rate": 8000
}
{
"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"
}
Detail service-setup record by id (jejak setup yang pernah dibuat).
Update setup record. Partial โ kirim hanya field yang berubah pada item / price pair-nya.
Soft-delete setup record (item + harga turunannya tetap di tabel masing-masing).
Buat product item + price pair dalam 1 request.
{
"outlet_id": "01923c8b-1234-7000-a000-000000000100",
"name": "Pewangi Lavender 500ml",
"description": "Botol semprot",
"unit_of_measure": "botol",
"tags": "fragrance",
"rate": 25000
}
{
"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"
}
Buat driver item + price pair dalam 1 request.
{
"outlet_id": "01923c8b-1234-7000-a000-000000000100",
"name": "Antar Reguler 0-5km",
"description": "Motor matic",
"unit_of_measure": "trip",
"tags": "motor,city",
"rate": 10000
}
{
"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"
}
Total outlet, jumlah outlet aktif, dan non-aktif untuk tenant saat ini.
{
"success": true,
"data": {
"total_outlet": 5,
"outlet_active": 4,
"outlet_non_active": 1
},
"message": "Success"
}
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.
{
"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"
}
Total jumlah order tenant.
{
"success": true,
"data": {
"total_orders": 1234
},
"message": "Success"
}
Single-call dashboard โ gabungan outlet + income + orders. Hemat round-trip dibanding hit tiga endpoint terpisah.
{
"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"
}
Status hutang COD tenant: outstanding (saldo aktif) + threshold (batas yang ditentukan billing_mode) + can_accept_cod (gating flag) + billing_mode (skema komisi yang berlaku).
{
"success": true,
"data": {
"tenant_id": "01923c8b-1234-7000-a000-000000000001",
"outstanding": 45000,
"billing_mode": "per_order",
"threshold": 50000,
"can_accept_cod": true
},
"message": "success"
}
Catat pembayaran (full / parsial) terhadap saldo. Setelah saldo turun di bawah threshold, can_accept_cod jadi true lagi. Response = status terbaru.
{
"amount": 25000,
"note": "TRX-20260601-001 transfer bank"
}
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).
{
"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"
}
{
"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"
}
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_idtidak 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).
{
"customer_id": "01923c8b-1234-7000-a000-000000000010"
}
{
"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"
}
Ambil profil tenant milik org saat ini. 400 kalau belum di-create.
{
"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"
}
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).
{
"business_name": "Bersih Cepat Laundry",
"owner_name": "Budi Santoso",
"nationality_number": "3201xxxxxxxxxxxx",
"mobile_number": "+62812xxxxxxx"
}
{
"success": true,
"data": { "id": "...", "organization_id": "...", "business_name": "Bersih Cepat Laundry", "is_active": true, ... },
"message": "tenant created successfully",
"request_id": "uuid"
}
Partial update โ kirim hanya field yang berubah. Field tidak dikirim = tetap.
{
"owner_name": "Budi Santoso S.E.",
"mobile_number": "+628999999999"
}
Ambil profil customer. Selalu 200 โ selama JWT valid, profil disintesis dari account_id walau mirror accounts belum sync (name kosong sampai event RabbitMQ datang).
{
"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"
}
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.
{
"name": "Siti Nurhaliza",
"address": "Jl. Merdeka 10",
"location": "Jakarta Pusat",
"latitude": "-6.175392",
"longitude": "106.827153",
"mobile_number": "+62811xxxxxxx"
}
Partial update. Writable:
address/location/latitude/longitudeโ upsert ke defaultaddressrow.mobile_numberโ upsert kecustomer_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).
{
"address": "Jl. Sudirman 5, Apt 12",
"latitude": "-6.208763",
"longitude": "106.845599"
}
Semua provinsi (~38 baris). Dipakai untuk dropdown pertama.
{
"success": true,
"data": [
{ "id": 31, "name": "DKI Jakarta" },
{ "id": 32, "name": "Jawa Barat" },
{ "id": 33, "name": "Jawa Tengah" }
],
"message": "success"
}
Kota/kabupaten dalam satu provinsi ({id} = province_id).
{
"success": true,
"data": [
{ "id": 3171, "province_id": 31, "name": "Kota Jakarta Pusat" },
{ "id": 3172, "province_id": 31, "name": "Kota Jakarta Utara" }
],
"message": "success"
}
Kecamatan dalam satu kota/kabupaten ({id} = city_id).
{
"success": true,
"data": [
{ "id": 317101, "city_id": 3171, "name": "Gambir" },
{ "id": 317102, "city_id": 3171, "name": "Sawah Besar" }
],
"message": "success"
}
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.
{
"success": true,
"data": { "total": 8, "active": 6, "on_leave": 1, "inactive": 1, "invited": 2 },
"message": "success"
}
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 viauser_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 viauser_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 bikintenant_memberโ undangan harus diterima viaPOST /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 โ pakaiemail. 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_namerequired untuk mode=register.account.passwordboleh kosong โ server auto-generate temp password, kembalikan sekali didata.temp_password(owner wajib catat โ tidak bisa di-recall).
Field per mode:
user_id(uuid) โ WAJIB untukassign&invite(diabaikan untuk register).emailโ WAJIB untukregister(diabaikan untuk assign/invite).mobile_number,profile_picture_id,role_id(optional),outlet_ids(optional) โ common ke semua mode.role_idboleh dikosongkan / tidak dikirim โ server default ke system rolestaff(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_idsdiabaikan untuk role admin/manager (akses implisit ke semua outlet, lihat Update member untuk ruleoutlet:all).- Role lintas-tenant ditolak:
role_idyangtenant_id-nya milik tenant lain โ 400invalid role_id. Role sistem (tenant_id NULL) shared.
Response shape (CreateMemberPresent):
assignโmemberpopulated.inviteโinvitation_idpopulated;membertidak ada sampai invitee accept. Pesan menjelaskan "user needs to accept invitation".registerโuser_id+member;temp_passwordmuncul hanya kalau server generate (password owner dikosongkan).
# 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" }
}
# 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"
}
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 denganid, di-expose terpisah untuk kejelasan.expired: truekalau melewatiexpires_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(atauis_active=trueuntuk inklusi cuti) - tab Cuti โ
status=on_leave - tab Nonaktif โ
status=inactive(atauis_active=false) - tab Diundang โ
status=invited(hanya pending)
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| 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) |
{
"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"
}
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.
{
"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"
}
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 ditenant_member).profile_picture_idโ avatar.statusโ transisi status karyawan, salah satu dariactive|on_leave|inactive. Menggantikan fieldis_activelama + 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 dariaccounts(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, semuaoutlet_memberlama dibersihkan (akses implisit ke semua outlet tetap berlaku).- Role staff (
outlet:alltidak punya):outlet_idsdivalidasi kepemilikannya (outlet asing โ400, assignment lama dibiarkan utuh).
# 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" }
{
"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"
}
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.
{
"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"
}
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.
{
"success": true,
"data": null,
"message": "invitation resent"
}
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.
{
"success": true,
"data": null,
"message": "invitation rejected"
}
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 punyatemp_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.
{
"delivery": "return",
"force_change_on_next_login": true
}
{
"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"
}
Keluarkan member: hapus dari organisasi (account-service), lalu soft-delete tenant_member + seluruh outlet_member-nya secara atomik. 404 bila bukan milik tenant ini.
{
"success": true,
"data": null,
"message": "member removed successfully"
}
Cek status onboarding org_id JWT. Selalu 200; UI baca per-step + setup_done.
{
"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"
}
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).
List promo milik tenant (paginasi). Query opsional:
statusโdraft/scheduled/active/paused/endedpromo_typeโ salah satu 5 nilaimechanismโcode/claim/automaticqโ 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).
Counter per-status (active_count, scheduled_count, ended_count, draft_count) + total_subsidy_this_month (rupiah dari coupon_apply.discount_amount bulan berjalan).
{
"success": true,
"data": {
"active_count": 3,
"scheduled_count": 1,
"ended_count": 8,
"draft_count": 2,
"total_subsidy_this_month": 245000
},
"message": "success"
}
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.
# 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"
}
Create+Publish atau Update+Publish dalam satu request. Body sama dengan Create plus:
id(opsional) โ kalau diisi, update promo existing lalu publish.publish(bool) โ kalaufalse, sama dengan Create/Update biasa (tetapdraft).
Setelah publish, status ke scheduled (kalau valid_from > now) atau active.
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"]untukactive).info_bannerโ string warning kontekstual (mis. "Budget hampir habis").performaโ{conversion_count, total_discount_amount, reward_value}agregat untuk dashboard.
Partial update โ kirim field yang berubah saja. Kalau status active, beberapa field di-lock (mis. promo_type, mechanism, coupon_code).
Soft delete. Hanya boleh dari status draft atau ended โ promo aktif/scheduled harus di-cancel dulu.
Dari draft โ scheduled/active (tergantung valid_from). 409 kalau dari state lain. published_at di-stamp.
Dari active/scheduled โ paused. paused_at di-stamp. Customer tidak bisa apply selama paused.
Dari paused โ kembali ke active/scheduled (sesuai tanggal saat ini).
Dari scheduled/active/paused โ ended lebih awal. Permanen.
Akhiri promo sekarang (sebelum valid_end). Status โ ended.
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.
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.
Sapu seluruh promotional_schema dan:
scheduledโactivekalauvalid_from โค now.activeโendedkalauvalid_end < now.
Idempoten. Aman dipanggil tiap menit.
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
| Param | Type | Required | Description |
|---|---|---|---|
| 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 |
{
"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"
}
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.
{
"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"
}
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.
{
"note": "Customer minta jemput sore"
}
Tandai satu baris item selesai. Setelah semua baris finished + state header sesuai, order otomatis advance ke status berikut via Order Flow (advance).
Batalkan satu baris item (mis. service tidak bisa dilakukan untuk material tertentu). Tidak membatalkan order header โ untuk full cancel pakai reject di Order Flow.
Koreksi quantity estimasi sebuah baris (mis. owner timbang ulang sebelum verifikasi final). Amount line dihitung ulang.
{
"quantity": 3.5
}
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 " | ").
{
"final_quantity": 3.2,
"note": "Berat aktual sesuai struk"
}
{
"success": true,
"data": null,
"message": "final quantity recorded"
}
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
| Param | Type | Required | Description |
|---|---|---|---|
| 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. |
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}
{
"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"
}
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.
Aggregate untuk dashboard / list header. Field:
averageโ avg score 0-5countโ total ratingunread_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
| Param | Type | Required | Description |
|---|---|---|---|
| outlet_id | string | optional | Optional โ scope ke 1 outlet. Kosong = semua outlet tenant. |
{
"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"
}
Service-level rating (per-item) di tenant ini.
Detail service rating + helpful count + reviewer + order context.
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).
{
"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"
}
Katalog statis semua capability: key, label, description, dan features (key menu). Dipakai frontend untuk menafsirkan capability sebuah role + membangun editor role.
{
"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"
}
Role template sistem + role custom milik tenant, masing-masing dengan capabilities + is_system. id-nya dipakai sebagai role_id saat assign/register/invite member.
{
"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"
}
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).
{
"name": "kasir",
"capabilities": ["order:fulfill"]
}
{
"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"
}
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.
{
"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"
}
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.
{
"capabilities": ["order:fulfill", "catalog:write"]
}
{
"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"
}
Hapus role custom. Role sistem โ 403; role yang masih ditugaskan ke member โ 409 (reassign dulu). Butuh role:manage.
{
"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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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).
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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).
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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".
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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).
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.stateada โ render badge settled (sudah disetujui/ditolak/diterima)channels[].fcm.statusโ diagnose kenapa push gak nyampe (lihat troubleshoot table di atas)
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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).
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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).
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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โ cukuppickup-arrived; verify-complete TIDAK perlu.self_dropoffโ wajib panggil verify-complete sebelum final-price.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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).
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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 jugacod.
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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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).
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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).
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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".
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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 tetapdraft) - "Simpan & Publish" โ
POST /tenant/promo/save-and-publishdenganpublish=true(langsung kescheduled/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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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").
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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.
API Calls
| Method | Endpoint | Purpose | Trigger | Auth |
|---|---|---|---|---|
| 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
Cara Menggunakan
๐ 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.
Permissions
| Permission | Description |
|---|---|
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. |