Files
apskel-pos-backend/docs/api-enakpoint.md
T

20 KiB
Raw Blame History

API EnakPoint & EnakCoin

30 Sep 2026

Semua endpoint EnakPoint (POINT, bisa bayar order) dan EnakCoin (COIN, untuk game dan ditukar ke EnakPoint) ada di bawah base URL /api/v1, memakai satu format response, dan semua jumlah berupa bilangan bulat.

Konvensi umum

Klien Autentikasi Prefix
Customer app / self-order Authorization: Bearer <token customer> /api/v1/customer
POS Token user (kasir/manager) /api/v1
Dashboard Token user, role Admin atau Manager /api/v1/marketing, /api/v1/outlets

Format response. Sukses: {"success": true, "data": {…}, "errors": null}. Gagal: {"success": false, "data": null, "errors": [{"code": "304", "entity": "wallet_service", "cause": "…"}]}. Tampilkan cause sebagai alasan penolakan.

code HTTP Arti
303, 310 400 Body atau parameter tidak lengkap / salah format
304 400 Ditolak aturan bisnis (saldo kurang, di luar batas, dst.)
404 404 Tidak ditemukan, juga untuk data milik customer atau organisasi lain
429 429 OTP diminta ulang terlalu cepat
PIN_NOT_SET 403 Customer belum membuat PIN
PIN_INVALID 400 PIN salah
PIN_LOCKED 423 PIN terkunci 30 menit setelah 5 kali salah
TRANSFER_BLOCKED 403 Transfer ditahan 24 jam setelah reset PIN
900 500 Kesalahan server

Error PIN membawa data yang tidak null: {"code": "PIN_INVALID", "remaining_attempts": 3}, {"code": "PIN_LOCKED", "locked_until": "…"}, atau {"code": "TRANSFER_BLOCKED", "transfer_blocked_until": "…"}. Endpoint yang menerima pin bisa mengembalikan salah satunya. PIN selalu dikirim sebagai string 6 digit.

