Files
apskel-pos-backend/docs/integration-enakpoint.md
T
efrilmandClaude Opus 5.5 798a36bd6c feat(enakgame): game sessions, rewards, vouchers, budgets and events
EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the
existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md).

Foundation (phase 1)
- Migrations 000103-000106: games extended with organization, slug, status,
  entry cost and result rules, old games archived (not deleted); budgets,
  versioned reward configs, sessions and session rewards; the ledger types
  GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs.
- AuditLogger writes in the caller's transaction only.
- enakgame.limit.user_daily and global_daily organization settings.

Games and sessions (phases 2-4)
- Admin /marketing/enakgame: games, reward config versions (immutable but for
  status, one ACTIVE per game), budgets with non-overlapping global periods and
  a daily job opening the next month.
- Customer /customer/enakgame: start (Idempotency-Key, entry cost and config
  frozen on the session), complete (result validation, reward engine, max_reward
  cap, daily limits via game_reward_counters, one GAME_REWARD per budget),
  automatic refunds for system errors and deactivated games, and a session job.
- Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand),
  rounded down.

Vouchers and budgets (phases 5-6)
- Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution;
  Economy Guard counters.
- STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action;
  realized cost traced through the lots to the budget that paid the reward.
- Budget metrics: realized cost, forecast, exposure and status. Migrations
  000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY.

Events (phase 7)
- Migration 000111: game events, each with its own EVENT budget. Event extras
  stack per PRD §16 defaults, with event and per-customer limits.

External vouchers (phase 8)
- VoucherProvider contract, two-step PENDING redemption and a recovery job,
  tested with a fake provider. No provider adapter is registered yet, so
  EXTERNAL vouchers stay out of the catalog.

Not yet decided before release: reward rounding, event stacking, budget
exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have
not been run on any shared database.

Also fixes a leftover PAYMENT filter in a wallet test and a data race in a
test PIN fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:53:14 +07:00

