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) → signInWithCredentialuser.getIdToken() → tanpa user input OTP
  3. Fallback Android: callback codeSent(verificationId, _) → navigate OtpPage(firebaseVerificationId) → user input 6 digit → PhoneAuthProvider.credential(verificationId, smsCode)signInWithCredentialuser.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):

  • subuser_id (uuid — identity.users di db_kesles_merchant_auth)
  • merchant_id — resolved from merchant.merchant_users link
  • isskesles-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_failedfrom > 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
filefileGambar 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.