Idempotency. Exchange dan transfer wajib header Idempotency-Key (maks. 50 karakter, X-Idempotency-Key juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan replayed: true. POST /payments wajib X-Idempotency-Key seperti pembayaran lain.

Waktu. Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya.

Customer app: saldo & riwayat

Method Path Keterangan
GET /customer/wallet Saldo, nilai rupiah, kedaluwarsa terdekat, 5 mutasi terakhir
GET /customer/wallet/transactions Riwayat mutasi, dengan pagination dan filter
GET /customer/wallet/expiring Saldo yang akan kedaluwarsa, per currency dan tanggal
PUT /customer/devices Daftarkan token FCM device
DELETE /customer/devices/:device_id Hapus device saat logout
GET /customer/outlets Outlet aktif di organisasi customer, dengan accepts_point_payment, earns_points, earns_coins
GET /customer/orders Riwayat order customer (page, limit), dengan points_earned / coins_earned
GET /customer/orders/:id Detail order: item, pembayaran, EnakPoint yang dipakai; order customer lain → 404

Registrasi (POST /customer-auth/register/start) menerima organization_id opsional: bila tidak dikirim dan hanya ada satu organisasi, customer masuk ke organisasi itu. Contoh request dan response lengkap untuk outlet dan order ada di mobile-customer-enakpoint.md §4.4–§4.5.

GET /customer/wallet

{
  "point_balance": 12500,
  "coin_balance": 8,
  "point_value": 1,
  "point_discount_value": 12500,
  "nearest_expiring": {
    "point": { "amount": 150, "date": "2026-12-31" },
    "coin": null
  },
  "recent_transactions": [ "… sama seperti item riwayat …" ]
}
  • point_balance / coin_balance = saldo yang bisa dipakai sekarang.
  • point_discount_value = point_balance × point_value; tampilkan sebagai "setara potongan Rp …", bukan saldo uang.
  • nearest_expiring.point / .coin bernilai null bila tidak ada yang akan kedaluwarsa.

GET /customer/wallet/transactions

Query Tipe Keterangan
page int Default 1
limit int 1–100, default 20
currency POINT | COIN Opsional
type string Satu tipe atau beberapa dipisah koma, mis. EARN,PAYMENT
from, to YYYY-MM-DD Tanggal WIB, inklusif
{
  "data": [
    {
      "id": "…",
      "currency": "POINT",
      "type": "EARN",
      "amount": 875,
      "balance_after": 12500,
      "description": "Belanja #ORD-0123 di Outlet Kemang",
      "source": { "type": "ORDER", "id": "…" },
      "outlet_id": "…",
      "group_id": null,
      "expires_at": "2026-12-31T23:59:59+07:00",
      "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }],
      "created_at": "2026-09-30T12:01:00Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
}

amount bertanda (+ menambah, − mengurangi). Penambahan membawa source, pengurangan membawa destination, keduanya { type, id }. Dua baris exchange atau transfer berbagi group_id. Daftar tipe ada di bagian Referensi.

GET /customer/wallet/expiring

{
  "point": [
    { "amount": 150, "date": "2026-10-31" },
    { "amount": 200, "date": "2026-12-31" }
  ],
  "coin": []
}

Terurut dari tanggal terdekat. Daftar kosong berarti tidak ada yang akan kedaluwarsa.

PUT /customer/devices

{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" }

Panggil setelah login dan setiap kali FCM memberi token baru. device_id dan fcm_token wajib; platform = android | ios | web. Satu token hanya milik satu customer: customer lain yang mendaftarkan token yang sama mengambil alih HP itu. Response: { "device_id": "a1b2c3" }.

Customer app: PIN

PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu.

Method Path Body Response
GET /customer/pin/status – { "has_pin", "locked_until", "transfer_blocked_until" }
POST /customer/pin/otp { "purpose": "pin_setup" } atau "pin_reset" { "purpose", "otp_token", "expires_at" }
POST /customer/pin { "otp_token", "otp_code", "pin", "confirm_pin" } Status PIN
PUT /customer/pin { "old_pin", "pin", "confirm_pin" } Status PIN
POST /customer/pin/reset { "otp_token", "otp_code", "pin", "confirm_pin" } Status PIN
  1. Buat PIN: minta OTP dengan purpose: "pin_setup" (dikirim lewat WhatsApp), lalu POST /customer/pin dengan otp_token dari response OTP dan kode yang diterima customer.
  2. Lupa PIN: minta OTP dengan purpose: "pin_reset", lalu POST /customer/pin/reset. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; pembayaran dan exchange tetap bisa.
  3. Ganti PIN: PUT /customer/pin dengan PIN lama.

PIN baru ditolak 304 bila bukan 6 digit, konfirmasinya beda, semua digit sama (111111), berurutan (123456, 654321), atau sama dengan tanggal lahir (DDMMYY / YYMMDD). OTP yang diminta terlalu cepat dijawab 429. Penanganan PIN_INVALID, PIN_LOCKED, dan TRANSFER_BLOCKED ada di Konvensi umum.

Customer app: bayar, exchange, transfer, game

Method Path PIN Idempotency-Key
POST /customer/wallet/payment-code Ya –
POST /customer/orders/:id/pay-with-points Ya –
GET /customer/wallet/exchange/preview?coins= – –
POST /customer/wallet/exchange Ya Wajib
GET /customer/wallet/transfer/recipient?phone= – –
POST /customer/wallet/transfer Ya Wajib
POST /customer/spin – –

POST /customer/wallet/payment-code

Body { "pin": "482913" }. Response:

{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }

Tampilkan code sebagai angka dan qr_payload sebagai QR untuk kasir. Berlaku 2 menit, sekali pakai, hanya untuk customer ini; kode baru membatalkan kode lama.

POST /customer/orders/:id/pay-with-points

Body { "points": 12500, "pin": "482913" }. Hanya untuk order milik customer yang login (order lain 404). Response sama dengan pembayaran POS (bagian POS). Batas dan aturan penolakan juga sama.

GET /customer/wallet/exchange/preview?coins=30

{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true }

Kurs: coin_amount EnakCoin = point_amount EnakPoint (default 1 : 1). Bila valid: false, tampilkan reason.

POST /customer/wallet/exchange

Body { "coins": 30, "pin": "482913" }. Response:

{
  "group_id": "…",
  "coins": 30,
  "points": 9,
  "coin_amount": 10,
  "point_amount": 3,
  "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }],
  "coin_balance": 5,
  "point_balance": 9,
  "replayed": false
}

coins harus kelipatan coin_amount; jumlah yang salah ditolak 304 sebelum PIN dicek. Exchange tidak bisa dibatalkan. EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (lihat lots).

GET /customer/wallet/transfer/recipient?phone=081234561234

{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }

Nomor di luar organisasi atau tidak terdaftar → 404. Diri sendiri, customer walk-in, atau nonaktif → 304.

POST /customer/wallet/transfer

Body { "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }. Response:

{
  "group_id": "…",
  "currency": "POINT",
  "amount": 120,
  "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" },
  "lots": [
    { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" },
    { "amount": 20, "expires_at": null }
  ],
  "balance": 30,
  "replayed": false
}

currency = POINT atau COIN. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak 304 sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (lots), dan penerima mendapat push WALLET_TRANSFER_IN.

POST /customer/spin

Body { "spin_id": "<id game>" }. Memotong EnakCoin sebesar metadata.coin_cost game itu (default 1).

{
  "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
  "prize_won": { "id": "…", "name": "Voucher 10rb" },
  "coins_remaining": 7
}

EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → 304, tidak ada EnakCoin yang terpotong.

POS: pembayaran EnakPoint

Kasir memakai endpoint pembayaran yang sudah ada dengan payment method bertipe point, disetujui customer lewat kode bayar dari aplikasinya; PIN tidak pernah diketik di perangkat kasir.

Method Path Keterangan
GET /orders/:id/point-payment/preview Batas pembayaran EnakPoint untuk order ini
POST /payments Bayar dengan method EnakPoint (points + payment_code)
POST /payments/:id/refund Refund pembayaran EnakPoint, kembali sebagai EnakPoint
  1. Customer membuat kode di aplikasi (POST /customer/wallet/payment-code) dan menunjukkan angka atau QR-nya.
  2. POS memanggil preview untuk tombol "pakai maksimal".
  3. POS memanggil POST /payments dengan kode tersebut. Sisa tagihan dibayar dengan method lain seperti biasa.

GET /orders/:id/point-payment/preview

{
  "order_id": "…",
  "customer_id": "…",
  "eligible": true,
  "point_balance": 12500,
  "point_value": 1,
  "remaining_amount": 87500,
  "min_payment_points": 1,
  "max_payment_percent": 100,
  "max_points": 12500,
  "max_amount": 12500
}

Bila eligible: false, reason menjelaskan kenapa (order walk-in, outlet tidak menerima EnakPoint, saldo di bawah minimal, dst.). Batas yang dipakai:

batas_rupiah = min(sisa_tagihan, total × max_payment_percent / 100 − sudah_dibayar_EnakPoint)
maks_point   = min(saldo, floor(batas_rupiah / point_value))

POST /payments

Header X-Idempotency-Key wajib.

{
  "order_id": "…",
  "payment_method_id": "<id method EnakPoint>",
  "points": 12500,
  "payment_code": "482913"
}
  • amount tidak perlu dikirim; backend menghitung points × point_value dan tidak pernah melebihi sisa tagihan (tidak ada kembalian).
  • payment_code boleh angka yang diketik atau hasil scan QR apa adanya (enakpoint:482913).
  • Response pembayaran membawa points_used dan point_value untuk struk; response order membawa points_earned dan coins_earned.
  • Ditolak 304 bila: order tanpa customer atau walk-in, customer nonaktif, outlet tidak menerima EnakPoint, points di luar batas, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method EnakPoint dipakai sebagai split. Kode terpakai begitu diterima; bila pembayaran lalu ditolak, minta kode baru.
  • Method EnakPoint dibuat otomatis per organisasi, tidak bisa dihapus atau diubah tipenya, dan tidak muncul di daftar method ?outlet_id= bila outlet tidak menerima EnakPoint.

Void dan refund

  • Void order: semua EnakPoint yang dipakai kembali sebagai EnakPoint.
  • POST /payments/:id/refund pada pembayaran EnakPoint: kembali floor(rupiah_direfund / point_value_saat_bayar); sisa di bawah 1 EnakPoint hangus.
  • Refund order ke tunai/method lain hanya sebesar bagian non-EnakPoint; mencoba merefund bagian EnakPoint secara tunai ditolak 304.
  • EnakPoint yang kembali memakai tanggal kedaluwarsa asal, minimal 7 hari sejak refund. Earning order ikut ditarik; bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan.

Dashboard

Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke organisasi user yang login. Rincian layar ada di backoffice-enakpoint.md.

Method Path Keterangan
GET, PUT /outlets/:outlet_id/loyalty-settings Earning dan penerimaan EnakPoint per outlet
GET, PUT /marketing/loyalty-settings Nilai EnakPoint, kurs, transfer, kedaluwarsa (?dry_run=true untuk preview)
GET /marketing/loyalty-settings/history Riwayat perubahan setting (page, limit, outlet_id)
GET /marketing/customers/:id/wallet Saldo, lot aktif, riwayat dengan nama asli
POST /marketing/customers/:id/wallet/adjust Koreksi saldo manual
GET /marketing/wallet-transactions/:id/trace Telusuri asal saldo per butir
DELETE /marketing/customers/:id/pin Hapus PIN customer
GET /marketing/customers/:id/security-events Log keamanan PIN (page, limit)

Pada kedua PUT setting, field yang tidak dikirim tetap memakai nilai sekarang; field yang tidak dikenal ditolak.

/outlets/:outlet_id/loyalty-settings

{
  "point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
  "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
  "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
}

Response menambahkan outlet_id, point_value, point_cashback_percent (default di atas = 1%), dan changes pada PUT. earn_mode adalah PER_AMOUNT (setiap earn_per_amount rupiah mendapat earn_value) atau PERCENTAGE (earn_percent persen dari basis). Validasi: earn_per_amount > 0, earn_value ≥ 0, earn_percent 0–100 dengan maks. 2 angka desimal, max_payment_percent 0–100.

/marketing/loyalty-settings

{
  "point_value": 1,
  "exchange": { "coin_amount": 1, "point_amount": 1 },
  "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
  "point_expiry": {
    "enabled": false,
    "mode": "FIXED_DATE",
    "fixed_dates": ["12-31"],
    "grace_months": 3,
    "period": 12,
    "unit": "MONTH",
    "end_of_month": false,
    "reminder_days": 7
  },
  "coin_expiry": { "…": "sama dengan point_expiry" }
}
Field kedaluwarsa Dipakai mode Nilai
mode – FIXED_DATE (hangus di tanggal tetap tiap tahun) atau ROLLING (umur sejak didapat)
fixed_dates FIXED_DATE MM-DD, boleh lebih dari satu; 02-29 ditolak
grace_months FIXED_DATE 0–24; saldo yang didapat kurang dari ini sebelum tanggal hangus ikut ke tanggal berikutnya
period, unit ROLLING ≥ 1, DAY atau MONTH
end_of_month ROLLING Dibulatkan ke akhir bulan
reminder_days keduanya Hari sebelum hangus untuk pengingat; 0 = tanpa pengingat

Response menambahkan:

  • impact: saldo beredar dan nilai rupiahnya sebelum/sesudah perubahan point_value atau kurs.
  • expiry_preview: { "point", "coin" }, kapan saldo yang didapat sekarang kedaluwarsa (null = tidak).
  • expiry_activations: bila perubahan ini menyalakan kedaluwarsa pertama kali, [{ "currency", "lots", "amount", "expires_at" }] saldo lama yang ikut diberi tanggal.
  • changes dan dry_run.

POST /marketing/customers/:id/wallet/adjust

{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" }

amount bertanda dan tidak boleh 0; reason wajib. Pengurangan yang melebihi saldo ditolak 304. Response: { "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }.

GET /marketing/wallet-transactions/:id/trace

{
  "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "PAYMENT", "amount": -30, "…": "…" },
  "lots": [
    {
      "amount": 30,
      "chain": [
        { "lot": { "id": "…", "expires_at": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" } } },
        { "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "reference_type": "ORDER", "description": "Belanja #ORD-1", "customer": { "name": "Anita" } } }
      ]
    }
  ]
}

Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap chain mundur lewat transfer, exchange, atau refund sampai lot pertama dari EARN, ADJUSTMENT, atau MIGRATION.

PIN customer

DELETE /marketing/customers/:id/pin dengan { "reason": "…" } memaksa customer membuat PIN baru lewat OTP; admin tidak bisa membuat, mengganti, atau melihat PIN. security-events mengembalikan PIN_SET, PIN_CHANGED, PIN_RESET, PIN_FAILED, PIN_LOCKED, PIN_REMOVED_BY_ADMIN beserta waktu, IP, dan perangkat.

Referensi

Tipe mutasi (type)

type Arah Arti source / destination
EARN + Didapat dari order lunas ORDER
EARN_REVERSAL − Ditarik karena order di-void/refund ORDER
PAYMENT − Membayar order (EnakPoint saja) PAYMENT
PAYMENT_REFUND + Kembali karena pembayaran di-void/refund PAYMENT
EXCHANGE_OUT − EnakCoin ditukar WALLET_TX (baris EXCHANGE_IN)
EXCHANGE_IN + EnakPoint hasil tukar WALLET_TX (baris EXCHANGE_OUT)
TRANSFER_OUT − Dikirim ke customer lain WALLET_TX (baris TRANSFER_IN)
TRANSFER_IN + Diterima dari customer lain WALLET_TX (baris TRANSFER_OUT)
GAME_SPEND − Main game (EnakCoin saja) GAME_PLAY
EXPIRE − Hangus karena kedaluwarsa LOT
ADJUSTMENT + / − Koreksi admin USER
MIGRATION + Saldo dari sistem lama LEGACY_POINTS / LEGACY_TOKENS

Notifikasi push (FCM)

Semua nilai data berupa string. Push hanya sampai ke device yang terdaftar lewat PUT /customer/devices.

data.type Kapan Isi data lainnya
WALLET_TRANSFER_IN Menerima transfer transaction_id, group_id, currency, amount
WALLET_EXPIRING reminder_days hari sebelum hangus, sekali per tanggal currency, amount, expiry_date
WALLET_EXPIRED Saldo baru saja hangus currency, amount
PIN_LOCKED PIN terkunci setelah 5 kali salah locked_until (RFC3339, UTC)

Endpoint dan field deprecated

Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Semua yang bernama token (/customer/tokens, total_tokens, tokens_history, token_used, tokens_remaining, campaign TOKENS) sudah dihapus; pakai coin_balance, coins_used, coins_remaining, dan COINS.

Lama Pengganti
GET /customer/points GET /customer/wallet → point_balance
total_points, points_history, last_updated di /customer/wallet point_balance, recent_transactions

Panduan alur lengkap per tim ada di integration-enakpoint.md.