Lewati ke konten utama

Merchant Mobile API Contract

API contract for the Kesles Merchant Mobile App. Endpoints are served by merchant_core_api (Go service, port :8080). Consumer: Flutter mobile app (Android + iOS).

Status (audited 2026-06-03): Doc historically described a target state including multi-outlet, user/role mgmt, settlement reporting, notifications. 17 of those endpoints are not yet implemented in the backend. This revision separates implemented endpoints from deferred design. See §2 partition.

v1 versioning: Mobile paths currently un-versioned. Backend Phase 3 (per api-versioning-v1-migration-plan.md) will add /v1/* aliases — not yet executed. Document tracks current production paths. After Phase 3, paths gain /v1/ segment.


title: Merchant Mobile API Contract

1. General Conventions​

1.1 Base URL​

Mobile clients hit the production gateway which strips the /merchant/api prefix before forwarding to merchant_core_api:

EnvironmentClient base URLBackend receives
Productionhttps://kesles.com/merchant/apipath appended after strip
Local devhttp://localhost:8080path as-is

Per-endpoint paths in §2 are written as the backend handler registers them (e.g. /merchant/transactions). Mobile client constructs the full URL: {base_url}/merchant/transactions.

1.2 Authentication​

Mobile auth uses two interchangeable channels via the /auth/* endpoints in merchant_core_api (NOT the dashboard auth endpoint).

Channel A — WhatsApp / Email OTP (default):

  1. POST /auth/resolve-phone → resolve phone number
  2. POST /auth/request-otp → kirim OTP (WhatsApp/Email)
  3. POST /auth/verify-otp → verify code + return access token
  4. POST /auth/refresh-token → renew expired token

Channel B — Firebase Phone Auth (SMS via Google):

Status timeline:

  • Backend (Phase 0 firebase_service + Phase 1 Deliverable A+B core_api): ✅ done 2026-05-20
  • Mobile Mode A registrasi (Android): ✅ done 2026-05-20
  • Mobile iOS: ⏳ deferred Phase 1.5 (butuh URL Type entitlement + reCAPTCHA fallback verify)
  • Mode B auto-fallback signal (Deliverable C): ⏳ deferred (touch existing OTP critical path, butuh feature flag)
  • Switch-channel endpoint (Deliverable D): ⏳ deferred (depends on C)

Flow:

  1. Mobile call FirebaseAuth.instance.verifyPhoneNumber(phone) → Google kirim SMS / instant approve untuk Test Phone Number
  2. Best case Android: callback verificationCompleted(credential) auto-fire (SMS Retriever) → signInWithCredential → user.getIdToken() → tanpa user input OTP
  3. Fallback Android: callback codeSent(verificationId, _) → navigate OtpPage(firebaseVerificationId) → user input 6 digit → PhoneAuthProvider.credential(verificationId, smsCode) → signInWithCredential → user.getIdToken()
  4. POST /auth/firebase-phone-verify dengan body {id_token, ...} → backend verify via services/firebase_service (Google JWKS signature check + claim validation) → return access token (sama shape dengan /auth/verify-otp)
  5. POST /auth/refresh-token (sama dengan Channel A)

Channel B dipakai untuk:

  • Mode A registrasi — UI Mode A VerificationMethodPage tampil 2 tombol (WhatsApp + SMS). User tap SMS → trigger Channel B flow di atas. ✅ aktif Android 2026-05-20.
  • Mode B login auto-fallback — kalau primary channel (WA/Email) gagal kirim, backend signal mobile via use_firebase_phone_auth: true di response /auth/request-otp (deferred — Deliverable C plan §7.1)
  • Mode B login manual switch — user click "Coba metode lain" di OtpPage (deferred — Deliverable D)

Mobile error handling (Channel B):

  • FirebaseAuthException di-route ke 4 user-facing message generik (network-request-failed, invalid-phone-number, default-fallback) + tombol snackbar action "Pakai WhatsApp" untuk 1-tap fallback.
  • Quota / billing / internal Firebase issue TIDAK dibocorkan ke user — full error code di-log ke Crashlytics. Admin lihat via Firebase Console > Authentication > Usage + Crashlytics dashboard.

iOS guard sementara: Platform.isIOS di mobile → snackbar "SMS belum tersedia di iOS. Silakan gunakan WhatsApp." + tombol fallback. Lift saat iOS dev setup selesai.

Plan: sms-otp-service-plan.md (notification site → firebase).

Authenticated endpoints require Bearer token:

Authorization: Bearer <jwt_token>

JWT algorithm: HS256. JWT issuer: kesles-merchant-auth (auth_service, post extraction 2026-05-30).

JWT claims (current):

  • sub — user_id (uuid — identity.users di db_kesles_merchant_auth)
  • merchant_id — resolved from merchant.merchant_users link
  • iss — kesles-merchant-auth

DB source note: iam.users di db_kesles_merchant sudah DROPPED (mig v1/057, 2026-05-30). auth_service (port 8081) adalah sole source of truth untuk semua data user/session. JWT diissue oleh auth_service dan divalidasi oleh core_api via shared HMAC secret.

Deferred claims: role (owner/manager/staff) + outlet_id are part of the multi-outlet design (proposal at merchant_docs/docs/database/plans/multi-outlet-enterprise-schema-proposal.md) — not yet in JWT today. Single-user-per-merchant model masih jalan.

HTTP timeout tiers (AppHttpClient — RetryPolicy):

  • Default JSON: 30 seconds
  • Upload (KTP, foto, payment proof): 60 seconds
  • Avatar upload: 90 seconds
  • RetryPolicy: GET-only, aktif di 12 service. POST/PATCH/DELETE tidak di-wrap RetryPolicy.

1.3 Role-Based Access Matrix​

Deferred — design only. Multi-user per merchant + outlet-scoped access is part of the multi-outlet schema proposal (merchant_docs/docs/database/plans/multi-outlet-enterprise-schema-proposal.md). Current implementation: 1 user = 1 merchant, no outlet scoping.

Target matrix (post multi-outlet implementation):

EndpointOwnerManagerStaff
GET /transactionsAll outletsAssigned outletAssigned outlet (today only)
GET /revenue/*YesAssigned outletAssigned outlet (today)
POST/PATCH /outletsYesNoNo
GET /outletsYesYesYes (read-only)
POST /users/inviteYesNoNo
DELETE /users/{id}YesNoNo
GET /settlementsYesYes (without MDR detail)No
GET /reports/*YesYesNo
/notifications/*YesYesYes (own only)

1.4 Error Response Format​

All errors follow a consistent shape:

{
"error": "error_code_snake_case",
"message": "Human-readable message (Bahasa Indonesia)",
"details": { "optional": "additional context" }
}

1.5 Standard HTTP Status Codes​

CodeCondition
200OK (GET success, PATCH success)
201Created (POST success)
204No Content (DELETE success)
400Bad Request (invalid JSON, missing required fields)
401Unauthorized (token invalid/expired)
403Forbidden (role does not have access)
404Not Found
409Conflict (duplicate name, etc)
422Unprocessable Entity (validation failed)
429Too Many Requests (rate limit)
500Internal Server Error

1.6 Error Code Reference​

CodeMeaning
unauthorizedMissing/invalid token
token_expiredJWT expired, client must refresh
forbiddenRole does not have access
forbidden_not_ownerOnly owner role allowed
forbidden_outlet_scopeUser outside their outlet scope
not_foundResource does not exist
validation_failedInput failed validation
duplicate_resourceUnique constraint violation
subscription_expiredTrial ended / not yet paid
rate_limit_exceededToo many requests
internal_errorServer error

1.7 Pagination​

List endpoints use cursor-based pagination for performance over large datasets.

Request:

GET /api/merchant/transactions?limit=50&cursor=eyJpZCI6...

Response:

{
"items": [...],
"pagination": {
"next_cursor": "eyJpZCI6...",
"has_more": true,
"total_count": 1247
}
}
  • limit: default 20, max 100
  • cursor: opaque string (base64 JSON)
  • has_more: false on the last page
  • total_count: optional, expensive for large data → set include_total=true when needed

1.8 Date & Time​

  • All datetimes use ISO 8601 UTC (2026-05-23T14:23:45Z)
  • The client is responsible for converting to the user's timezone
  • Date-only uses YYYY-MM-DD (without timezone)
  • Time-only uses HH:MM:SS

1.9 Money / Amount​

  • Pakai pola amount (integer, smallest unit) + currency (string ISO 4217) — bukan suffix _idr di nama field. Konsisten dengan migration 076/077 (semua kolom DB drop suffix _idr + tambah kolom currency).
  • Default currency = "IDR" saat ini; field tetap di-return supaya partner / mobile client siap multi-currency (QRIS Cross-border SGD/THB/MYR future).
  • IDR = unit terkecil rupiah utuh, no decimal. Untuk currency dengan minor unit (USD, SGD), amount adalah cents (×100).
  • Tipe: int64 only — jangan strings, jangan float.

1.10 Rate Limiting​

  • Default per user: 100 req/minute for GET endpoints, 20 req/minute for POST/PATCH/DELETE
  • Heavy endpoints (reports, charts): 10 req/minute
  • Response header:
    X-RateLimit-Limit: 100
    X-RateLimit-Remaining: 45
    X-RateLimit-Reset: 1716452580

1.11 Request ID Tracing​

The client must set the X-Request-ID header on every request for tracing:

X-Request-ID: <uuid-v4-generated-by-client>

The server echoes it back in the response header + logs it in Sentry.


2. Implementation Status (Audited 2026-06-03)​

This document is partially aspirational. Backend code in merchant_core_api/internal/httpapi/ is the source of truth.

2.0 Currently implemented endpoints​

All paths below are registered in the backend and reachable in production. Production base: https://kesles.com/merchant/api (gateway strips /merchant/api/ before forwarding).

Auth (routes_auth.go)​

MethodPathHandlerAuth
POST/auth/resolve-phonehandleResolvePhonepublic
POST/auth/request-otphandleRequestOTPpublic
POST/auth/verify-otphandleVerifyOTPpublic
POST/auth/firebase-phone-verifyhandleFirebasePhoneVerifypublic (Firebase Phone Auth — Phase 0 done 2026-05-20)
POST/auth/refresh-tokenhandleRefreshTokenpublic (uses refresh token)
GET/auth/device-login/cancelhandleCancelDeviceLoginpublic
POST/auth/push-tokenshandleRegisterPushTokenbearer
GET/auth/profilehandleProfilebearer
POST/auth/profile/email/request-otphandleRequestEmailOTPbearer
POST/auth/profile/email/verify-otphandleVerifyEmailOTPbearer
POST/profile/avatarhandleUploadProfileAvatarbearer (multipart)

Merchant (routes_merchant.go)​

MethodPathHandlerNotes
POST/merchant/registrationhandleCreateMerchantRegistrationinitial registration
GET/POST/merchant/registration/drafthandleCreateOrGetRegistrationDraftdraft persistence
POST/merchant/registration/photohandleUploadMerchantRegistrationPhotoKYC photo upload
GET/merchant/registration/device-offerhandleGetActiveMerchantRegistrationDeviceOfferdevice package offer
GET/merchant/shipping-previewhandleGetMerchantShippingPreviewpreview ongkir tanpa create order
POST/merchant/sales-orderhandleCreateMerchantSalesOrdercreate sales order (device purchase)
GET/merchant/sales-ordershandleListMerchantSalesOrderslist with status_group query
GET/merchant/sales-order/currenthandleGetCurrentSalesOrderactive order
POST/merchant/sales-order/payment-proofhandleUploadMerchantPaymentProofupload bukti transfer — multipart, max 200 KB, lihat §3
GET/merchant/sales-order/{id}handleGetMerchantSalesOrderByIDcatch-all detail by ID
GET/merchant/payment-destinationhandleGetMerchantPaymentDestinationrekening tujuan transfer
GET/merchant/payment-destinationshandleListMerchantPaymentDestinationslist rekening kandidat — termasuk QRIS korporat dari payment.qris_config (payment-service, Flow B)
GET/merchant/shipping-addresshandleGetMerchantShippingAddressalamat kirim default
GET/merchant/shipping-ratehandleGetMerchantShippingRatetarif ongkir current
POST/merchant/feedbackhandleCreateMerchantFeedbackuser feedback
GET/merchant/home-dashboardhandleGetMerchantHomeDashboardhome screen aggregate
GET/merchant/transactionshandleGetMerchantTransactionslist transaksi Flow A (customer→merchant) — lihat §2.1 for response shape design
GET/merchant/payment-terminalhandleGetMerchantPaymentTerminaldevice info
POST/merchant/payment-terminal/confirm-receipthandleConfirmTerminalReceiptkonfirmasi terima device
POST/merchant/terminals/pairhandleMerchantTerminalPairpair device ke merchant
GET/merchant/contract-summaryhandleGetMerchantContractSummarysummary kontrak / billing
GET/merchant/outletshandleGetMerchantOutletsoutlet list (read-only; multi-outlet Phase 3.1)
GET/merchant/outlets/{id}handleGetMerchantOutletByIDoutlet detail
GET/merchant/outlets/{id}/deviceshandleGetMerchantOutletDevicesdevices per outlet

/merchant/payment-destinations — QRIS context: Endpoint ini return daftar rekening + QRIS tujuan pembayaran untuk Flow B (merchant beli produk Kesles). QRIS korporat Kesles diambil dari payment.qris_config singleton di db_payment (NMID: ID9999999999999, PT Inti Kesles Nusantara). Berbeda dengan QRIS per-merchant (Flow A) yang NMID-nya ada di merchant.merchants.nmid di db_kesles_merchant.

Public (routes_public.go)​

MethodPathHandler
GET/public/legal-documentshandleGetLegalDocument

UMKM Academy endpoints (/public/umkm-academy/*) are served by content_service (db_kesles_merchant_content), not merchant_core_api. They are out of scope for this contract.

References (routes_reference.go)​

MethodPathHandler
GET/references/provinceshandleListProvinces
GET/references/citieshandleListCities
GET/references/districtshandleListDistricts
GET/references/subdistrictshandleListSubdistricts
GET/references/postal-codeshandleListPostalCodes
GET/references/business-scaleshandleListBusinessScales
GET/references/business-typeshandleListBusinessTypes

2.0.1 Deferred — designed but NOT implemented​

The following endpoints are described in §2.1–§2.9 below as design specs, but no backend handler exists as of 2026-06-03. They depend on the multi-outlet schema model (proposal at merchant_docs/docs/database/plans/multi-outlet-enterprise-schema-proposal.md) and the multi-user role model — both pending product approval.

  • GET /merchant/transactions/{id} — transaction detail
  • GET /merchant/revenue/summary — revenue aggregate
  • GET /merchant/revenue/chart — revenue time series
  • POST /merchant/outlets, PATCH /merchant/outlets/{id}, DELETE /merchant/outlets/{id} — outlet write CRUD (read endpoints GET /merchant/outlets, GET /merchant/outlets/{id}, GET /merchant/outlets/{id}/devices are now implemented — see §2.0)
  • GET /merchant/settlements — settlement listing
  • GET /merchant/reports/{type} — report exports
  • GET /merchant/users, POST /merchant/users/invite, PATCH /merchant/users/{id}/role, DELETE /merchant/users/{id} — multi-user mgmt
  • POST /merchant/notifications/register-token, DELETE /merchant/notifications/tokens/{token_id}, GET /merchant/notifications/settings, PATCH /merchant/notifications/settings — these exact paths are not implemented. The shipped equivalents are: POST /auth/push-tokens (FCM token register), GET /merchant/me/notifications + PATCH /merchant/me/notifications/{event_type} (per-merchant per-event prefs), and GET/PATCH /me/notification-prefs (per-user global prefs, proxied to firebase_service).
  • POST /merchant/terminals/{terminal_code}/keypad-amount — virtual keypad

The detailed design for these endpoints is preserved in §2.1–§2.9 below for future implementation reference.


2.x Designed but deferred — for future implementation​

Sections below document design intent for endpoints listed in §2.0.1 as deferred. Treat as design reference, not as currently-callable contract. When implementation begins, validate each spec against final multi-outlet schema (it may need adjustment).

2.1 Transactions​

GET /api/merchant/transactions​

List transactions with filter + pagination.

Auth: Required (any role)

Query params:

ParamTypeRequiredDefaultDescription
fromdateNotodayStart date filter (inclusive)
todateNotodayEnd date filter (inclusive)
outlet_iduuidNoallFilter by outlet. Staff is auto-locked to their outlet
amount_minintNo-Minimum amount filter (IDR)
amount_maxintNo-Maximum amount filter (IDR)
statusstringNosuccesssuccess, refunded, all
searchstringNo-Search by reference number / customer name
limitintNo20Max 100
cursorstringNo-Pagination cursor

Response 200:

{
"items": [
{
"id": "8b3a2c1f-...",
"transaction_at": "2026-05-23T14:23:45Z",
"amount": 75000,
"currency": "IDR",
"mdr_fee": 525,
"net_amount": 74475,
"reference_number": "BI20260523142345XXXX",
"customer_name": "Budi S.",
"payer_bank": "BCA",
"outlet_id": "a1b2...",
"outlet_name": "Toko Pusat Makassar",
"device_serial": "QRISPLUS-M1-0042",
"settlement_status": "pending",
"settlement_date": null,
"status": "success"
}
],
"pagination": {
"next_cursor": "eyJ...",
"has_more": true,
"total_count": 1247
},
"summary": {
"total_amount": 5240000,
"transaction_count": 34,
"avg_amount": 154117
}
}

Errors:

  • 422 validation_failed — from > to, negative filter values
  • 403 forbidden_outlet_scope — staff requesting another outlet

Notes:

  • Transactions listed here are Flow A only (customer bayar ke merchant via QRIS) — data source: payment.transactions di db_payment.
  • Staff is automatically filtered to their outlet + today only
  • Manager may use any date within their assigned outlet

GET /api/merchant/transactions/{id}​

Transaction detail.

Path params:

  • id — transaction uuid

Response 200:

{
"item": {
"id": "8b3a2c1f-...",
"transaction_at": "2026-05-23T14:23:45Z",
"amount": 75000,
"mdr_total": 525,
"mdr_fee_kesles": 225,
"mdr_fee_bank": 300,
"net_amount": 74475,
"reference_number": "BI20260523142345XXXX",
"rrn": "301234567890",
"customer_name": "Budi S.",
"customer_phone_masked": "08**-****-1234",
"payer_bank": "BCA",
"payer_bank_code": "014",
"outlet_id": "a1b2...",
"outlet_name": "Toko Pusat Makassar",
"device_serial": "QRISPLUS-M1-0042",
"device_id": "c3d4...",
"settlement_status": "pending",
"settlement_expected_date": "2026-05-24",
"settlement_actual_date": null,
"settlement_batch_id": null,
"status": "success",
"meta": {
"qris_merchant_id": "ID102326...",
"qris_merchant_criteria": "regular"
}
},
"meta": {
"can_view_mdr": true
}
}

Errors:

  • 404 not_found — transaction does not exist or is outside the user's scope
  • 403 forbidden_outlet_scope — transaction in another outlet

Notes:

  • Manager does not see mdr_fee_* (meta.can_view_mdr = false)
  • Staff cannot access transaction detail older than 7 days

2.2 Revenue​

GET /api/merchant/revenue/summary​

Aggregate revenue summary for the dashboard home screen.

Query params:

ParamTypeRequiredDefaultDescription
periodenumNotodaytoday, yesterday, week, month, custom
fromdateOnly if custom-Custom date range
todateOnly if custom-Custom date range
outlet_iduuidNoallOutlet filter
compareboolNotrueInclude comparison vs previous period

Response 200:

{
"period": {
"label": "today",
"from": "2026-05-23T00:00:00Z",
"to": "2026-05-23T23:59:59Z"
},
"summary": {
"currency": "IDR",
"revenue": 3240000,
"transaction_count": 48,
"avg_ticket": 67500,
"peak_hour": 12,
"peak_hour_count": 14,
"unique_customer_estimate": 42
},
"comparison": {
"period_label": "yesterday",
"revenue_delta_pct": 12.5,
"transaction_delta_pct": 8.3,
"direction": "up"
},
"outlets_breakdown": [
{
"outlet_id": "a1b2...",
"outlet_name": "Toko Pusat Makassar",
"revenue": 1850000,
"transaction_count": 26,
"share_pct": 57.1
}
],
"generated_at": "2026-05-23T15:30:00Z"
}

Notes:

  • This is the most frequently hit endpoint (dashboard refresh every 30s) — server-side cached for 30 seconds
  • outlets_breakdown is included only for multi-outlet merchants (≥2 outlets)

GET /api/merchant/revenue/chart​

Data points for the revenue chart.

Query params:

ParamTypeRequiredDefaultDescription
periodenumYes-7d, 30d, 90d, 12m
granularityenumNoautohour, day, week, month
outlet_iduuidNoallOutlet filter
group_by_outletboolNofalseReturn breakdown per outlet (stacked chart)

Response 200:

{
"period": "7d",
"granularity": "day",
"currency": "IDR",
"points": [
{
"timestamp": "2026-05-17T00:00:00Z",
"label": "Mon",
"revenue": 2340000,
"transaction_count": 34
},
{
"timestamp": "2026-05-18T00:00:00Z",
"label": "Tue",
"revenue": 2890000,
"transaction_count": 42
}
],
"stats": {
"total": 18420000,
"avg": 2631428,
"max": 3240000,
"max_at": "2026-05-23T00:00:00Z"
}
}

Stacked chart (when group_by_outlet=true):

{
"points": [
{
"timestamp": "2026-05-17T00:00:00Z",
"label": "Mon",
"by_outlet": [
{ "outlet_id": "a1b2...", "outlet_name": "Pusat", "revenue": 1500000 },
{ "outlet_id": "c3d4...", "outlet_name": "Cabang", "revenue": 840000 }
]
}
]
}

Notes:

  • granularity=auto picks the optimal value: 7d→day, 30d→day, 90d→week, 12m→month
  • Tighter rate limit (10 req/minute) due to heavy queries

2.3 Outlets​

GET /api/merchant/outlets​

List the merchant's outlets.

Response 200:

{
"items": [
{
"id": "a1b2...",
"outlet_name": "Toko Pusat Makassar",
"address_line": "Jl. Deng Ramang Perumahan The Victoria Residence B15",
"city": "Makassar",
"province": "Sulawesi Selatan",
"is_primary": true,
"device_count": 2,
"active_user_count": 3,
"created_at": "2026-04-10T08:00:00Z"
}
],
"meta": {
"total_count": 3,
"can_create": true,
"max_allowed": 10
}
}

POST /api/merchant/outlets​

Create a new outlet.

Auth: Owner only

Request body:

{
"outlet_name": "Cabang Gowa",
"address_line": "Jl. Pallangga No. 45",
"city": "Gowa",
"province": "Sulawesi Selatan",
"is_primary": false
}

Validation:

  • outlet_name: required, 3-150 chars, unique per merchant
  • address_line: optional, max 500 chars
  • city, province: optional, max 100 chars
  • is_primary: when true, other outlets are auto-set to false

Response 201:

{
"item": {
"id": "new-uuid-...",
"outlet_name": "Cabang Gowa",
"...": "..."
}
}

Errors:

  • 403 forbidden_not_owner
  • 409 duplicate_resource — name already in use
  • 422 validation_failed — empty name, invalid city
  • 422 max_outlets_exceeded — already 10 outlets (MVP limit)

PATCH /api/merchant/outlets/{id}​

Update an outlet.

Auth: Owner only

Request body (all optional):

{
"outlet_name": "Cabang Gowa (Updated)",
"address_line": "...",
"city": "...",
"province": "...",
"is_primary": true
}

Response 200: Same shape as the POST response

Errors:

  • 404 not_found
  • 403 forbidden_not_owner
  • 409 duplicate_resource

DELETE /api/merchant/outlets/{id}​

Soft-delete an outlet (only when there is no active device/user).

Auth: Owner only

Response 200:

{
"deleted": true,
"id": "a1b2..."
}

Errors:

  • 404 not_found
  • 403 forbidden_not_owner
  • 422 outlet_has_active_devices — detach the device first
  • 422 outlet_is_primary — set another outlet as primary first

2.4 Settlements​

GET /api/merchant/settlements​

List settlements (pending + completed).

Query params:

ParamTypeDefaultDescription
statusenumallpending, completed, all
fromdate30 days ago
todatetoday
outlet_iduuidall
limitint20
cursorstring-

Response 200:

{
"items": [
{
"id": "sett-uuid-...",
"batch_number": "SETT-20260523-001",
"status": "pending",
"expected_date": "2026-05-24",
"completed_date": null,
"transaction_count": 48,
"gross_amount": 3240000,
"mdr_total": 22680,
"net_amount": 3217320,
"bank_name": "BCA",
"bank_account_masked": "1234-****-5678",
"transaction_date_range": {
"from": "2026-05-23T00:00:00Z",
"to": "2026-05-23T23:59:59Z"
},
"is_overdue": false
}
],
"pagination": { "...": "..." },
"summary": {
"currency": "IDR",
"pending_total": 3240000,
"pending_count": 2,
"overdue_count": 0
}
}

Notes:

  • is_overdue = true when expected_date has passed by >1 day
  • Manager does not see mdr_total (depending on merchant setting)

2.5 Reports​

GET /api/merchant/reports/{type}​

Generate a PDF/CSV/Excel report.

Path params:

  • type — enum: daily, weekly, monthly, custom

Query params:

ParamTypeRequiredDescription
fromdateRequired (for custom)Date range
todateRequired (for custom)Date range
formatenumNo, default pdfpdf, csv, excel
outlet_iduuidNoFilter
include_detailboolNo, default trueInclude per-transaction detail

Response 200: The response body is a binary file. Content-Type matches the format:

  • application/pdf
  • text/csv
  • application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

Response headers:

Content-Type: application/pdf
Content-Disposition: attachment; filename="kesles-daily-20260523.pdf"
X-Report-Generated-At: 2026-05-23T15:30:00Z

Alternative: async generation for large ranges

If the range exceeds 90 days or estimated size > 5MB:

Response 202 (Accepted):

{
"job_id": "job-uuid-...",
"status": "queued",
"estimated_ready_at": "2026-05-23T15:32:00Z",
"poll_url": "/api/merchant/reports/jobs/job-uuid-..."
}

The client polls GET /api/merchant/reports/jobs/{job_id}:

{
"job_id": "...",
"status": "completed", // queued | processing | completed | failed
"download_url": "https://cdn.kesles.id/reports/...pdf",
"expires_at": "2026-05-24T15:30:00Z"
}

Errors:

  • 422 date_range_too_large — max 365 days
  • 422 invalid_format — format not supported
  • 403 forbidden — staff cannot export

Rate limit: 10 req/minute per user (expensive endpoint)


2.6 Users (Multi-User / Role Management)​

GET /api/merchant/users​

List all merchant users.

Auth: Owner or Manager

Response 200:

{
"items": [
{
"id": "user-uuid-...",
"full_name": "Budi Santoso",
"email": "budi@example.com",
"phone_masked": "08**-****-5678",
"role": "owner",
"outlet_id": null,
"outlet_name": null,
"status": "active",
"invited_by_name": null,
"invited_at": null,
"accepted_at": "2026-04-10T08:00:00Z",
"last_login_at": "2026-05-23T09:15:00Z"
},
{
"id": "user-uuid-2",
"full_name": "Ani Wulandari",
"email": "ani@example.com",
"phone_masked": "08**-****-1234",
"role": "manager",
"outlet_id": "a1b2...",
"outlet_name": "Cabang Gowa",
"status": "invited",
"invited_by_name": "Budi Santoso",
"invited_at": "2026-05-20T10:00:00Z",
"accepted_at": null,
"last_login_at": null
}
],
"meta": {
"can_invite": true,
"can_manage_roles": true
}
}

POST /api/merchant/users/invite​

Invite a new staff member.

Auth: Owner only

Request body:

{
"email": "staff@example.com",
"phone": "081234567890",
"full_name": "Ani Wulandari",
"role": "manager",
"outlet_id": "a1b2...",
"send_via": "whatsapp"
}

Validation:

  • At least one of: email or phone
  • role: enum manager, staff (owner cannot be invited)
  • outlet_id: required for staff, optional for manager (null = all outlets)
  • send_via: enum email, whatsapp, sms. Default based on available contact

Response 201:

{
"item": {
"id": "user-uuid-...",
"status": "invited",
"invite_token_masked": "abc****xyz",
"invite_expires_at": "2026-05-27T10:00:00Z",
"invite_link": "https://merchant.kesles.id/accept?token=..."
}
}

Errors:

  • 422 email_and_phone_both_empty
  • 409 user_already_member — email/phone already registered to this merchant
  • 422 outlet_required_for_staff

PATCH /api/merchant/users/{id}/role​

Update a user's role.

Auth: Owner only

Request body:

{
"role": "manager",
"outlet_id": "a1b2..."
}

Errors:

  • 422 cannot_change_owner_role — owner cannot be changed
  • 422 cannot_demote_last_manager — when the outlet requires at least 1 manager

DELETE /api/merchant/users/{id}​

Remove a user from the merchant.

Auth: Owner only

Response 200:

{
"deleted": true,
"id": "user-uuid-..."
}

Errors:

  • 422 cannot_delete_self
  • 422 cannot_delete_owner — transfer ownership first (out of MVP scope)

Notes:

  • Soft-delete in merchant.merchant_app_users (set deleted_at)
  • The user account in identity.users (db_kesles_merchant_auth) stays — bisa jadi member merchant lain

2.7 Notifications​

POST /api/merchant/notifications/register-token​

Register an FCM/APN token at app login.

Request body:

{
"token": "dFGsd3f4g...fcm-token-here",
"platform": "android",
"app_version": "1.0.0",
"device_model": "Samsung Galaxy A52"
}

Validation:

  • token: required, max 512 chars
  • platform: enum android, ios
  • app_version: optional, semver

Response 201:

{
"item": {
"id": "token-uuid-...",
"registered_at": "2026-05-23T15:30:00Z"
}
}

Notes:

  • If the token already exists for this user → update last_used_at, return 200 (not 201)
  • Stale tokens (not updated for > 30 days) are auto-cleaned up server-side
  • Token stored in notification.fcm_push_tokens di db_kesles_merchant_notification (sole SOT sejak mig 040)

DELETE /api/merchant/notifications/tokens/{token_id}​

Unregister a token (on logout or uninstall detection).

Response 200:

{ "deleted": true }

GET /api/merchant/notifications/settings​

Get the user's notification preferences.

Response 200:

{
"item": {
"user_id": "user-uuid-...",
"transaction_notif": true,
"silent_hours_start": "22:00:00",
"silent_hours_end": "06:00:00",
"anomaly_notif": true,
"daily_digest": false,
"min_amount_notif": 0,
"updated_at": "2026-05-20T09:00:00Z"
}
}

Field semantics:

  • transaction_notif: master switch for transaction notifications
  • silent_hours_start/end: time range during which notifications are muted (on the device, not server-side)
  • anomaly_notif: P1 feature (smart alerts)
  • daily_digest: P1 feature (morning summary)
  • min_amount_notif: when >0, only notify for transactions above the threshold

PATCH /api/merchant/notifications/settings​

Update notification preferences.

Request body (all optional):

{
"transaction_notif": true,
"silent_hours_start": "22:00:00",
"silent_hours_end": "06:00:00",
"anomaly_notif": false,
"daily_digest": true,
"min_amount_notif": 10000
}

Response 200: Same as GET


2.8 Sales Orders (Device Order)​

Endpoints for the Order + Payment pages in mobile_user. An order = a device package order for QRIS Plus / accessories (multi-line). State machine: pending → accepted → payment_received → processing → completed (cancelled from any non-terminal status).

DB note: Sales order data tersimpan di db_kesles_merchant_order (order_service, port 8083). Payment lifecycle per order tersimpan di orders.sales_order_payments (mig 004) — ini adalah Flow B (merchant beli produk Kesles), terpisah dari payment.transactions yang merupakan Flow A (customer bayar ke merchant).

GET /api/merchant/sales-orders?status_group=<group>&limit=100​

List orders belonging to the user's active merchant. status_group (optional, default semua (all)):

  • semua (all) / belum_bayar (unpaid) / dikemas (packed) / dikirim (shipped) / selesai (completed) / dibatalkan (cancelled) / pengembalian_dana (refunded — currently returns empty; the schema does not yet have a refunded status)

Response:

{
"items": [
{
"id": "uuid",
"order_number": "SO-0000000000000067",
"status": "pending",
"subtotal_amount": 420000,
"shipping_fee_amount": 30000,
"promo_amount": 850000,
"total_amount": 466200,
"payment_proof_url": "https://...",
"created_at": "2026-05-02T16:58:00Z",
"updated_at": "...",
"total_quantity": 3,
"device_summary": "QRIS Plus, QRIS Statis ACR-1, QRIS Statis Sticker S01",
"items_summary": [
{
"line_no": 1,
"device_name": "QRIS Plus",
"quantity": 1,
"unit_price_amount": 1200000,
"promo_amount": 850000,
"line_total_amount": 388500
}
]
}
]
}

items_summary[].promo_amount = sales_quotations.promo_amount × quantity (per-line total, not per-unit). Mobile uses this to render the strikethrough of the normal price + the after-promo price. device_name is resolved from payment_device_models.product_model_name (live SOT) with a fallback to sales_quotations.device_name (snapshot).

GET /api/merchant/sales-order/{id}/items​

Per-line detail for the Payment page (image, description, price, qty).

Response:

{
"items": [
{
"line_no": 1,
"device_name": "QRIS Plus",
"description": "Dual QR Display ...",
"image_url": "https://.../qris-plus.png",
"quantity": 1,
"unit_price_amount": 1200000,
"promo_amount": 850000,
"line_total_amount": 388500
}
]
}

PATCH /api/merchant/sales-order/{id}/items/{line_no}​

Update qty for a single line. Only allowed when the order status = pending.

Body: {"quantity": 2} (1–99)

Response 200: items array (re-fetched) + header (subtotal/promo/ shipping/total recomputed).

DELETE /api/merchant/sales-order/{id}/items/{line_no}​

Delete a single line item. Mobile uses this when the user taps minus on a line with qty=1 + confirmation popup. Only allowed when status = pending. The header is recomputed.

Response 200: Same shape as PATCH.

DELETE /api/merchant/sales-order/{id}​

Delete the entire order (soft-cancel). Mobile uses this when the user removes the LAST item of the order — instead of per-line delete, the order is fully cancelled, so the audit trail stays intact.

Body:

{
"reasons": ["change_quantity", "wrong_product", "change_address",
"price_too_high", "not_buying", "other"],
"reason_note": "Optional free-text. Required if reasons contains 'other'."
}

Stored in the sales_orders.metadata.cancellation JSONB. Status is changed to cancelled (not hard-deleted).


2.9 Virtual Keypad → Device​

POST /api/merchant/terminals/{terminal_code}/keypad-amount​

Send the transaction amount from the mobile virtual keypad to a QRIS Plus device (e.g. Aisino Q161). The backend forwards to the device via push channel — the device displays the amount & QR as if the operator pressed the physical keypad. Purpose: reduce hardware keypad wear.

Body: {"amount": 15000} — Rupiah integer, min 1000.

Response 200: {"sent_at": "2026-05-03T10:00:00Z"} (TBD — backend endpoint is still TODO; mobile already calls the sendKeypadAmount method).


2.x OpenAPI Spec (Draft)​

Section 2.8 (Sales Orders) + 2.9 (Keypad) have a formal OpenAPI 3.0.3 schema in openapi-draft.yaml — can be imported into Postman or used for client code-gen.


3. Subscription & Billing Aware​

Every endpoint must return 402 Payment Required when:

  • The 30-day trial has ended and the user has not subscribed
  • The subscription has expired (daily fee unpaid > 7 days)

Response 402:

{
"error": "subscription_expired",
"message": "Your subscription has ended. Please renew your payment.",
"details": {
"trial_ended_at": "2026-06-23T00:00:00Z",
"last_payment_date": null,
"grace_period_days_remaining": 0,
"renew_url": "https://merchant.kesles.id/billing"
}
}

Exempt endpoints (still accessible while expired):

  • GET /api/merchant/notifications/settings (the user can still change settings)
  • POST /api/merchant/notifications/register-token (so billing notifications still come through)
  • Billing endpoints (out of scope for this spec)

4. Webhooks (Backend Internal)​

Not part of the API consumed by the mobile app, but worth mentioning because it is on the critical path for F1 (real-time notifications).

4.1 Incoming Webhook: Bank Acquirer → Kesles​

The existing endpoint /api/dashboard/partner-webhooks receives the bank callback when QRIS is settled.

Flow after the webhook is received:

  1. Bank callback → dashboard-api /api/dashboard/partner-webhooks
  2. dashboard-api inserts into payment.transactions di db_payment (Flow A — customer bayar ke merchant)
  3. dashboard-api triggers internal event transaction.created
  4. The event handler resolves notification.fcm_push_tokens (db_kesles_merchant_notification) for the owner + active users in the outlet
  5. The event handler calls FCM API via firebase_service for the push notification
  6. Response to the bank: 200 OK (when DB insert succeeds)

SLA: webhook-to-push < 3 seconds


5. Per-Endpoint cURL Examples​

# 1. List today's transactions
curl -H "Authorization: Bearer $TOKEN" \
"https://kesles.com/merchant/api/transactions?limit=20"

# 2. Revenue dashboard summary
curl -H "Authorization: Bearer $TOKEN" \
"https://kesles.com/merchant/api/revenue/summary?period=today&compare=true"

# 3. Revenue chart, 7 days
curl -H "Authorization: Bearer $TOKEN" \
"https://kesles.com/merchant/api/revenue/chart?period=7d"

# 4. Create outlet
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"outlet_name":"Cabang Gowa","city":"Gowa","province":"Sulawesi Selatan"}' \
"https://kesles.com/merchant/api/outlets"

# 5. Download daily report
curl -H "Authorization: Bearer $TOKEN" \
-o report.pdf \
"https://kesles.com/merchant/api/reports/daily?format=pdf&from=2026-05-23&to=2026-05-23"

# 6. Invite staff
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"ani@example.com","phone":"081234567890","full_name":"Ani W","role":"manager","outlet_id":"a1b2...","send_via":"whatsapp"}' \
"https://kesles.com/merchant/api/users/invite"

# 7. Register FCM token
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"token":"dFGsd...","platform":"android","app_version":"1.0.0"}' \
"https://kesles.com/merchant/api/notifications/register-token"

6. Versioning & Compatibility​

6.1 API Versioning​

Current state (2026-06-03): Mobile paths are un-versioned (/auth/*, /merchant/*, /public/*, /references/*).

Migration plan: Add /v1/* aliases as part of api-versioning-v1-migration-plan.md Phase 3. Once aliases are live, mobile clients gradually migrate per module. After all modules migrated + ≥2 weeks stable, the un-versioned paths are removed.

Target post-Phase 3:

CallerPath
Mobile (current)/merchant/transactions
Mobile (post-Phase 3)/v1/merchant/transactions
Backend handler registerboth (alias paralel)

PSP and Partner already on /api/psp/v1/* and /api/partner/v1/* (Phase 2 done). Mobile + dashboard SPA pending Phase 3.

Versioning policy (after Phase 3): Additive changes (new fields, new endpoints) do not require a new version. Breaking changes (remove field, change type, change auth) require a new major version (/v2/*) with ≥6-month deprecation notice for mobile clients.

6.2 Client Version Compatibility​

The server checks the X-App-Version header from the client:

X-App-Version: 1.0.0
X-Platform: android

Response headers when there is an important update:

X-Client-Min-Version: 1.0.0
X-Client-Update-Available: 1.2.0
X-Client-Update-Required: false

When update_required=true, the client must force the user to update before using the app.


7. Testing & Staging​

7.1 Staging Data​

Staging uses synthetic datasets:

  • 3 test merchants (merchant_test_small, merchant_test_medium, merchant_test_large)
  • Each merchant has 1-5 outlets
  • Random transactions over the last 30 days
  • Universal password: Test1234! (internal use only)

7.2 Health Check​

GET /api/merchant/health

Auth not required:

{
"status": "ok",
"version": "1.0.0",
"timestamp": "2026-05-23T15:30:00Z"
}

7.3 Postman Collection​

Download dari /postman/kesles-merchant-mobile-api.postman_collection.json (di-serve oleh internal Docusaurus dari merchant_docs/api_docs/internal/static/postman/).


8. Performance Targets​

Endpointp50 latencyp95 latencyp99 latency
Auth /login150ms400ms800ms
/transactions (list, 20 items)80ms200ms500ms
/transactions/{id}40ms100ms250ms
/revenue/summary60ms150ms300ms
/revenue/chart100ms250ms500ms
/outlets (list)30ms80ms150ms
/reports/daily (PDF)2s5s10s
/notifications/register-token20ms50ms100ms

9. Backend Implementation Checklist​

For each endpoint:

Setup​

  • Route registration in services/dashboard_api/internal/app/server.go
  • New handler file: dashboard-api/internal/app/merchant_*.go
  • Auth middleware wrapper that verifies JWT + checks the role matrix

Per-Endpoint​

  • Request validation (payload, query params)
  • DB query with proper indexes
  • Response shape matching the spec
  • Error handling for all documented error codes
  • Rate limit middleware
  • Sentry error reporting
  • Audit log for POST/PATCH/DELETE
  • Unit tests for happy path + 3 error cases
  • Integration tests against the staging dataset
  • Load tests for high-traffic endpoints (/transactions, /revenue/summary)

Documentation​

  • Auto-generated OpenAPI/Swagger spec
  • Example responses in the Postman collection
  • Changelog entry in CHANGELOG.md at release

10. Migration Dependencies​

This API depends on 3 new migrations that are not yet created:

MigrationTablePriority
062merchant.merchant_outlets + merchant.devices.outlet_idP0
063merchant.merchant_app_usersP0
064merchant.notification_tokens + merchant.notification_preferencesP0

The schema is already drafted in apps/mobile_user/mvp-spec.md (merchant_docs/docs/apps/mobile_user/mvp-spec.md) §3.3.

These migrations must be applied before endpoint implementation begins.


11. Open Questions for the Backend Team​

  1. JWT or session-based auth? What does dashboard-api use today? Recommendation: JWT with refresh token (aligned with mobile best practice).
  2. Rate limiter implementation? Redis sliding window or simpler in-memory? Recommendation: Redis (scale-out ready).
  3. PDF generation library? Go native (gofpdf) or via headless Chrome (chromedp)? Recommendation: chromedp (easier layout, reuse HTML template).
  4. FCM credentials storage? Vault or env var? Recommendation: env var for MVP, Vault at production scale.
  5. Background job queue for async reports? Redis Queue or cron-based? Recommendation: Redis Queue (scale + observability).
  6. Bank webhook signature verification? HMAC SHA256 or custom? Confirm with the bank vendor.
  7. Can a merchant user invite another owner? Per MVP, 1 merchant = 1 owner. Transfer ownership = future feature.

12. References​

  • Mobile app spec: apps/mobile_user/mvp-spec.md (merchant_docs/docs/apps/mobile_user/mvp-spec.md)
  • Existing backend: services/dashboard_api/internal/app/
  • Auth reference: dashboard_profile.go
  • Existing pattern: dashboard_vendors.go — CRUD handler reference
  • Router mounting: server.go
  • Feasibility analysis: qrisplus-selling-strategy-analysis.md


3. Upload Payment Proof — Spec Lengkap​

3.1 Endpoint​

POST /merchant/sales-order/payment-proof
Authorization: Bearer <access_token>
X-Merchant-ID: <merchant_id> ← dari MerchantContextService.readActiveId()
Content-Type: multipart/form-data

3.2 Request body (multipart/form-data)​

FieldTypeRequiredKeterangan
filefile✅Gambar bukti transfer. JPEG/PNG. Max 200 KB setelah compress.
order_idstring (UUID)✅ID sales order target. Kalau kosong, backend fallback ke current order (compat lama).

Constraint ukuran:

  • Backend limit: 200 KB (maxPaymentProofSize = 200 * 1024 di handler Go)
  • Mobile compress ke 180 KB sebelum upload (safety margin 10% untuk multipart overhead)
  • Backend reject dengan 400 jika > 200 KB

3.3 Response 200​

{
"id": "uuid",
"order_number": "SO-0000000000000067",
"status": "payment_review",
"payment_proof_url": "https://storage.../payment-proof-20260528-143000.jpg",
"payment_proof_uploaded_at": "2026-05-28T07:30:00Z",
"payment_verification_deadline_at": "2026-05-29T07:30:00Z",
"total_amount": 466200
}

Field payment_verification_deadline_at dihitung server via addBusinessHours (Mon–Sat, skip Minggu) dari payment_proof_uploaded_at + payment_verification_sla_hours di merchant.company_profile (default 24 jam).

3.4 Response error​

StatusError codeKondisi
400multipart_parse_errorForm tidak valid atau ukuran > 200 KB
400file_too_largeFile > 200 KB
400order_id_required (merchant multi)order_id kosong dan user multi-merchant
404sales_order_not_foundorder_id tidak ditemukan untuk merchant ini
409payment_deadline_expiredpayment_deadline_at < NOW() — order akan auto-cancel
409invalid_order_statusStatus order bukan pending atau payment_review

3.5 Side effect di backend​

  1. Upload file ke MinIO → dapat public URL.
  2. INSERT ke orders.sales_order_payments (di db_kesles_merchant_order) dengan status='submitted' dan payment_proof_url=$url. Jika row sudah ada (re-upload), UPDATE payment_proof_url saja — payment_proof_uploaded_at pakai COALESCE (anchor SLA dari upload pertama, idempotent).
  3. UPDATE orders.sales_orders SET status='payment_review' (summary cache).
  4. INSERT ke merchant.audit_events (type: payment_proof_uploaded).

3.6 RBAC​

Endpoint di-gate withMerchantRole([owner, admin]) — role staff, viewer, dan member tidak bisa upload. Lihat merchant-rbac-permission-matrix.md §2.

3.7 Implementasi sisi mobile​

Flow teknis lengkap dan known issues (double compression, race condition token refresh) didokumentasikan di order-payment-shipping-lifecycle-plan.md §13.


Conclusion​

17 endpoints for the MVP mobile app. Target delivery: end of Month 2 (June 2026), 4 weeks after the DB migration is applied.

Critical path:

  1. Week 1 — Auth + /revenue/summary + /transactions (dashboard home screen ready)
  2. Week 2 — /outlets + /users (multi-outlet & role-based)
  3. Week 3 — /reports + /settlements (advanced features)
  4. Week 4 — /notifications + PDF generation + polish

All endpoints extend the existing pattern from dashboard_vendors.go (CRUD), dashboard_reports_wac.go (read-only report), and dashboard_profile.go (auth). Maximum reuse = fast delivery.