EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903). Budget Controller (EG-901, EG-902) - GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the multiplier (budget − realized) / (forecast − realized), within one step of 1, rounded down to two decimals, either way. Shows each game's new rules. - POST .../recommendation/accept with the multiplier the admin saw: recomputed in the transaction, then one new ACTIVE version per game, the old one RETIRED, audited with source budget_controller and RECOMMENDATION_ACCEPTED on the budget. - Migration 000112: base_config_id, multiplier and budget_id on game_reward_configs. Rules are always scaled from the admin's last version, so rounding does not compound and min/max are against what the admin set. - Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier 50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4. - RewardCalculator.Scale for the four reward types: amounts only, rounded down. Analytics (EG-903) - GET /marketing/enakgame/analytics/games and /analytics/economy over a range of Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger. - Migrations 000113 (game_sessions by organization and start) and 000114 (wallet_transactions by organization and time, CONCURRENTLY). The Postgres tests for accepting and analytics were not run: no test database here. Migrations 000112-000114 have not been run anywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1007 lines
47 KiB
Markdown
1007 lines
47 KiB
Markdown
# RFC: EnakGame — Game Session, Reward, Voucher & Budget
|
||
|
||
**Status:** Draft
|
||
**Tanggal:** 2026-10-07
|
||
**PRD:** [enakgame-prd.md](enakgame-prd.md)
|
||
**Scope:** Game catalog, game session + entry cost + refund, Reward Engine, Economy Guard,
|
||
voucher & redemption, budget & Budget Controller (recommendation mode), audit
|
||
**Out of scope:** Mission, leaderboard, reward `TIERED`, Budget Controller automatic mode
|
||
(lihat §16)
|
||
|
||
---
|
||
|
||
## 1. Ringkasan
|
||
|
||
EnakGame dibangun **di atas wallet EnakPoint/EnakCoin yang sudah ada**
|
||
([prd-point-coin.md](prd-point-coin.md)), bukan sebagai sistem saldo baru. Ledger, lot,
|
||
kedaluwarsa, idempotency, exchange Coin → Point, dan PIN sudah tersedia dan sudah
|
||
teruji. Yang dibangun baru:
|
||
|
||
| Komponen PRD | Kondisi sekarang | Rencana |
|
||
|---|---|---|
|
||
| Game Management (§9) | `games` ada, tanpa `organization_id`, tanpa status/slug/URL | **Extend** `games` |
|
||
| Game Session (§10) | Tidak ada. `game_plays` adalah main-instan tanpa session | **Baru**: `game_sessions` |
|
||
| Entry cost (§10.1) | Ada (`GAME_SPEND`, `metadata.coin_cost`) tapi tanpa idempotency | **Reuse** `GAME_SPEND`, ref baru `GAME_SESSION` |
|
||
| Refund entry cost (§10.2) | Tidak ada | **Baru**: tipe ledger `GAME_SPEND_REFUND` + job |
|
||
| Reward Engine (§11–13) | Tidak ada. Hadiah spin tidak memberi apa pun | **Baru**: `game_reward_configs` (versioned) |
|
||
| Event / campaign (§14–16) | Tidak ada. `campaigns` ada tapi tidak pernah dieksekusi | **Baru**: `game_events`, masing-masing dengan budget sendiri |
|
||
| Economy Guard & Limits (§17, §35) | Tidak ada | **Baru**: counter harian + settings organisasi |
|
||
| Coin Wallet & Ledger (§18–20) | **Ada lengkap** | **Reuse**, tambah 3 tipe ledger |
|
||
| Coin → Point, expiry | **Ada** (F4, F12) | **Reuse** tanpa perubahan |
|
||
| Voucher & Redemption (§21–28) | `rewards` ada tanpa org, tanpa redemption, tanpa kode | **Baru**: `vouchers`, `voucher_codes`, `voucher_redemptions` |
|
||
| Budget & Controller (§5–8, §29–34) | Tidak ada | **Baru**: `game_budgets` + atribusi cost per lot |
|
||
| Audit Log (§37) | Tidak ada yang generik | **Baru**: `audit_logs` |
|
||
|
||
Keputusan paling penting ada di §3, terutama **D5**: realized cost voucher diatribusikan ke
|
||
budget dengan menelusuri lot Point yang dipakai sampai ke asalnya.
|
||
|
||
---
|
||
|
||
## 2. Kondisi Sekarang
|
||
|
||
Temuan yang memengaruhi desain:
|
||
|
||
1. **Tabel game, reward, campaign, dan tier tidak punya `organization_id`.** Semua query
|
||
membaca semua tenant. Tabel wallet (`000090`) sudah punya.
|
||
2. **Main game sekarang tidak punya session.** `GamePlayProcessor.PlayGame`
|
||
(`processor/game_play_processor.go:141`) memotong Coin, memilih hadiah, dan mencatat
|
||
`game_plays` dalam satu request.
|
||
3. **Hadiah game tidak memberi apa pun.** Prize hanya tercatat sebagai
|
||
`game_plays.prize_id` dan teks deskripsi ledger. Tidak ada kredit Coin/Point, tidak ada
|
||
voucher.
|
||
4. **`GAME_SPEND` tanpa idempotency key** (`game_play_processor.go:186`). Tombol main yang
|
||
ditekan dua kali memotong Coin dua kali.
|
||
5. **Tidak ada alur penukaran.** Tipe ledger `REWARD_REDEEM` dan ref
|
||
`REWARD_REDEMPTION` sudah ada di `walletTypeRules` dan CHECK database, tapi belum pernah
|
||
dipakai.
|
||
6. **Wallet sudah mendukung semua kebutuhan dasar:** `Credit` / `Debit` dengan lock per
|
||
customer, lot FIFO berdasarkan kedaluwarsa, `idempotency_key` UNIQUE dengan replay,
|
||
`origin_lot_id` untuk menelusuri asal saldo, `RefundExpiry` untuk refund.
|
||
7. **Tidak ada scheduler library.** Semua job adalah goroutine `time.NewTicker` di
|
||
`app/app.go`, aman multi-instance lewat lock wallet dan idempotency key.
|
||
8. **`TxManager.WithTransaction` tidak me-reuse transaksi di context.** Pemanggilan
|
||
bersarang membuka transaksi baru yang independen.
|
||
|
||
---
|
||
|
||
## 3. Keputusan Inti
|
||
|
||
**D1 — EnakGame memakai wallet yang sudah ada.**
|
||
Entry cost, reward, refund, dan redemption semuanya lewat `WalletProcessor.Credit` /
|
||
`Debit`. Tidak ada tabel saldo baru. Konsekuensinya, aturan K5 (setiap mutasi punya asal
|
||
dan tujuan), K6 (bilangan bulat), dan K9 (lot FIFO) otomatis berlaku untuk EnakGame.
|
||
|
||
**D2 — `games` di-extend, game lama diarsipkan, `game_plays` tidak dipakai EnakGame.**
|
||
`games` sudah dibaca customer app. Kolom yang kurang ditambahkan (§5.1). Session baru masuk
|
||
ke `game_sessions`. Game lama (spin, ferris wheel) **dihapus dari sisi produk** dan spin
|
||
dibangun ulang sebagai game EnakGame. Secara data, baris lama diarsipkan, bukan di-`DELETE`
|
||
(§14).
|
||
|
||
**D3 — Coin dipotong saat session dibuat, dalam satu transaksi.**
|
||
Sesuai PRD §10.1. Idempotency key dari header `Idempotency-Key`, mengikuti pola exchange.
|
||
|
||
**D4 — Session punya state machine yang ditegakkan dengan UPDATE bersyarat.**
|
||
`STARTED → COMPLETED | REFUNDED | EXPIRED`. Setiap transisi adalah
|
||
`UPDATE ... WHERE id = ? AND status = 'STARTED'`. Complete dan refund tidak mungkin
|
||
sama-sama berhasil untuk satu session, karena hanya satu yang mendapat baris ter-update.
|
||
|
||
**D5 — Realized cost diatribusikan ke budget lewat lot.**
|
||
Point yang dipakai menukar voucher ditelusuri lewat `wallet_lot_allocations` →
|
||
`wallet_lots.origin_lot_id` sampai ke lot pertama. Lot pertama menunjuk mutasi asalnya:
|
||
`GAME_REWARD` (EnakGame, dengan budget yang tercatat), atau `EARN` / `ADJUSTMENT` /
|
||
`MIGRATION` (bukan dari game). Hasilnya dibekukan per redemption di
|
||
`voucher_redemption_costs`.
|
||
|
||
**Hanya bagian yang berasal dari `GAME_REWARD` yang dihitung ke budget.** Point dari
|
||
belanja (`EARN`) dan sumber lain tetap dicatat atribusinya (dengan `budget_id` kosong)
|
||
untuk reporting, tetapi tidak mengurangi budget mana pun.
|
||
|
||
Alasannya: Point bersifat fungible. Customer bisa memegang Point dari belanja, dari
|
||
exchange Coin hasil game, dan dari transfer sekaligus. Tanpa penelusuran lot, sistem tidak
|
||
bisa tahu berapa bagian voucher yang benar-benar dibiayai budget EnakGame atau budget
|
||
event tertentu. Lot sudah menyimpan jejak ini sejak PRD point-coin (Q9), jadi tidak ada
|
||
perubahan struktur wallet.
|
||
|
||
**D6 — Reward per budget dicatat sebagai baris ledger terpisah.**
|
||
Satu session bisa menghasilkan reward dari budget global (reward normal) dan dari budget
|
||
event (tambahan dari multiplier/bonus event). Masing-masing menjadi satu baris
|
||
`GAME_REWARD` dengan lot sendiri, dan `game_session_rewards` mencatat budget tiap baris.
|
||
Ini yang membuat D5 bisa membedakan budget global dan event tanpa menambah kolom di
|
||
`wallet_lots`.
|
||
|
||
Dalam RFC ini **event = campaign**: istilah yang sama untuk hal yang sama.
|
||
|
||
**D7 — Reward configuration immutable.**
|
||
Baris `game_reward_configs` tidak pernah di-UPDATE kecuali kolom `status`. Perubahan
|
||
reward = baris baru dengan `version + 1`. Session menyimpan `reward_config_id` saat
|
||
**Start Game**, sehingga perubahan config tidak memengaruhi session yang sedang berjalan.
|
||
|
||
**D8 — Semua tabel baru punya `organization_id`.**
|
||
Satu organisasi = satu ekonomi EnakGame (budget, limit, voucher, game). Org customer dibaca
|
||
dari tabel `customers` seperti flow wallet sekarang, karena JWT customer tidak membawa org.
|
||
Game, voucher, dan event milik org lain ditolak.
|
||
|
||
**D9 — Budget Controller v1 hanya recommendation mode.**
|
||
Sesuai default PRD §33. Automatic mode di luar scope RFC ini.
|
||
|
||
---
|
||
|
||
## 4. Prinsip
|
||
|
||
**P1 — Backend satu-satunya penentu reward.** Client hanya mengirim `score`, `outcome`, dan
|
||
data hasil. Request yang membawa angka reward diabaikan.
|
||
|
||
**P2 — Setiap mutasi uang punya idempotency key deterministik.** Diturunkan dari id
|
||
session/redemption, bukan dari waktu. Retry selalu menghasilkan key yang sama.
|
||
|
||
**P3 — Snapshot, bukan join.** Entry cost, reward config, face value voucher, dan point cost
|
||
dibekukan di baris transaksi saat terjadi, sama seperti `unit_price` di `order_items`.
|
||
|
||
**P4 — Lock wallet customer selalu diambil lebih dulu.** Semua alur (start, complete,
|
||
refund, redeem) mengunci `customer_wallets` sebelum menyentuh tabel lain, supaya urutan
|
||
lock konsisten dan tidak deadlock.
|
||
|
||
---
|
||
|
||
## 5. Model Data
|
||
|
||
Semua migrasi mengikuti golang-migrate di `migrations/`, nomor lanjut dari `000101`.
|
||
|
||
### 5.1 `games` (extend)
|
||
|
||
```sql
|
||
ALTER TABLE games
|
||
ADD COLUMN organization_id UUID,
|
||
ADD COLUMN slug VARCHAR(100),
|
||
ADD COLUMN description TEXT,
|
||
ADD COLUMN thumbnail_url VARCHAR(500),
|
||
ADD COLUMN game_url VARCHAR(500),
|
||
ADD COLUMN version VARCHAR(50),
|
||
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
|
||
CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')),
|
||
ADD COLUMN entry_cost BIGINT,
|
||
ADD COLUMN session_ttl_seconds INT NOT NULL DEFAULT 600
|
||
CHECK (session_ttl_seconds > 0),
|
||
-- Batas validasi hasil: max_score, min_duration_seconds, max_score_per_second, outcome
|
||
-- yang valid. Dibaca Result Validator (§7.2).
|
||
ADD COLUMN result_rules JSONB NOT NULL DEFAULT '{}';
|
||
|
||
-- Game lama dihapus dari produk: diarsipkan, tidak di-DELETE (§14).
|
||
UPDATE games SET status = 'ARCHIVED', is_active = FALSE,
|
||
entry_cost = COALESCE((metadata->>'coin_cost')::bigint, 1);
|
||
|
||
ALTER TABLE games
|
||
ALTER COLUMN entry_cost SET NOT NULL,
|
||
ADD CONSTRAINT chk_games_entry_cost CHECK (entry_cost >= 1), -- PRD §10.1: tidak ada game gratis
|
||
-- Semua game EnakGame wajib punya org dan slug. Hanya arsip lama yang boleh kosong.
|
||
ADD CONSTRAINT chk_games_enakgame_identity CHECK (
|
||
status = 'ARCHIVED' OR (organization_id IS NOT NULL AND slug IS NOT NULL));
|
||
|
||
CREATE UNIQUE INDEX uq_games_org_slug ON games(organization_id, slug) WHERE slug IS NOT NULL;
|
||
CREATE INDEX idx_games_org_status ON games(organization_id, status);
|
||
```
|
||
|
||
**Catatan:**
|
||
|
||
- Baris lama tidak punya org, sehingga tidak bisa dijadikan game EnakGame. Mereka
|
||
diarsipkan dan tidak pernah tampil di endpoint EnakGame. Game baru (termasuk spin yang
|
||
dibangun ulang) dibuat sebagai baris baru dengan org.
|
||
- `is_active` tidak dipakai EnakGame dan dihapus bersama alur lama (§14). EnakGame hanya
|
||
membaca `status`.
|
||
|
||
### 5.2 `game_reward_configs`
|
||
|
||
```sql
|
||
CREATE TABLE game_reward_configs (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
organization_id UUID NOT NULL,
|
||
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
|
||
version INT NOT NULL,
|
||
reward_type VARCHAR(30) NOT NULL
|
||
CHECK (reward_type IN ('FIXED', 'SCORE_BASED', 'OUTCOME_BASED', 'PROBABILITY')),
|
||
rules JSONB NOT NULL, -- bentuk per tipe di §8
|
||
max_reward BIGINT NOT NULL CHECK (max_reward >= 0),
|
||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
|
||
CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED')),
|
||
effective_at TIMESTAMPTZ,
|
||
created_by UUID NOT NULL,
|
||
reason VARCHAR(255),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
UNIQUE (game_id, version)
|
||
);
|
||
|
||
-- Satu config aktif per game.
|
||
CREATE UNIQUE INDEX uq_game_reward_configs_active
|
||
ON game_reward_configs(game_id) WHERE status = 'ACTIVE';
|
||
```
|
||
|
||
`MULTIPLIER` tidak menjadi `reward_type` karena di PRD ia adalah modifier di atas base
|
||
reward, bukan cara menghitung base. Multiplier dan bonus hidup di `game_events` (§5.5).
|
||
|
||
### 5.3 `game_sessions`
|
||
|
||
```sql
|
||
CREATE TABLE game_sessions (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
organization_id UUID NOT NULL,
|
||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
||
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
|
||
reward_config_id UUID NOT NULL REFERENCES game_reward_configs(id), -- snapshot (D7)
|
||
entry_cost BIGINT NOT NULL CHECK (entry_cost >= 1), -- snapshot (P3)
|
||
|
||
status VARCHAR(20) NOT NULL DEFAULT 'STARTED'
|
||
CHECK (status IN ('STARTED', 'COMPLETED', 'REFUNDED', 'EXPIRED')),
|
||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
expires_at TIMESTAMPTZ NOT NULL,
|
||
ended_at TIMESTAMPTZ,
|
||
|
||
-- Hasil dari client (P1: hanya data, tanpa angka reward).
|
||
result JSONB,
|
||
-- Validasi & perhitungan: base, modifier event, cap guard, alasan penolakan, roll RNG.
|
||
reward_breakdown JSONB,
|
||
reward_total BIGINT NOT NULL DEFAULT 0 CHECK (reward_total >= 0),
|
||
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||
|
||
spend_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id),
|
||
refund_transaction_id UUID REFERENCES wallet_transactions(id),
|
||
refund_reason VARCHAR(30)
|
||
CHECK (refund_reason IN ('SYSTEM_ERROR', 'GAME_DEACTIVATED')),
|
||
-- Diisi saat complete gagal karena error sistem (5xx), di transaksi terpisah (§7.3).
|
||
completion_failed_at TIMESTAMPTZ,
|
||
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
CONSTRAINT chk_game_sessions_refund CHECK (
|
||
(status = 'REFUNDED') = (refund_transaction_id IS NOT NULL AND refund_reason IS NOT NULL))
|
||
);
|
||
|
||
CREATE INDEX idx_game_sessions_customer ON game_sessions(customer_id, started_at DESC);
|
||
CREATE INDEX idx_game_sessions_open ON game_sessions(expires_at) WHERE status = 'STARTED';
|
||
CREATE INDEX idx_game_sessions_game_open ON game_sessions(game_id) WHERE status = 'STARTED';
|
||
```
|
||
|
||
`balance_before` yang diminta PRD §19 tidak perlu kolom: `balance_after - amount` di
|
||
`wallet_transactions` sudah memberikannya.
|
||
|
||
### 5.4 `game_session_rewards`
|
||
|
||
```sql
|
||
-- Satu baris per budget yang membiayai reward session (D6).
|
||
CREATE TABLE game_session_rewards (
|
||
session_id UUID NOT NULL REFERENCES game_sessions(id),
|
||
budget_id UUID NOT NULL REFERENCES game_budgets(id),
|
||
amount BIGINT NOT NULL CHECK (amount > 0),
|
||
wallet_transaction_id UUID NOT NULL UNIQUE REFERENCES wallet_transactions(id),
|
||
PRIMARY KEY (session_id, budget_id)
|
||
);
|
||
```
|
||
|
||
### 5.5 `game_events` dan `game_event_games`
|
||
|
||
```sql
|
||
CREATE TABLE game_events (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
organization_id UUID NOT NULL,
|
||
name VARCHAR(255) NOT NULL,
|
||
slug VARCHAR(100) NOT NULL,
|
||
description TEXT,
|
||
banner_url VARCHAR(500),
|
||
start_at TIMESTAMPTZ NOT NULL,
|
||
end_at TIMESTAMPTZ NOT NULL,
|
||
timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Jakarta',
|
||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
|
||
CHECK (status IN ('DRAFT', 'ACTIVE', 'ENDED', 'CANCELLED')),
|
||
priority INT NOT NULL DEFAULT 0,
|
||
multiplier NUMERIC(5,2) CHECK (multiplier IS NULL OR multiplier > 0),
|
||
bonus BIGINT CHECK (bonus IS NULL OR bonus > 0),
|
||
-- Budget event sendiri, terpisah dari global (§5.6). Membiayai tambahan reward
|
||
-- dari multiplier dan bonus event ini.
|
||
budget_id UUID NOT NULL REFERENCES game_budgets(id),
|
||
reward_limit BIGINT, -- PRD §35 Event Limit
|
||
user_daily_limit BIGINT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
UNIQUE (organization_id, slug),
|
||
CHECK (end_at > start_at)
|
||
);
|
||
|
||
CREATE TABLE game_event_games (
|
||
event_id UUID NOT NULL REFERENCES game_events(id) ON DELETE CASCADE,
|
||
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
|
||
PRIMARY KEY (event_id, game_id)
|
||
);
|
||
```
|
||
|
||
Event adalah campaign dalam arti PRD §7: setiap event punya budget sendiri. Base reward
|
||
tetap dibiayai budget global. Hanya selisih yang ditambahkan event (multiplier + bonus)
|
||
yang dibiayai budget event. Budget event harus ber-`scope = 'EVENT'`, ditegakkan di
|
||
processor.
|
||
|
||
### 5.6 `game_budgets`
|
||
|
||
```sql
|
||
CREATE TABLE game_budgets (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
organization_id UUID NOT NULL,
|
||
scope VARCHAR(20) NOT NULL CHECK (scope IN ('GLOBAL', 'EVENT')),
|
||
name VARCHAR(255) NOT NULL,
|
||
period_start DATE NOT NULL,
|
||
period_end DATE NOT NULL,
|
||
amount BIGINT NOT NULL CHECK (amount > 0), -- rupiah
|
||
-- {"warning": 70, "critical": 90} dalam persen utilisasi/forecast (PRD §8, §32).
|
||
thresholds JSONB NOT NULL DEFAULT '{}',
|
||
exhaustion_policy VARCHAR(30), -- PRD §34, menunggu keputusan
|
||
created_by UUID NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
CHECK (period_end >= period_start)
|
||
);
|
||
|
||
-- Budget global tidak boleh tumpang tindih di satu org: satu baris per periode.
|
||
CREATE UNIQUE INDEX uq_game_budgets_global_period
|
||
ON game_budgets(organization_id, period_start) WHERE scope = 'GLOBAL';
|
||
```
|
||
|
||
- **Global** default bulanan (PRD §5.2): `period_start` tanggal 1, `period_end` akhir bulan.
|
||
Periode lain bisa di-configure dengan mengisi rentang sendiri.
|
||
- **Event** periodenya rentang event. Realized cost dihitung ke budget event **kapan pun
|
||
Point-nya ditukar**, termasuk setelah event berakhir, karena biaya itu lahir dari reward
|
||
event tersebut.
|
||
|
||
### 5.7 Voucher
|
||
|
||
```sql
|
||
CREATE TABLE vouchers (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
organization_id UUID NOT NULL,
|
||
name VARCHAR(255) NOT NULL,
|
||
description TEXT,
|
||
image_url VARCHAR(500),
|
||
voucher_type VARCHAR(30) NOT NULL
|
||
CHECK (voucher_type IN ('FIXED_VALUE', 'PERCENTAGE', 'FREE_ITEM', 'MERCHANT_BENEFIT')),
|
||
face_value BIGINT NOT NULL CHECK (face_value > 0), -- rupiah, dasar realized cost
|
||
point_cost BIGINT NOT NULL CHECK (point_cost > 0), -- boleh beda dari face_value (PRD §22)
|
||
business_cost BIGINT, -- reporting saja, bukan budget
|
||
stock_mode VARCHAR(20) NOT NULL
|
||
CHECK (stock_mode IN ('STATIC', 'CODE_POOL', 'EXTERNAL')),
|
||
stock BIGINT CHECK (stock IS NULL OR stock >= 0), -- hanya STATIC
|
||
provider VARCHAR(50), -- hanya EXTERNAL
|
||
provider_ref VARCHAR(255),
|
||
max_per_customer INT,
|
||
valid_from TIMESTAMPTZ,
|
||
valid_until TIMESTAMPTZ,
|
||
terms JSONB NOT NULL DEFAULT '{}',
|
||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
|
||
CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
CHECK ((stock_mode = 'STATIC') = (stock IS NOT NULL)),
|
||
CHECK ((stock_mode = 'EXTERNAL') = (provider IS NOT NULL))
|
||
);
|
||
|
||
CREATE TABLE voucher_codes (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
voucher_id UUID NOT NULL REFERENCES vouchers(id) ON DELETE RESTRICT,
|
||
code VARCHAR(255) NOT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE'
|
||
CHECK (status IN ('AVAILABLE', 'RESERVED', 'REDEEMED', 'EXPIRED', 'CANCELLED')),
|
||
redemption_id UUID,
|
||
expires_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
UNIQUE (voucher_id, code),
|
||
CHECK ((status IN ('RESERVED', 'REDEEMED')) = (redemption_id IS NOT NULL))
|
||
);
|
||
|
||
CREATE INDEX idx_voucher_codes_available ON voucher_codes(voucher_id, created_at)
|
||
WHERE status = 'AVAILABLE';
|
||
|
||
CREATE TABLE voucher_redemptions (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
organization_id UUID NOT NULL,
|
||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
||
voucher_id UUID NOT NULL REFERENCES vouchers(id),
|
||
idempotency_key VARCHAR(100) NOT NULL,
|
||
status VARCHAR(20) NOT NULL
|
||
CHECK (status IN ('PENDING', 'COMPLETED', 'FAILED')),
|
||
-- Snapshot (P3).
|
||
face_value BIGINT NOT NULL,
|
||
point_cost BIGINT NOT NULL,
|
||
voucher_code_id UUID REFERENCES voucher_codes(id),
|
||
external_code VARCHAR(255),
|
||
external_ref VARCHAR(255),
|
||
debit_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id),
|
||
refund_transaction_id UUID REFERENCES wallet_transactions(id),
|
||
failure_reason VARCHAR(255),
|
||
attempts INT NOT NULL DEFAULT 0,
|
||
completed_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
UNIQUE (customer_id, idempotency_key),
|
||
CHECK ((status = 'FAILED') = (refund_transaction_id IS NOT NULL))
|
||
);
|
||
|
||
CREATE INDEX idx_voucher_redemptions_pending ON voucher_redemptions(updated_at)
|
||
WHERE status = 'PENDING';
|
||
|
||
-- Atribusi realized cost per budget, dibekukan saat redemption COMPLETED (D5).
|
||
CREATE TABLE voucher_redemption_costs (
|
||
redemption_id UUID NOT NULL REFERENCES voucher_redemptions(id),
|
||
-- NULL = Point yang bukan berasal dari EnakGame (EARN, ADJUSTMENT, MIGRATION).
|
||
budget_id UUID REFERENCES game_budgets(id),
|
||
source_type VARCHAR(30) NOT NULL, -- tipe ledger lot asal
|
||
points BIGINT NOT NULL CHECK (points > 0),
|
||
cost BIGINT NOT NULL CHECK (cost >= 0), -- rupiah, bagian dari face_value
|
||
recognized_at TIMESTAMPTZ NOT NULL, -- = completed_at, dasar periode budget
|
||
UNIQUE (redemption_id, budget_id, source_type)
|
||
);
|
||
|
||
CREATE INDEX idx_voucher_redemption_costs_budget
|
||
ON voucher_redemption_costs(budget_id, recognized_at);
|
||
```
|
||
|
||
### 5.8 Counter Economy Guard
|
||
|
||
```sql
|
||
-- Jumlah reward yang sudah diterbitkan per cakupan per hari (Asia/Jakarta).
|
||
CREATE TABLE game_reward_counters (
|
||
organization_id UUID NOT NULL,
|
||
scope_type VARCHAR(20) NOT NULL CHECK (scope_type IN ('USER', 'GAME', 'EVENT', 'GLOBAL')),
|
||
scope_id UUID NOT NULL, -- customer / game / event / organization
|
||
day DATE NOT NULL,
|
||
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
|
||
PRIMARY KEY (organization_id, scope_type, scope_id, day)
|
||
);
|
||
```
|
||
|
||
`EVENT` memakai `day = '0001-01-01'` untuk limit seumur event (PRD §35 Event Limit) dan
|
||
tanggal sebenarnya untuk `game_events.user_daily_limit`.
|
||
|
||
### 5.9 `audit_logs`
|
||
|
||
```sql
|
||
CREATE TABLE audit_logs (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
organization_id UUID NOT NULL,
|
||
actor_type VARCHAR(20) NOT NULL CHECK (actor_type IN ('USER', 'SYSTEM')),
|
||
actor_id UUID,
|
||
entity_type VARCHAR(50) NOT NULL,
|
||
entity_id UUID NOT NULL,
|
||
action VARCHAR(50) NOT NULL,
|
||
before JSONB,
|
||
after JSONB,
|
||
reason VARCHAR(255),
|
||
source VARCHAR(50) NOT NULL, -- 'admin_api', 'budget_controller', 'session_job', ...
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id, created_at DESC);
|
||
```
|
||
|
||
### 5.10 Pengaturan organisasi
|
||
|
||
Limit global dan per user disimpan di `organization_settings` (key-value, `000091`),
|
||
dikelola lewat pola `LoyaltySettingsProcessor`: field descriptor dengan default dan
|
||
min/max, advisory lock, dan riwayat otomatis di `loyalty_setting_changes`. Audit perubahan
|
||
setting gratis didapat dari situ.
|
||
|
||
| Key | Tipe | Default | Arti |
|
||
|---|---|---|---|
|
||
| `enakgame.limit.user_daily` | int ≥ 0 | 0 (tanpa batas) | Coin maksimal yang didapat satu customer per hari |
|
||
| `enakgame.limit.global_daily` | int ≥ 0 | 0 | Coin maksimal yang diterbitkan seluruh org per hari |
|
||
|
||
Perilaku saat limit terlampaui tidak di-configure: reward selalu **dipotong ke sisa limit**
|
||
(§9).
|
||
|
||
Limit per game (`Game Daily Limit`) disimpan di `games.result_rules` agar ikut di-configure
|
||
per game.
|
||
|
||
---
|
||
|
||
## 6. Ledger
|
||
|
||
### 6.1 Tipe baru dan perubahan
|
||
|
||
| Tipe | Currency | Arah | Ref | Wajib tambahan | Idempotency key | Status |
|
||
|---|---|---|---|---|---|---|
|
||
| `GAME_SPEND` | COIN | keluar | `GAME_PLAY` (lama) **atau `GAME_SESSION`** | – | `game-entry:{customer}:{key}` | ref baru |
|
||
| `GAME_SPEND_REFUND` | COIN | masuk | `GAME_SESSION` | `reverses_transaction_id` | `game-refund:{session}` | **baru** |
|
||
| `GAME_REWARD` | COIN | masuk | `GAME_SESSION` | – | `game-reward:{session}:{budget}` | **baru** |
|
||
| `REWARD_REDEEM` | POINT | keluar | `REWARD_REDEMPTION` → `voucher_redemptions.id` | – | `redeem:{redemption}` | sudah ada, mulai dipakai |
|
||
| `REWARD_REDEEM_REFUND` | POINT | masuk | `REWARD_REDEMPTION` | `reverses_transaction_id` | `redeem-refund:{redemption}` | **baru** |
|
||
|
||
Pemetaan ke konsep PRD §19: `EARNED` = `GAME_REWARD`, `SPENT` = `GAME_SPEND` /
|
||
`REWARD_REDEEM`, `REFUND` = `GAME_SPEND_REFUND` / `REWARD_REDEEM_REFUND`, `EXPIRED` =
|
||
`EXPIRE`, `CONVERSION` = `EXCHANGE_OUT` + `EXCHANGE_IN`. `BONUS` dari event tetap
|
||
`GAME_REWARD`, dibedakan lewat budget dan `reward_breakdown`.
|
||
|
||
### 6.2 Perubahan yang harus dilakukan bersamaan
|
||
|
||
Menambah tipe berarti mengubah **dua tempat** yang harus sinkron:
|
||
|
||
1. `walletTypeRules` di `processor/wallet_processor.go:487`, plus konstanta di
|
||
`constants/wallet.go` (`WalletRefTypeGameSession`).
|
||
2. CHECK di `wallet_transactions` (drop + create ulang):
|
||
- `chk_wallet_transactions_point_only_types` + `REWARD_REDEEM_REFUND`
|
||
- `chk_wallet_transactions_coin_only_types` + `GAME_SPEND_REFUND`, `GAME_REWARD`
|
||
- `chk_wallet_transactions_reversal_source` + `GAME_SPEND_REFUND`, `REWARD_REDEEM_REFUND`
|
||
|
||
Reconciliation job (`service/wallet_reconciliation_job.go`) harus diperiksa: invariant
|
||
yang menghitung per tipe perlu mengenali tipe baru.
|
||
|
||
### 6.3 Lot
|
||
|
||
| Mutasi | Lot yang dibuat |
|
||
|---|---|
|
||
| `GAME_REWARD` | Satu lot, `expires_at = ComputeExpiry(CoinExpiry, now)`, tanpa `origin_lot_id` (lot akar) |
|
||
| `GAME_SPEND_REFUND` | Satu lot per alokasi `GAME_SPEND` asal: `expires_at = RefundExpiry(lot.expires_at, now)`, `origin_lot_id = lot asal`. Sama persis dengan pola `PAYMENT_REFUND` (`point_payment_refund.go`) |
|
||
| `REWARD_REDEEM_REFUND` | Sama dengan `GAME_SPEND_REFUND`, untuk Point |
|
||
|
||
---
|
||
|
||
## 7. Alur
|
||
|
||
### 7.1 Start Game
|
||
|
||
```
|
||
POST /customer/enakgame/sessions { game_id } Idempotency-Key: <≤50 char>
|
||
```
|
||
|
||
Satu transaksi:
|
||
|
||
1. Baca customer → `organization_id`. Tolak bila game bukan milik org tersebut atau
|
||
`status <> 'ACTIVE'`.
|
||
2. Baca reward config `ACTIVE` untuk game. Tolak bila tidak ada.
|
||
3. Pastikan ada budget global untuk periode berjalan. Tolak bila belum diatur, karena
|
||
reward yang nanti diterbitkan wajib menunjuk budget (D6).
|
||
4. `LockWallet(customer)`.
|
||
5. `FindTransaction("game-entry:{customer}:{key}")`. Bila ada → kembalikan session yang
|
||
menunjuknya (replay, tidak memotong lagi).
|
||
6. Buat `session_id` baru. `Debit` COIN `GAME_SPEND`, ref `GAME_SESSION → session_id`,
|
||
amount `games.entry_cost`. Saldo kurang → `ErrWalletInsufficientBalance` → tolak tanpa
|
||
apa pun tercatat.
|
||
7. Insert `game_sessions` dengan `entry_cost`, `reward_config_id`,
|
||
`expires_at = now + session_ttl_seconds`, `spend_transaction_id`.
|
||
|
||
Response: `session_id`, `expires_at`, `entry_cost`, `coin_balance`.
|
||
|
||
### 7.2 Complete Game
|
||
|
||
```
|
||
POST /customer/enakgame/sessions/:id/complete { score?, outcome?, data? }
|
||
```
|
||
|
||
Satu transaksi:
|
||
|
||
1. `LockWallet(customer)`.
|
||
2. Baca session. Bila `customer_id` beda → 404. Bila status sudah `COMPLETED` → kembalikan
|
||
hasil yang tersimpan (idempotent, PRD §20). Bila `REFUNDED` / `EXPIRED` → tolak.
|
||
3. Bila `now > expires_at` → tolak (session dibiarkan untuk job, §7.3).
|
||
4. Bila game sudah tidak `ACTIVE` → **refund** (`GAME_DEACTIVATED`) di transaksi ini, lalu
|
||
kembalikan response "game dinonaktifkan, Coin dikembalikan".
|
||
5. **Result Validator** (`games.result_rules`): durasi minimal sejak `started_at`, skor
|
||
maksimum, skor per detik, outcome yang dikenal. Gagal → reward 0, `flagged = true`,
|
||
alasan di `reward_breakdown`. Session tetap `COMPLETED`, tidak ada refund.
|
||
6. **Reward Engine** (§8): hitung base dari config snapshot.
|
||
7. **Event modifier**: event `ACTIVE` yang mencakup game, `start_at ≤ now < end_at`.
|
||
Hasilnya daftar komponen `{budget_id, amount}`.
|
||
8. **Economy Guard** (§9): naikkan counter dengan UPDATE bersyarat, potong komponen yang
|
||
melewati limit.
|
||
9. `UPDATE game_sessions SET status='COMPLETED' ... WHERE id=? AND status='STARTED'`.
|
||
0 baris → session baru saja di-refund job → rollback, kembalikan status terbaru.
|
||
10. Untuk tiap komponen > 0: `Credit` COIN `GAME_REWARD` dengan key
|
||
`game-reward:{session}:{budget}`, lalu insert `game_session_rewards`.
|
||
|
||
Response: `reward_total`, rincian yang aman ditampilkan (base, bonus event), `coin_balance`.
|
||
|
||
### 7.3 Refund otomatis & kedaluwarsa session
|
||
|
||
PRD §10.2: refund otomatis untuk **system error** dan **game dinonaktifkan**; session yang
|
||
ditinggal user tidak di-refund.
|
||
|
||
**Mendefinisikan "system error".** Backend hanya bisa membedakan dua hal ini bila ada jejak.
|
||
Aturannya:
|
||
|
||
- Bila complete (§7.2) gagal dengan error non-bisnis (DB error, panic, timeout → HTTP 5xx),
|
||
handler menulis `completion_failed_at = now()` di **transaksi terpisah**
|
||
(`DetachTransaction`, karena transaksi utama sudah rollback).
|
||
- Error validasi (4xx) dan error yang terjadi di client (Phaser crash, koneksi putus
|
||
sebelum request sampai) **tidak** meninggalkan jejak, sehingga diperlakukan sebagai
|
||
ditinggal user dan tidak di-refund (diputuskan).
|
||
|
||
**`GameSessionJob`** (ticker, pola `WalletExpiryJob`, per batch, satu transaksi per
|
||
session):
|
||
|
||
| Kondisi session `STARTED` | Aksi |
|
||
|---|---|
|
||
| Game tidak lagi `ACTIVE` | Refund `GAME_DEACTIVATED` segera, tanpa menunggu kedaluwarsa |
|
||
| Lewat `expires_at` dan `completion_failed_at IS NOT NULL` | Refund `SYSTEM_ERROR` |
|
||
| Lewat `expires_at`, tanpa jejak gagal | `EXPIRED`, tanpa refund |
|
||
|
||
Langkah refund: `LockWallet` → `UPDATE ... SET status='REFUNDED' WHERE status='STARTED'`
|
||
(0 baris = sudah diselesaikan pihak lain, lewati) → `Credit` COIN `GAME_SPEND_REFUND`
|
||
dengan `reverses_transaction_id = spend_transaction_id`, lot sesuai §6.3, key
|
||
`game-refund:{session}` → isi `refund_transaction_id`, `refund_reason` → `audit_logs`
|
||
dengan `actor_type = SYSTEM`.
|
||
|
||
Saat admin mengubah game ke `INACTIVE`, handler tidak perlu menyapu session. Job berikutnya
|
||
menanganinya, dan complete di §7.2 langkah 4 menangani yang lebih cepat.
|
||
|
||
### 7.4 Redemption voucher internal (`STATIC`, `CODE_POOL`)
|
||
|
||
```
|
||
POST /customer/enakgame/vouchers/:id/redeem { pin } Idempotency-Key: <≤50 char>
|
||
```
|
||
|
||
Satu transaksi, sehingga state `RESERVED` tidak diperlukan:
|
||
|
||
1. Validasi voucher `ACTIVE`, dalam masa berlaku, org sama.
|
||
2. `VerifyPin(..., PinActionRedeem)` — aksi PIN baru, konsisten dengan K8 (PIN untuk setiap
|
||
pemakaian saldo). Batas salah 5 kali / kunci 30 menit yang sudah ada ikut berlaku.
|
||
3. `LockWallet(customer)`.
|
||
4. Cari `voucher_redemptions (customer_id, idempotency_key)`. Ada → kembalikan (replay).
|
||
5. Cek `max_per_customer` dari `COUNT(*)` redemption `COMPLETED` / `PENDING`.
|
||
6. Ambil stok:
|
||
- `STATIC`: `UPDATE vouchers SET stock = stock - 1 WHERE id = ? AND stock > 0`.
|
||
- `CODE_POOL`: `SELECT ... FROM voucher_codes WHERE voucher_id = ? AND status = 'AVAILABLE'
|
||
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`, lalu set `REDEEMED`.
|
||
- Habis → tolak, tidak ada yang tercatat.
|
||
7. `Debit` POINT `REWARD_REDEEM` sebesar `point_cost`, key `redeem:{redemption}`.
|
||
8. Insert `voucher_redemptions` `COMPLETED` dengan snapshot `face_value`, `point_cost`.
|
||
9. **Atribusi cost** (§7.6) → insert `voucher_redemption_costs`.
|
||
|
||
### 7.5 Redemption voucher eksternal (`EXTERNAL`)
|
||
|
||
Panggilan ke provider tidak boleh berada di dalam transaksi database, jadi alurnya dua
|
||
tahap dengan pemulihan:
|
||
|
||
1. **Transaksi 1:** langkah 1–5 dan 7 di §7.4, lalu insert redemption `PENDING`. Point sudah
|
||
terpotong.
|
||
2. **Panggil provider** dengan `redemption_id` sebagai idempotency key provider.
|
||
3. **Transaksi 2**, tergantung hasil:
|
||
- Sukses → simpan `external_code` / `external_ref`, `COMPLETED`, atribusi cost (§7.6).
|
||
- Gagal pasti (provider menolak) → `Credit` POINT `REWARD_REDEEM_REFUND`, `FAILED`.
|
||
- Timeout / tidak jelas → biarkan `PENDING`, response "sedang diproses".
|
||
4. **`VoucherRedemptionRecoveryJob`** mengambil `PENDING` yang sudah lewat N menit, bertanya
|
||
ke provider (atau mengulang dengan key yang sama), lalu menjalankan transaksi 2. Setelah
|
||
batas percobaan, refund dan `FAILED`.
|
||
|
||
Dengan ini syarat PRD §26 terpenuhi: tidak ada keadaan "Point terpotong, voucher tidak
|
||
datang" yang tidak dipulihkan.
|
||
|
||
### 7.6 Atribusi realized cost
|
||
|
||
Dijalankan di dalam transaksi redemption, setelah debit `REWARD_REDEEM`:
|
||
|
||
```sql
|
||
WITH RECURSIVE chain AS (
|
||
SELECT a.lot_id AS spent_lot, a.amount AS points, l.origin_lot_id, l.source_transaction_id
|
||
FROM wallet_lot_allocations a
|
||
JOIN wallet_lots l ON l.id = a.lot_id
|
||
WHERE a.transaction_id = :redeem_tx
|
||
UNION ALL
|
||
SELECT c.spent_lot, c.points, p.origin_lot_id, p.source_transaction_id
|
||
FROM chain c
|
||
JOIN wallet_lots p ON p.id = c.origin_lot_id
|
||
)
|
||
SELECT c.points, t.type AS source_type, gsr.budget_id
|
||
FROM chain c
|
||
JOIN wallet_transactions t ON t.id = c.source_transaction_id
|
||
LEFT JOIN game_session_rewards gsr ON gsr.wallet_transaction_id = t.id
|
||
WHERE c.origin_lot_id IS NULL; -- lot akar
|
||
```
|
||
|
||
Rantai lot akar Point bisa melewati `EXCHANGE_IN` → lot Coin → `TRANSFER_IN` → ... sampai
|
||
`GAME_REWARD` (EnakGame) atau `EARN` / `ADJUSTMENT` / `MIGRATION` (bukan EnakGame).
|
||
`exchangeLots` dan transfer sudah mengisi `origin_lot_id`, jadi query ini bekerja dengan
|
||
data yang sudah ada.
|
||
|
||
Hasil dikelompokkan per `(budget_id, source_type)`, lalu `face_value` dibagi
|
||
proporsional terhadap Point:
|
||
|
||
```
|
||
cost_i = face_value × points_i / point_cost (dibulatkan; selisih pembulatan
|
||
diberikan ke bagian terbesar, sehingga
|
||
Σ cost_i = face_value persis)
|
||
```
|
||
|
||
Contoh: voucher face value Rp10.000, point cost 8.000. Point yang dipakai: 6.000 dari
|
||
`GAME_REWARD` (budget global Oktober), 2.000 dari `EARN`.
|
||
|
||
| budget_id | source_type | points | cost |
|
||
|---|---|---|---|
|
||
| global-2026-10 | `GAME_REWARD` | 6.000 | 7.500 |
|
||
| NULL | `EARN` | 2.000 | 2.500 |
|
||
|
||
Hanya Rp7.500 yang mengurangi budget global Oktober. Rp2.500 dari Point belanja tetap
|
||
tercatat untuk reporting Finance, tetapi tidak dihitung ke budget mana pun (D5).
|
||
|
||
---
|
||
|
||
## 8. Reward Engine
|
||
|
||
Satu interface, satu implementasi per tipe:
|
||
|
||
```go
|
||
type RewardCalculator interface {
|
||
Validate(rules json.RawMessage) error // saat config dibuat
|
||
Calculate(rules json.RawMessage, result SessionResult, rng RNG) (base int64, detail map[string]any, err error)
|
||
}
|
||
```
|
||
|
||
| Tipe | `rules` | Catatan |
|
||
|---|---|---|
|
||
| `FIXED` | `{"amount": 5}` | |
|
||
| `SCORE_BASED` | `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}` | Band tidak boleh tumpang tindih atau berlubang; band terakhir boleh tanpa `max` |
|
||
| `OUTCOME_BASED` | `{"outcomes": {"PERFECT": 20, "GOOD": 10, "NORMAL": 5, "FAIL": 0}}` | Outcome di luar daftar → ditolak Result Validator |
|
||
| `PROBABILITY` | `{"table": [{"weight": 1, "amount": 1000}, {"weight": 10, "amount": 100}, {"weight": 889, "amount": 0}]}` | Bobot bilangan bulat, bukan persen desimal, supaya validasi "total = 100%" tidak bergantung float |
|
||
|
||
**PROBABILITY memakai `crypto/rand`**, bukan `math/rand` yang di-seed ulang dengan waktu
|
||
seperti `selectPrizeByWeight` sekarang. Angka acak yang ditarik disimpan di
|
||
`reward_breakdown` untuk audit. Hasil diundi saat **complete**, bukan saat start, supaya
|
||
client tidak bisa mengetahui hasil lalu meninggalkan session.
|
||
|
||
**Modifier event** (urutan tetap): `base × multiplier` → `+ bonus` → cap `max_reward` config.
|
||
Aturan tumpuk antar event mengikuti default PRD §16 (multiplier terkontrol, bonus dihitung
|
||
terpisah, cap selalu berlaku) sampai diputuskan (§19.2 #2).
|
||
|
||
**Pembulatan:** semua hasil perkalian **dibulatkan ke bawah** ke Coin utuh, mengikuti K6 di
|
||
PRD point-coin. Ini usulan untuk menutup Open Decision PRD §43 #1.
|
||
|
||
`TIERED` di luar scope: tabel `tiers` belum terhubung ke customer, dan aturan tier dibahas
|
||
di PRD terpisah (point-coin Q8).
|
||
|
||
---
|
||
|
||
## 9. Economy Guard & Limits
|
||
|
||
Pemeriksaan di §7.2 langkah 8, untuk setiap komponen reward:
|
||
|
||
1. Session valid, belum rewarded, user & game cocok — sudah dijamin §7.2 langkah 2–4 dan 9.
|
||
2. Untuk tiap limit yang berlaku (`USER` harian, `GAME` harian, `EVENT` total & per user,
|
||
`GLOBAL` harian):
|
||
|
||
```sql
|
||
INSERT INTO game_reward_counters (...) VALUES (..., 0) ON CONFLICT DO NOTHING;
|
||
UPDATE game_reward_counters SET amount = amount + :x
|
||
WHERE ... AND amount + :x <= :limit
|
||
RETURNING amount;
|
||
```
|
||
|
||
0 baris → limit terlampaui → `x` diturunkan ke sisa limit dan diulang. Sisa 0 berarti
|
||
komponen menjadi 0. Batas yang memotong dicatat di `reward_breakdown`, supaya customer
|
||
bisa diberi tahu kenapa reward-nya lebih kecil.
|
||
3. Status budget (§10) `EXHAUSTED` → terapkan `exhaustion_policy` (§19.2 #3).
|
||
|
||
Counter `USER` aman karena wallet customer sudah dikunci. Counter `GAME` / `GLOBAL` adalah
|
||
baris panas yang dikunci singkat oleh setiap complete di org tersebut. Untuk volume awal
|
||
ini dapat diterima. Lihat risiko di §17.
|
||
|
||
---
|
||
|
||
## 10. Budget & Budget Controller v1
|
||
|
||
**Metrik per budget** (PRD §8), dihitung on-read dari tabel yang ada, tanpa tabel agregat:
|
||
|
||
| Metrik | Sumber |
|
||
|---|---|
|
||
| Realized cost | `SUM(voucher_redemption_costs.cost)` untuk `budget_id`, `recognized_at` dalam periode (global) / tanpa batas waktu (event) |
|
||
| Coin issued | `SUM(game_session_rewards.amount)` untuk `budget_id` |
|
||
| Remaining | `amount − realized` |
|
||
| Utilization | `realized / amount` |
|
||
| Forecast | `realized + rata-rata realized harian 7 hari terakhir × sisa hari` |
|
||
| Exposure | Sisa Coin/Point beredar yang berasal dari budget ini (via lot), sebagai batas atas biaya yang masih bisa datang |
|
||
| Status | Bandingkan utilization dan forecast dengan `thresholds` → `HEALTHY` / `WARNING` / `CRITICAL` / `EXHAUSTED` |
|
||
|
||
**Rekomendasi** (recommendation mode, D9): bila forecast > budget, hitung multiplier yang
|
||
membuat forecast = budget, lalu batasi dengan step maksimum dan min/max multiplier (PRD
|
||
§31). Rekomendasi ditampilkan ke admin. Bila disetujui, admin membuat **reward config
|
||
versi baru** (§5.2). Tidak ada perubahan reward tanpa versi baru dan tanpa audit.
|
||
|
||
Implementasi (EG-901, EG-902):
|
||
|
||
- Hanya budget `GLOBAL` (yang membayar base reward). Budget `EVENT` ditolak; tambahan
|
||
event diatur di event-nya.
|
||
- Target = (budget − realized) / (forecast − realized), karena hanya biaya ke depan yang
|
||
ikut berubah bila reward diubah. Target dibatasi ±step dari 1 lalu dibulatkan ke bawah
|
||
ke dua desimal. Bisa turun **atau naik**.
|
||
- Multiplier tiap game diukur terhadap **base**: versi terakhir yang ditulis admin.
|
||
Versi buatan Budget Controller menyimpan `base_config_id`, `multiplier`, dan
|
||
`budget_id` (migrasi 000112), dan aturannya selalu dihitung ulang dari base (dibulatkan
|
||
ke bawah), jadi pembulatan tidak menumpuk. Min/max berlaku untuk multiplier kumulatif
|
||
ini. Admin yang menulis versi baru memulai base baru di 1.
|
||
- Cooldown per organisasi: setelah rekomendasi diterima, rekomendasi berikutnya baru bisa
|
||
diterima setelah `cooldown_days`.
|
||
- Guardrail disimpan di `game_budgets.thresholds` bersama warning/critical:
|
||
`max_step_percent` (default 10), `min_multiplier_percent` (50),
|
||
`max_multiplier_percent` (150), `cooldown_days` (7). Nilai default ini **sementara**,
|
||
menunggu §19.2 #4.
|
||
- Terima: `POST /budgets/:id/recommendation/accept` dengan `multiplier` yang dilihat
|
||
admin. Rekomendasi dihitung ulang di dalam transaksi; bila berbeda, tidak ada yang
|
||
berubah. Satu transaksi: versi lama `RETIRED`, versi baru langsung `ACTIVE`, audit
|
||
`source = budget_controller` per config dan `RECOMMENDATION_ACCEPTED` di budget.
|
||
|
||
`GET` metrik cukup cepat untuk dashboard selama index di §5.7 ada. Bila nanti lambat,
|
||
tambahkan snapshot harian, bukan cache yang di-invalidate.
|
||
|
||
---
|
||
|
||
## 11. API
|
||
|
||
### Customer (`/api/v1/customer/enakgame`, `ValidateCustomerToken`)
|
||
|
||
| Method | Path | Catatan |
|
||
|---|---|---|
|
||
| `GET` | `/games` | Game `ACTIVE` milik org customer, dengan `entry_cost` dan event aktif |
|
||
| `POST` | `/sessions` | §7.1. Wajib `Idempotency-Key` |
|
||
| `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header |
|
||
| `GET` | `/sessions/:id` | Status dan hasil |
|
||
| `GET` | `/sessions` | Riwayat main |
|
||
| `GET` | `/vouchers` | Katalog `ACTIVE` + stok tersedia |
|
||
| `POST` | `/vouchers/:id/redeem` | §7.4 / §7.5. Wajib `Idempotency-Key` + PIN |
|
||
| `GET` | `/redemptions` | Voucher milik customer, termasuk kode |
|
||
|
||
Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama.
|
||
|
||
### Admin (`/api/v1/marketing/enakgame`, `RequireAdminOrManager`)
|
||
|
||
| Resource | Endpoint |
|
||
|---|---|
|
||
| Games | CRUD, `PUT /:id/status` |
|
||
| Reward configs | `POST /games/:id/reward-configs` (versi baru), `POST /reward-configs/:id/activate`, `GET` daftar versi |
|
||
| Events | CRUD, `PUT /:id/status` |
|
||
| Budgets | CRUD, `GET /:id/metrics`, `GET /:id/recommendation`, `POST /:id/recommendation/accept` |
|
||
| Analytics | `GET /analytics/games?from=&to=&game_id=`, `GET /analytics/economy?from=&to=` (tanggal Asia/Jakarta, maks 366 hari) |
|
||
| Vouchers | CRUD, `POST /:id/codes` (impor CSV), `GET /:id/codes` |
|
||
| Redemptions | `GET` list, `GET /:id` dengan atribusi cost |
|
||
| Sessions | `GET` list + filter `flagged` |
|
||
| Settings | Lewat endpoint loyalty settings yang ada, dengan key baru (§5.10) |
|
||
|
||
Perubahan budget, voucher, dan reward config memerlukan `RequireLoyaltyManager`, sama
|
||
seperti adjustment saldo.
|
||
|
||
---
|
||
|
||
## 12. Background Jobs
|
||
|
||
Semua mengikuti pola goroutine + `time.NewTicker` di `app/app.go`, satu transaksi per item,
|
||
aman dijalankan di beberapa instance karena setiap item dikunci lewat UPDATE bersyarat.
|
||
|
||
| Job | Interval | Tugas |
|
||
|---|---|---|
|
||
| `GameSessionJob` | 1 menit | §7.3: refund / expire session `STARTED` |
|
||
| `VoucherRedemptionRecoveryJob` | 1 menit | §7.5: selesaikan `PENDING` eksternal |
|
||
| `VoucherCodeExpiryJob` | 1 jam | `AVAILABLE → EXPIRED` untuk kode lewat `expires_at` |
|
||
| `GameBudgetPeriodJob` | 1 hari | Membuat baris budget global bulan berikutnya dari bulan berjalan, bila belum ada |
|
||
|
||
---
|
||
|
||
## 13. Audit
|
||
|
||
`audit_logs` diisi untuk semua perubahan yang disebut PRD §37: reward config (buat,
|
||
aktifkan, pensiunkan), event, budget, voucher (termasuk impor kode), status game, refund
|
||
otomatis, dan penerimaan rekomendasi Budget Controller. Ditulis di transaksi yang sama
|
||
dengan perubahannya, sehingga tidak ada perubahan tanpa jejak.
|
||
|
||
Pengaturan limit sudah ter-audit lewat `loyalty_setting_changes` (§5.10). Adjustment saldo
|
||
sudah ter-audit lewat ledger `ADJUSTMENT`.
|
||
|
||
---
|
||
|
||
## 14. Legacy & Migrasi
|
||
|
||
| Bagian lama | Nasib |
|
||
|---|---|
|
||
| Baris `games` lama | Diarsipkan (`status = 'ARCHIVED'`), **tidak di-`DELETE`**. FK `game_plays.game_id` adalah `ON DELETE CASCADE`: menghapus game ikut menghapus `game_plays`, padahal ledger `GAME_SPEND` lama menunjuk ke sana lewat `reference_id`. Jejak asal-tujuan saldo (K5) akan putus |
|
||
| `POST /customer/spin`, `GET /customer/games`, `GET /customer/ferris-wheel` | Dihapus. Spin dibangun ulang sebagai game EnakGame dengan config `PROBABILITY` dan reward Coin |
|
||
| `game_prizes`, `game_plays` | Dibekukan (read-only). Data tetap disimpan untuk riwayat ledger `GAME_PLAY`. Kode processor/handler/route-nya dihapus |
|
||
| `rewards` | Diganti `vouchers`. Tidak punya org, tidak punya redemption, tidak dipakai flow mana pun, dan tidak punya data produksi (dikonfirmasi), sehingga tidak ada migrasi data |
|
||
| `campaigns`, `campaign_rules` | Tidak dipakai EnakGame. Campaign EnakGame adalah `game_events` |
|
||
| Bayar order dengan EnakPoint | **Dimatikan sebelum EnakGame rilis** (PRD §3.2). Belum ada order yang dibayar dengan Point, jadi tidak ada data yang dimigrasi. Yang dilepas: route point payment, `payment_methods` tipe `point` beserta trigger pembuatnya (`000094`), dan setting `loyalty.point.accept_payment`. Dikerjakan sebagai task terpisah |
|
||
|
||
---
|
||
|
||
## 15. Temuan Sampingan
|
||
|
||
Ditemukan saat memetakan code. Tidak memblokir RFC ini, tapi sebagian berdampak ke uang:
|
||
|
||
1. **Double charge di `/customer/spin`.** `GAME_SPEND` di `PlayGame` tanpa idempotency key.
|
||
2. **`/customer/spin` menerima game id mana pun**, tanpa cek tipe dan tanpa cek org
|
||
(`spin_game_service.go:27`). Customer org A bisa memainkan game org B.
|
||
|
||
Nomor 1 dan 2 hilang sendiri saat alur lama dihapus (§14). Bila penghapusannya tidak
|
||
segera, endpoint lama sebaiknya dimatikan lebih dulu daripada ditambal.
|
||
3. **RNG hadiah** di-seed ulang dengan `UnixNano` setiap panggilan (`game_play_processor.go:287`).
|
||
4. **`threshold` dan `fallback_prize_id`** di `game_prizes` tidak pernah dipakai.
|
||
5. **`GET /customer/ferris-wheel`** mengembalikan `First()` dari game SPIN aktif tanpa urutan,
|
||
sehingga game yang dikembalikan tidak pasti.
|
||
6. **`rewards`**: create menolak tipe `BALANCE`, update menerimanya, database tidak punya
|
||
CHECK.
|
||
7. **`campaigns`**: `GetActiveCampaigns` mengikat string `"now()"` sebagai parameter tanggal
|
||
(`campaign_repository.go:114`). Belum diverifikasi apakah Postgres menerimanya.
|
||
8. **`tiers.name` UNIQUE global**, bukan per org.
|
||
|
||
---
|
||
|
||
## 16. Di Luar Scope
|
||
|
||
- **Mission** (PRD §3.1, §19). PRD belum mendefinisikan aturannya.
|
||
- **Leaderboard** (PRD §15).
|
||
- **Reward `TIERED`** (§8).
|
||
- **Budget Controller automatic mode** (D9).
|
||
- **Hosting dan build Phaser.** RFC ini hanya mendefinisikan API yang dipanggil game.
|
||
|
||
---
|
||
|
||
## 17. Urutan Implementasi
|
||
|
||
1. **Matikan bayar dengan EnakPoint** (§14). Independen, bisa paralel.
|
||
2. **Migrasi skema**, dengan urutan mengikuti foreign key: extend `games`,
|
||
`game_budgets`, `game_reward_configs`, `game_sessions`, `game_session_rewards`,
|
||
`audit_logs`; tipe ledger baru + CHECK (§6.2).
|
||
3. **Session + entry cost** (§7.1) dan **refund + `GameSessionJob`** (§7.3).
|
||
4. **Reward Engine** `FIXED`, `SCORE_BASED`, `OUTCOME_BASED`, `PROBABILITY` + Result
|
||
Validator + complete (§7.2, §8).
|
||
5. **Economy Guard + limits** (§9, §5.10).
|
||
6. **Voucher internal + redemption + atribusi cost** (§7.4, §7.6).
|
||
7. **Budget metrics + status** (§10).
|
||
8. **Events / campaign** dengan budget sendiri (§5.5).
|
||
9. **Voucher eksternal + recovery job** (§7.5). Butuh provider pertama yang konkret.
|
||
10. **Rekomendasi Budget Controller + analytics** (§10, PRD §36).
|
||
11. **Spin dibangun ulang sebagai game EnakGame**, lalu hapus kode alur lama dan arsipkan
|
||
datanya (§14). Endpoint lama bisa dimatikan lebih awal, kapan pun.
|
||
|
||
Langkah 2–4 sudah membuat game bisa dimainkan end-to-end dengan Coin. Langkah 6 membuat
|
||
Point bisa ditukar. Langkah 7 membuat Finance bisa melihat biaya.
|
||
|
||
---
|
||
|
||
## 18. Risiko
|
||
|
||
| Risiko | Dampak | Mitigasi |
|
||
|---|---|---|
|
||
| Satu dari dua tempat aturan ledger (§6.2) terlewat | Mutasi ditolak di produksi, atau lolos tanpa validasi | Test per tipe di `wallet_processor_test.go` + test DB yang benar-benar insert ke `wallet_transactions` |
|
||
| Farming lewat banyak akun + transfer Coin | Limit harian per user dilewati, karena Coin hasil game boleh ditransfer (diputuskan) | Setting transfer yang ada (`Transfer.DailyLimit`, `MaxPerTransaction`); pantau `GAME_REWARD` yang langsung diikuti `TRANSFER_OUT` di analytics |
|
||
| Counter `GLOBAL` / `GAME` jadi bottleneck | Complete melambat saat ramai | Diukur dulu. Bila perlu, pecah counter per shard dan jumlahkan saat cek |
|
||
| Rantai `origin_lot_id` panjang | Query atribusi lambat | Rantai praktis pendek (reward → exchange → transfer). Bila perlu, tambah kolom `root_lot_id` di `wallet_lots` |
|
||
| Provider eksternal tidak idempotent | Voucher terbit dua kali saat recovery | Syarat integrasi: provider wajib menerima idempotency key; bila tidak, recovery hanya boleh *query*, tidak boleh mengulang |
|
||
| Client mengirim skor palsu | Coin terbit tanpa main | Result Validator (§7.2) + `flagged`; reward tidak pernah dari client (P1) |
|
||
| Game lama di-`DELETE` alih-alih diarsipkan | `game_plays` ikut terhapus (CASCADE), ledger `GAME_SPEND` lama kehilangan tujuan | Migrasi §5.1 mengarsipkan; `chk_games_enakgame_identity` mencegah arsip lama tampil sebagai game EnakGame |
|
||
|
||
---
|
||
|
||
## 19. Keputusan & Pertanyaan Terbuka
|
||
|
||
### 19.1 Sudah Diputuskan (2026-10-07)
|
||
|
||
| # | Pertanyaan | Keputusan | Tercermin di |
|
||
|---|---|---|---|
|
||
| Q1 | Point dari belanja (`EARN`) yang ditukar voucher masuk budget EnakGame? | **Tidak.** Hanya Point yang berasal dari `GAME_REWARD` | D5, §7.6 |
|
||
| Q2 | Campaign itu apa? | **Campaign = event.** Setiap event punya budget sendiri | D6, §5.5, §5.6 |
|
||
| Q3 | Spin dan game lama? | **Dibangun ulang** sebagai game EnakGame | §14, §17 |
|
||
| Q4 | Baris `games` lama? | **Dihapus dari produk** (diarsipkan secara data, lihat §14) | §5.1, §14 |
|
||
| Q5 | Reward melewati limit? | **Dipotong ke sisa limit** | §9 |
|
||
| Q6 | Coin hasil game boleh ditransfer? | **Boleh** | §18 |
|
||
| Q7 | PIN untuk redemption voucher? | **Ya** | §7.4 |
|
||
| Q8 | Definisi "system error" untuk refund? | **Hanya error yang tercatat di server** (5xx saat complete). Crash di client = ditinggal user | §7.3 |
|
||
|
||
### 19.2 Masih Terbuka
|
||
|
||
Dari PRD §43:
|
||
|
||
1. **Pembulatan reward.** Usulan §8: bulatkan ke bawah, mengikuti K6.
|
||
2. **Event stacking.** Sementara memakai default PRD §16.
|
||
3. **Budget exhaustion policy**, untuk budget global dan budget event.
|
||
4. **Threshold Budget Controller.** Sementara memakai default di §10 (step 10%,
|
||
multiplier 50%–150%, cooldown 7 hari), bisa diubah per budget.
|
||
5. **Timeout reservasi voucher.** Dengan §7.4, hanya relevan untuk voucher eksternal.
|
||
|
||
Tidak ada yang memblokir langkah 1–7 di §17. Nomor 3 dan 4 harus diputuskan sebelum langkah
|
||
7 (budget status) dirilis.
|