553 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Integrasi EnakPoint & EnakCoin — Customer App, POS & Dashboard
**Migrasi:** `000090`–`000097` · **Base URL:** `/api/v1` · **Kompatibilitas:** endpoint
lama tetap jalan sebagai alias (lihat §8)
Panduan untuk memakai saldo loyalitas dari sisi klien. Alasan di balik setiap aturan
ada di [`prd-point-coin.md`](./prd-point-coin.md).
> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus (migrasi
> `000102`). EnakPoint sekarang hanya bisa ditukar ke voucher, tidak bisa dipakai
> sebagai alat bayar dan tidak bisa dicairkan
> ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus
> ada di §8.
---
## 1. Konsep inti
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|---|---|---|
| Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin |
| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint |
| Bisa ditransfer | Ya | Ya |
| Bisa kedaluwarsa | Ya, bila diaktifkan owner | Ya, bila diaktifkan owner |
Aturan yang berlaku di seluruh dokumen ini:
1. **Semua jumlah bilangan bulat.** Tidak ada "setengah EnakPoint".
2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, dan EnakPoint tidak bisa
dipakai membayar order. Tampilkan nilai rupiahnya sebagai **"setara potongan
Rp …"**, bukan "saldo Rp …".
3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): exchange
dan transfer. Main game tidak butuh PIN.
4. **Wallet milik customer di satu organisasi.** Saldo berlaku di semua outlet
organisasi itu. Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan
kedaluwarsa diatur per organisasi; earning per outlet.
5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah
dihapus. Koreksi muncul sebagai baris baru.
### Format response
Semua endpoint memakai amplop yang sama:
```json
{ "success": true, "data": { … }, "errors": null }
```
```json
{
"success": false,
"data": null,
"errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }]
}
```
| `code` | HTTP | Arti |
|---|---|---|
| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format |
| `304` | 400 | Permintaan ditolak aturan bisnis; `cause` menjelaskan alasannya |
| `404` | 404 | Tidak ditemukan (juga dipakai untuk data milik customer/organisasi lain) |
| `429` | 429 | Terlalu cepat meminta ulang (OTP) |
| `PIN_NOT_SET` | 403 | Customer belum membuat PIN |
| `PIN_INVALID` | 400 | PIN salah |
| `PIN_LOCKED` | 423 | PIN terkunci |
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN |
| `900` | 500 | Kesalahan server |
---
## 2. Customer app — saldo & riwayat
Semua endpoint customer memakai header `Authorization: Bearer <token customer>`.
### 2.1 Saldo
`GET /api/v1/customer/wallet`
```json
{
"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": [ … ]
}
```
- `point_balance` dan `coin_balance` adalah saldo yang **bisa dipakai sekarang**.
- `point_discount_value` = `point_balance × point_value`. Tampilkan sebagai
"setara potongan Rp 12.500".
- `nearest_expiring` bernilai `null` per currency bila tidak ada yang akan kedaluwarsa.
- `recent_transactions` berisi 5 mutasi terakhir dengan bentuk yang sama seperti §2.2.
### 2.2 Riwayat
`GET /api/v1/customer/wallet/transactions?page=1&limit=20&currency=POINT&type=EARN,TRANSFER_IN&from=2026-09-01&to=2026-09-30`
Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma.
`from` / `to` tanggal WIB, inklusif.
```json
{
"data": [
{
"id": "…",
"currency": "POINT",
"type": "EARN",
"amount": 875,
"balance_after": 12500,
"description": "Belanja #ORD-0123 di Outlet Kemang",
"source": { "type": "ORDER", "id": "…" },
"outlet_id": "…",
"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: positif menambah saldo, negatif mengurangi.
- Penambahan punya `source`, pengurangan punya `destination`. Keduanya berbentuk
`{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, game play,
dst.).
- `description` sudah siap tampil dan tidak berubah walau nama outlet atau customer
berubah belakangan. Nama lawan transfer sudah disamarkan.
- Dua baris exchange atau transfer berbagi `group_id` yang sama.
| `type` | Arah | Arti | `source` / `destination` |
|---|---|---|---|
| `EARN` | + | Didapat dari order lunas | `ORDER` |
| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` |
| `EXCHANGE_OUT` / `EXCHANGE_IN` | − / + | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) |
| `TRANSFER_OUT` / `TRANSFER_IN` | − / + | Transfer antar customer | `WALLET_TX` (baris pasangannya) |
| `GAME_SPEND` | − | Main game | `GAME_PLAY` |
| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` |
| `ADJUSTMENT` | + / − | Koreksi oleh admin | `USER` |
| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` |
### 2.3 Yang akan kedaluwarsa
`GET /api/v1/customer/wallet/expiring`
```json
{
"point": [
{ "amount": 150, "date": "2026-10-31" },
{ "amount": 200, "date": "2026-12-31" }
],
"coin": []
}
```
Dikelompokkan per tanggal (WIB), paling dekat lebih dulu. Saldo bisa dipakai sampai
akhir hari tanggal itu. Daftar kosong berarti tidak ada yang akan kedaluwarsa.
### 2.4 Notifikasi push (FCM)
Aplikasi mendaftarkan token FCM-nya **setelah login dan setiap kali FCM memberi token
baru**:
`PUT /api/v1/customer/devices`
```json
{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" }
```
`platform`: `android`, `ios`, atau `web` (opsional). Saat logout, panggil
`DELETE /api/v1/customer/devices/:device_id` supaya HP itu tidak lagi menerima
notifikasi customer tersebut. Satu token hanya milik satu customer: bila customer lain
login di HP yang sama dan mendaftarkan token yang sama, customer sebelumnya otomatis
tidak menerima notifikasi di HP itu lagi.
Push yang dikirim, dibedakan lewat `data.type`:
| `data.type` | Kapan | Isi `data` lainnya |
|---|---|---|
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` |
| `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo kedaluwarsa, 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) |
Semua nilai di `data` berupa string, sesuai aturan FCM.
---
## 3. Customer app — PIN
PIN 6 digit, terpisah dari password login, dikirim sebagai **string** supaya angka nol
di depan tidak hilang. PIN tidak pernah dikembalikan di response.
### 3.1 Cek status
`GET /api/v1/customer/pin/status`
```json
{ "has_pin": true, "locked_until": null, "transfer_blocked_until": null }
```
Minta customer membuat PIN saat pertama kali ia melakukan aksi yang butuh PIN
(`has_pin: false`), bukan saat registrasi.
### 3.2 Membuat PIN pertama kali
1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. OTP dikirim ke
nomor customer lewat WhatsApp. Response: `{ "purpose", "otp_token", "expires_at" }`.
2. `POST /api/v1/customer/pin` dengan
`{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`.
PIN ditolak (`304`) bila bukan 6 digit, konfirmasinya beda, semua digit sama
(`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir
(`DDMMYY` / `YYMMDD`). Tampilkan `cause` apa adanya. Meminta OTP terlalu cepat
menghasilkan `429`.
### 3.3 Mengganti dan mereset PIN
- **Ganti:** `PUT /api/v1/customer/pin` dengan `{ "old_pin", "pin", "confirm_pin" }`.
- **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu
`POST /api/v1/customer/pin/reset` dengan body yang sama seperti §3.2. Reset juga
membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**;
exchange tetap bisa.
### 3.4 Menangani error PIN
Setiap endpoint yang menerima `pin` bisa mengembalikan error PIN. Pada error ini `data`
**tidak** `null`:
```json
{
"success": false,
"data": { "code": "PIN_INVALID", "remaining_attempts": 3 },
"errors": [{ "code": "PIN_INVALID", "entity": "customer_pin_service", "cause": "wrong PIN, 3 attempts left" }]
}
```
| `data.code` | Field tambahan | Yang ditampilkan aplikasi |
|---|---|---|
| `PIN_NOT_SET` | – | Arahkan ke pembuatan PIN (§3.2) |
| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa 3 percobaan" |
| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai 14:30", tawarkan reset PIN |
| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada …" |
Lima kali salah berturut-turut mengunci PIN selama 30 menit. Selama terkunci, PIN yang
benar pun ditolak. Penghitung disimpan di server, jadi tidak bisa diakali dengan
reinstall atau ganti HP.
---
## 4. Earning, void, dan refund
EnakPoint bukan payment method: tidak ada payment method bertipe `point`, dan
`POST /api/v1/payments` memakai `amount` seperti pembayaran lain. Kode bayar,
bayar dari aplikasi, dan preview pembayaran EnakPoint sudah dihapus (§8).
Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak
menghasilkan apa-apa). Earning dihitung dari `subtotal − discount`, sebelum pajak, dan
diberikan saat order lunas.
EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila
saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah
diblokir karena ini.
---
## 5. Exchange EnakCoin → EnakPoint
Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1).
1. **Preview** sebelum minta PIN:
`GET /api/v1/customer/wallet/exchange/preview?coins=30`
```json
{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true }
```
Bila `valid: false`, tampilkan `reason` (misalnya harus kelipatan `coin_amount`,
atau EnakCoin tidak cukup).
2. **Tukar:**
`POST /api/v1/customer/wallet/exchange` dengan header **`Idempotency-Key`** (wajib,
maks. 50 karakter, satu key per percobaan tukar)
```json
{ "coins": 30, "pin": "482913" }
```
```json
{
"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
}
```
- Jumlah EnakCoin harus kelipatan `coin_amount`. Kesalahan jumlah ditolak **sebelum**
PIN dicek, jadi tidak memakan jatah percobaan PIN.
- Exchange tidak bisa dibatalkan; tampilkan konfirmasi.
- Kirim ulang dengan `Idempotency-Key` yang sama bila koneksi putus: hasil pertama
dikembalikan dengan `replayed: true` tanpa menukar lagi, dengan kurs saat itu.
`Idempotency-Key` yang sama untuk jumlah berbeda ditolak.
- EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (`lots`
menunjukkan tanggalnya).
---
## 6. Transfer ke customer lain
1. **Cek penerima** sebelum konfirmasi:
`GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234`
```json
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
```
Nomor yang tidak terdaftar di organisasi yang sama dijawab `404`. Diri sendiri,
customer walk-in, atau customer nonaktif dijawab `304`.
2. **Kirim:**
`POST /api/v1/customer/wallet/transfer` dengan header **`Idempotency-Key`** (wajib)
```json
{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }
```
```json
{
"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`, satu jenis per transfer.
- Batas dari organisasi: transfer bisa dimatikan, ada minimal, maksimal per
transaksi, dan batas harian per currency (reset tengah malam WIB). Pelanggaran batas
ditolak `304` sebelum PIN dicek.
- Transfer final dan tidak bisa dibatalkan customer.
- Saldo yang dikirim membawa tanggal kedaluwarsa aslinya ke penerima (`lots`).
Tampilkan ini ke pengirim.
- Penerima mendapat push `WALLET_TRANSFER_IN` (§2.4).
- Retry dengan `Idempotency-Key` yang sama mengembalikan hasil pertama
(`replayed: true`) dan tidak dihitung dua kali terhadap batas harian.
---
## 7. Game
`POST /api/v1/customer/spin` dengan `{ "spin_id": "<id game>" }`. Tanpa PIN.
Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
Response:
```json
{
"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 dijawab `304`; tidak ada
EnakCoin yang terpotong.
Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1.
---
## 8. Endpoint lama (deprecated)
Masih jalan dan membaca saldo wallet, tapi akan dihapus setelah semua versi aplikasi
pindah. Aplikasi baru jangan memakainya.
| Lama | Ganti dengan |
|---|---|
| `GET /customer/points` | `GET /customer/wallet` (`point_balance`) |
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri,
supaya alias ini bisa dihapus.
Semua yang bernama token sudah dihapus: `GET /customer/tokens`, `total_tokens`,
`tokens_history`, `token_used`, `tokens_remaining`, dan nilai `TOKENS` di campaign. Pakai
`coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
Bayar dengan EnakPoint juga sudah dihapus (7 Okt 2026, migrasi `000102`) karena
EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2).
Tidak ada penggantinya:
- Endpoint `POST /customer/wallet/payment-code`, `POST /customer/orders/:id/pay-with-points`,
dan `GET /orders/:id/point-payment/preview`.
- Payment method tipe `point`, serta field `points` dan `payment_code` di
`POST /payments`; `amount` kembali wajib seperti pembayaran lain.
- `points_used` dan `point_value` di response pembayaran dan di `payments` pada
`GET /customer/orders/:id`; `accepts_point_payment` di `GET /customer/outlets`.
- Objek `point_payment` (`accept_payment`, `min_payment_points`,
`max_payment_percent`) di setting outlet (§9.1).
- Di analytics payment method: `point_amount`, `points_used`, `total_with_points` di
`summary`, serta `points_used` dan `counts_as_cash_in` per baris.
`summary.total_amount` kembali total semua method, dan persentase dihitung dari total
itu.
- Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` tidak ditulis lagi.
---
## 9. Dashboard
Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager.
### 9.1 Pengaturan per outlet
`GET` / `PUT /api/v1/outlets/:outlet_id/loyalty-settings`
```json
{
"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 }
}
```
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. `PUT` yang masih
mengirim `point_payment` ditolak `310` (field tidak dikenal). Response menambahkan
`point_value` organisasi dan `point_cashback_percent`
(`earn_value × point_value / earn_per_amount × 100`, atau `earn_percent × point_value`
pada `earn_mode` `PERCENTAGE`). **Tampilkan persentase ini di
samping setting** supaya owner tidak salah membaca skala: default di atas setara
cashback 1%.
### 9.2 Pengaturan organisasi
`GET` / `PUT /api/v1/marketing/loyalty-settings` (tambah `?dry_run=true` untuk preview
tanpa menyimpan)
```json
{
"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 … },
"enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 }
}
```
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan:
- `impact`: total saldo beredar dan nilai rupiahnya **sebelum dan sesudah** perubahan
`point_value` atau kurs. Tampilkan sebagai peringatan sebelum owner menyimpan.
- `expiry_preview`: `{ "point": …, "coin": … }`, kapan saldo yang didapat hari ini
akan kedaluwarsa (`null` bila tidak kedaluwarsa). Tampilkan sebagai "EnakPoint yang
didapat hari ini kedaluwarsa pada 31 Des 2026".
- `expiry_activations`: bila perubahan ini **menyalakan** kedaluwarsa untuk pertama
kali, berapa saldo lama yang ikut diberi tanggal (`lots`, `amount`) dan tanggalnya
(`expires_at`). Selalu minta konfirmasi dengan `dry_run=true` dulu.
- `changes`: key yang berubah.
**Kedaluwarsa** diatur per currency dengan salah satu model:
| `mode` | Cara kerja | Field yang dipakai |
|---|---|---|
| `FIXED_DATE` (default) | Semua saldo hangus di tanggal tetap setiap tahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya | `fixed_dates` (format `MM-DD`, boleh lebih dari satu, `02-29` ditolak), `grace_months` (0–24) |
| `ROLLING` | Tiap saldo berlaku sekian lama sejak didapat | `period`, `unit` (`DAY` / `MONTH`), `end_of_month` |
- `reminder_days` berlaku untuk keduanya: customer diingatkan sekian hari sebelum
hangus (0 = tanpa pengingat).
- Mengubah pengaturan hanya berlaku untuk saldo yang masuk setelahnya.
- Menyalakan kedaluwarsa pertama kali memberi saldo lama masa berlaku penuh: tanggal
hangus kedua berikutnya (`FIXED_DATE`) atau satu periode penuh (`ROLLING`).
- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal.
Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=20`
(tambah `outlet_id=` untuk setting outlet).
### 9.3 Wallet customer
- `GET /api/v1/marketing/customers/:id/wallet` — saldo buku dan saldo yang bisa
dipakai, semua lot yang masih berisi, dan riwayat dengan nama asli (lawan transfer,
admin, kasir, outlet). Query riwayat sama seperti §2.2.
- `POST /api/v1/marketing/customers/:id/wallet/adjust`
```json
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" }
```
`amount` bertanda. `reason` wajib. Pengurangan yang melebihi saldo ditolak.
Adjustment tidak disertai pembayaran uang, jadi jangan pakai alasan "pencairan".
- `GET /api/v1/marketing/wallet-transactions/:id/trace` — telusuri satu mutasi per
butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer atau
exchange sampai ke earning/adjustment/migrasi pertama. Contoh: dari transfer keluar
B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A yang
mentransfer ke B.
### 9.4 PIN customer
- `DELETE /api/v1/marketing/customers/:id/pin` dengan `{ "reason": "…" }` — hapus PIN
bila customer kehilangan akses. Customer lalu membuat PIN baru lewat OTP. Admin
**tidak bisa** membuat, mengganti, atau melihat PIN.
- `GET /api/v1/marketing/customers/:id/security-events?page=1&limit=20` — log keamanan:
`PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`,
`PIN_REMOVED_BY_ADMIN`, beserta waktu, IP, dan perangkat.
---
## 10. Checklist integrasi
**Customer app**
- [ ] Daftarkan token FCM setelah login dan saat token berganti; hapus saat logout.
- [ ] Tangani empat kode error PIN (§3.4) di semua layar yang meminta PIN.
- [ ] Kirim `Idempotency-Key` baru untuk setiap exchange dan transfer, dan pakai ulang
key yang sama saat retry.
- [ ] Tampilkan nilai rupiah sebagai "setara potongan", bukan saldo uang.
- [ ] Baca `coins_used` / `coins_remaining` dan `/customer/wallet`, bukan field lama.
**POS**
- [ ] Cetak `points_earned` dan `coins_earned` di struk.
- [ ] Jangan menampilkan EnakPoint sebagai payment method (§4).
**Dashboard**
- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan
`expiry_activations` sebelum owner menyimpan setting.
- [ ] Isi `metadata.coin_cost` untuk setiap game.