Feat/disable point payment #42
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,981 @@
|
||||
# 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.
|
||||
|
||||
`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` |
|
||||
| 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.**
|
||||
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.
|
||||
@@ -0,0 +1,503 @@
|
||||
# Task Breakdown: EnakGame
|
||||
|
||||
**Sumber:** [RFC EnakGame](rfc-enakgame.md), [PRD EnakGame](enakgame-prd.md)
|
||||
**Tanggal:** 2026-10-07
|
||||
|
||||
Setiap task menyebut bagian RFC yang dikerjakan, lapisan kode yang disentuh, task yang
|
||||
harus selesai lebih dulu, dan kriteria selesai. Ukuran: **S** ≤ 1 hari, **M** 2–3 hari,
|
||||
**L** 4–5 hari.
|
||||
|
||||
Konvensi kode mengikuti yang sudah ada: `migrations/` (lanjut dari `000101`),
|
||||
`entities` → `repository` → `processor` → `service` → `handler` / `validator` →
|
||||
`router`, dan wiring di `internal/app/app.go`. Repository **selalu** memakai
|
||||
`DBFromContext`. Semua mutasi saldo **hanya** lewat `WalletProcessor`.
|
||||
|
||||
---
|
||||
|
||||
## Ringkasan
|
||||
|
||||
| Fase | Task | Terblokir oleh |
|
||||
|---|---|---|
|
||||
| 0. Matikan bayar dengan EnakPoint | EG-001 – EG-002 | – |
|
||||
| 1. Fondasi | EG-101 – EG-106 | – |
|
||||
| 2. Admin game & session | EG-201 – EG-206 | – |
|
||||
| 3. Reward Engine & complete | EG-301 – EG-303 | Pembulatan (RFC §19.2 #1) sebelum **rilis** |
|
||||
| 4. Economy Guard | EG-401 – EG-402 | – |
|
||||
| 5. Voucher & redemption | EG-501 – EG-507 | – |
|
||||
| 6. Budget metrics | EG-601 – EG-602 | Exhaustion policy & threshold (#3, #4) sebelum **rilis** |
|
||||
| 7. Event / campaign | EG-701 – EG-704 | Event stacking (#2) sebelum **rilis** |
|
||||
| 8. Voucher eksternal | EG-801 – EG-803 | Provider pertama yang konkret sebelum **dikerjakan** |
|
||||
| 9. Budget Controller & analytics | EG-901 – EG-903 | Threshold (#4) sebelum **dikerjakan** |
|
||||
| 10. Spin & bersih-bersih | EG-1001 – EG-1003 | – |
|
||||
|
||||
Fase 0 independen dan bisa jalan paralel dengan semua fase lain, tetapi **harus selesai
|
||||
sebelum EnakGame rilis** (PRD §3.2).
|
||||
|
||||
Fase 1–3 membuat game bisa dimainkan end-to-end dengan Coin. Fase 5 membuat Point bisa
|
||||
ditukar. Fase 6 membuat Finance bisa melihat biaya.
|
||||
|
||||
```
|
||||
Fase 0 EG-001 ── EG-002
|
||||
|
||||
Fondasi EG-101 ── EG-102 ── EG-105
|
||||
EG-103, EG-104, EG-106, EG-301 (independen)
|
||||
|
||||
Game EG-104 + EG-105 ─────────────────── EG-201, EG-203
|
||||
EG-104 + EG-105 + EG-301 ────────── EG-202
|
||||
EG-103 + EG-201 + EG-202 + EG-203 ─ EG-204 ── EG-205
|
||||
EG-204 + EG-205 + EG-301 + EG-302 ─ EG-303
|
||||
EG-106 ── EG-401
|
||||
EG-303 + EG-401 ─────────────────── EG-402
|
||||
|
||||
Voucher EG-501 ── EG-502
|
||||
EG-103 + EG-502 + EG-503 ────────── EG-504
|
||||
EG-303 + EG-504 ─────────────────── EG-505 ── EG-601 ─┬─ EG-602
|
||||
└─ EG-901 ── EG-902
|
||||
EG-504 ── EG-801
|
||||
EG-505 + EG-801 ─────────────────── EG-802 ── EG-803
|
||||
|
||||
Event EG-102 ── EG-701
|
||||
EG-203 + EG-701 ─────────────────── EG-702
|
||||
EG-402 + EG-702 ─────────────────── EG-703
|
||||
|
||||
Akhir EG-402 ── EG-1001 ── EG-1002 ── EG-1003
|
||||
```
|
||||
|
||||
Task kecil yang tidak digambar: EG-206, EG-302 (setelah EG-105), EG-506 (EG-504), EG-507
|
||||
(EG-501), EG-704 (EG-206 + EG-702), EG-903 (EG-303 + EG-505).
|
||||
|
||||
---
|
||||
|
||||
## Fase 0 — Matikan Bayar dengan EnakPoint
|
||||
|
||||
PRD §3.2: Point hanya bisa ditukar ke voucher. Belum ada order yang dibayar dengan Point,
|
||||
jadi tidak ada data yang dimigrasi.
|
||||
|
||||
### EG-001 · Tutup semua jalur pembayaran EnakPoint · M
|
||||
- **RFC:** §14
|
||||
- **Kerjakan:**
|
||||
- Sudah dikonfirmasi belum ada pembayaran dengan EnakPoint di production (2026-10-07).
|
||||
- Migrasi: drop trigger yang membuat payment method `point` untuk organisasi baru
|
||||
(`000094`), lalu nonaktifkan / hapus baris `payment_methods` tipe `point`.
|
||||
- Tolak pembayaran bertipe `point` di jalur POS (`order_handler`, `order_processor`,
|
||||
`payment_method_processor`) dan customer app (`customer_order_payment_service`).
|
||||
- Hapus route `GET /orders/:id/point-payment/preview` dan
|
||||
`POST /customer/wallet/payment-code`.
|
||||
- Hapus `PAYMENT` dan `PAYMENT_REFUND` dari `walletTypeRules`, supaya engine menolak
|
||||
keduanya. CHECK database dibiarkan.
|
||||
- Hapus setting `loyalty.point.accept_payment` dari descriptor
|
||||
`LoyaltySettingsProcessor` dan dari response outlet customer.
|
||||
- **Selesai jika:** pembayaran order dengan method `point` ditolak di POS dan customer app,
|
||||
outlet baru tidak lagi mendapat payment method EnakPoint, dan test yang ada diperbarui.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-002 · Hapus kode point payment · S
|
||||
- **Kerjakan:** hapus `PointPaymentProcessor`, `point_payment_refund.go`,
|
||||
`PointPaymentRepository`, `PointPaymentService`, `PointPaymentHandler`, wiring di
|
||||
`app.go` / `router.go`, dan test-nya (`point_payment_db_test.go`,
|
||||
`point_payment_method_db_test.go`). Pindahkan dulu helper yang ternyata dipakai di
|
||||
tempat lain (mis. pola pembuatan lot refund, yang dipakai ulang di EG-205).
|
||||
- **Selesai jika:** `go build ./...` dan seluruh test lulus, dan
|
||||
`grep -rn "PointPayment" internal/` kosong.
|
||||
- **Bergantung pada:** EG-001
|
||||
|
||||
---
|
||||
|
||||
## Fase 1 — Fondasi
|
||||
|
||||
### EG-101 · Migrasi extend `games` + arsipkan game lama · S
|
||||
- **RFC:** §5.1, §14
|
||||
- **Kerjakan:** migrasi up & down persis seperti §5.1. Semua baris lama menjadi
|
||||
`ARCHIVED`. **Tidak ada `DELETE`** (FK `game_plays` CASCADE).
|
||||
- **Selesai jika:**
|
||||
- Up/down bersih di database kosong dan di salinan staging.
|
||||
- Jumlah baris `game_plays` sebelum = sesudah.
|
||||
- Ditolak database: game `ACTIVE` tanpa `organization_id` atau `slug`;
|
||||
`entry_cost = 0`; dua game dengan slug sama di satu org.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-102 · Migrasi budget, reward config, session · M
|
||||
- **RFC:** §5.2, §5.3, §5.4, §5.6
|
||||
- **Kerjakan:** `game_budgets`, `game_reward_configs`, `game_sessions`,
|
||||
`game_session_rewards`, dalam urutan foreign key.
|
||||
- **Selesai jika:** up/down bersih. Ditolak database: dua config `ACTIVE` untuk satu game;
|
||||
session `REFUNDED` tanpa `refund_transaction_id`; dua budget `GLOBAL` dengan
|
||||
`period_start` sama di satu org; `game_session_rewards` dengan
|
||||
`wallet_transaction_id` yang sama dua kali.
|
||||
- **Bergantung pada:** EG-101
|
||||
|
||||
### EG-103 · Tipe ledger baru · M
|
||||
- **RFC:** §6
|
||||
- **Kerjakan:**
|
||||
- Konstanta `GAME_SPEND_REFUND`, `GAME_REWARD`, `REWARD_REDEEM_REFUND`, ref
|
||||
`GAME_SESSION` di `constants/wallet.go`.
|
||||
- `walletTypeRules`: tambah tiga tipe, dan izinkan ref `GAME_SESSION` pada
|
||||
`GAME_SPEND`.
|
||||
- Migrasi: drop + create ulang tiga CHECK di `wallet_transactions` (§6.2).
|
||||
- Periksa invariant di `wallet_reconciliation_repository.go` terhadap tipe baru.
|
||||
- **Selesai jika:** test validasi di `wallet_processor_test.go` per tipe baru (currency
|
||||
salah, ref salah, reversal tanpa `reverses_transaction_id` → ditolak), dan test DB yang
|
||||
benar-benar insert ke `wallet_transactions` untuk setiap tipe (lolos engine **dan**
|
||||
CHECK).
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-104 · `audit_logs` + helper · S
|
||||
- **RFC:** §5.9, §13
|
||||
- **Kerjakan:** migrasi, entity, dan `AuditLogger.Record(ctx, entry)` yang menulis lewat
|
||||
`DBFromContext`, sehingga audit ikut commit/rollback bersama perubahannya.
|
||||
- **Selesai jika:** perubahan yang di-rollback tidak meninggalkan baris audit.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-105 · Entities & repository EnakGame · M
|
||||
- **RFC:** §5.1–§5.6
|
||||
- **Kerjakan:** entities dan repository untuk kolom baru `games`, `game_reward_configs`,
|
||||
`game_sessions`, `game_session_rewards`, `game_budgets`. Transisi status session
|
||||
sebagai `UPDATE ... WHERE status = 'STARTED'` yang mengembalikan jumlah baris
|
||||
ter-update (D4). Semua query game/budget memfilter `organization_id`.
|
||||
- **Selesai jika:** test repository: dua transisi bersamaan pada session yang sama, hanya
|
||||
satu yang mendapat 1 baris.
|
||||
- **Bergantung pada:** EG-101, EG-102
|
||||
|
||||
### EG-106 · Setting limit EnakGame · S
|
||||
- **RFC:** §5.10
|
||||
- **Kerjakan:** key `enakgame.limit.user_daily` dan `enakgame.limit.global_daily` sebagai
|
||||
field descriptor di `LoyaltySettingsProcessor` (default 0 = tanpa batas, min 0).
|
||||
- **Selesai jika:** setting terbaca dengan default, bisa diubah lewat endpoint loyalty
|
||||
settings yang ada, dan perubahan tercatat di `loyalty_setting_changes`.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
---
|
||||
|
||||
## Fase 2 — Admin Game & Session
|
||||
|
||||
### EG-201 · Admin game CRUD · M
|
||||
- **RFC:** §11 (admin)
|
||||
- **Kerjakan:** `/marketing/enakgame/games` CRUD + `PUT /:id/status`, org dari user admin.
|
||||
Validasi `slug`, `entry_cost ≥ 1`, `result_rules`. Game `ARCHIVED` tidak bisa diubah.
|
||||
Perubahan status masuk `audit_logs`.
|
||||
- **Selesai jika:** admin org A tidak bisa melihat atau mengubah game org B; game lama
|
||||
(arsip) tidak muncul.
|
||||
- **Bergantung pada:** EG-104, EG-105
|
||||
|
||||
### EG-202 · Admin reward config versioning · M
|
||||
- **RFC:** §5.2, D7
|
||||
- **Kerjakan:** `POST /games/:id/reward-configs` (versi baru, `rules` divalidasi
|
||||
`RewardCalculator.Validate`), `POST /reward-configs/:id/activate` (dalam satu transaksi:
|
||||
config lama → `RETIRED`, yang baru → `ACTIVE`), `GET` daftar versi. Tidak ada endpoint
|
||||
update atau delete. Wajib `RequireLoyaltyManager`. Semua aksi masuk `audit_logs`.
|
||||
- **Selesai jika:** config yang sudah dipakai session tidak bisa diubah lewat jalur apa
|
||||
pun; aktivasi bersamaan dua versi berakhir dengan tepat satu `ACTIVE`.
|
||||
- **Bergantung pada:** EG-104, EG-105, EG-301
|
||||
|
||||
### EG-203 · Admin budget + `GameBudgetPeriodJob` · M
|
||||
- **RFC:** §5.6, §12
|
||||
- **Kerjakan:** CRUD `/marketing/enakgame/budgets` (scope `GLOBAL` / `EVENT`), wajib
|
||||
`RequireLoyaltyManager`, audit. Job harian yang membuat budget global bulan berikutnya
|
||||
dari bulan berjalan bila belum ada.
|
||||
- **Selesai jika:** job aman dijalankan berulang (unique index mencegah duplikat), dan
|
||||
perubahan `amount` tercatat sebelum/sesudah di audit.
|
||||
- **Bergantung pada:** EG-104, EG-105
|
||||
|
||||
### EG-204 · Start Game · M
|
||||
- **RFC:** §7.1
|
||||
- **Kerjakan:** `POST /customer/enakgame/sessions` dengan `Idempotency-Key` wajib (≤ 50
|
||||
karakter, pola `customer_wallet_handler.go`). Langkah persis §7.1.
|
||||
- **Selesai jika:** test untuk:
|
||||
- Key yang sama dua kali → satu debit, session yang sama dikembalikan.
|
||||
- Coin kurang → ditolak, tidak ada ledger maupun session.
|
||||
- Game org lain, game tidak `ACTIVE`, tanpa config aktif, tanpa budget global → ditolak.
|
||||
- `entry_cost` dan `reward_config_id` tersimpan sebagai snapshot: mengubah game setelah
|
||||
start tidak mengubah session.
|
||||
- **Bergantung pada:** EG-103, EG-105, EG-201, EG-202, EG-203
|
||||
|
||||
### EG-205 · Refund otomatis + `GameSessionJob` · M
|
||||
- **RFC:** §7.3, §6.3
|
||||
- **Kerjakan:**
|
||||
- `RefundSession(ctx, sessionID, reason)`: lock wallet → transisi bersyarat ke
|
||||
`REFUNDED` → `Credit` `GAME_SPEND_REFUND` dengan lot per alokasi asal
|
||||
(`RefundExpiry`, `origin_lot_id`) → audit `SYSTEM`.
|
||||
- Job tiap 1 menit, per batch, satu transaksi per session, sesuai tabel §7.3.
|
||||
- Wiring start/stop di `app.go` seperti `WalletExpiryJob`.
|
||||
- **Selesai jika:** test untuk:
|
||||
- Game dinonaktifkan → session `STARTED` di-refund tanpa menunggu kedaluwarsa.
|
||||
- Expired dengan `completion_failed_at` → refund. Expired tanpa → `EXPIRED`, saldo tetap.
|
||||
- Lot refund punya `origin_lot_id` ke lot asal dan `expires_at` minimal 7 hari.
|
||||
- Complete dan refund bersamaan pada session yang sama → tepat satu yang berhasil.
|
||||
- Job dijalankan dua kali → tidak ada refund ganda.
|
||||
- **Bergantung pada:** EG-204
|
||||
|
||||
### EG-206 · Customer: daftar game & riwayat session · S
|
||||
- **RFC:** §11 (customer)
|
||||
- **Kerjakan:** `GET /customer/enakgame/games`, `GET /sessions`, `GET /sessions/:id`. Hanya
|
||||
game `ACTIVE` milik org customer. Rincian internal (`reward_breakdown` lengkap, angka
|
||||
RNG) tidak ditampilkan.
|
||||
- **Bergantung pada:** EG-105
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Reward Engine & Complete
|
||||
|
||||
### EG-301 · `RewardCalculator` + empat tipe · M
|
||||
- **RFC:** §8
|
||||
- **Kerjakan:** interface `Validate` / `Calculate`, implementasi `FIXED`, `SCORE_BASED`,
|
||||
`OUTCOME_BASED`, `PROBABILITY`. RNG lewat interface yang diisi `crypto/rand` di
|
||||
production dan RNG tetap di test. Pembulatan ke bawah.
|
||||
- **Selesai jika:** unit test: contoh tabel PRD §12 untuk setiap tipe; band skor
|
||||
tumpang tindih / berlubang ditolak `Validate`; bobot `PROBABILITY` nol atau negatif
|
||||
ditolak; distribusi `PROBABILITY` pada 100.000 undian berada dalam toleransi bobotnya.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-302 · Result Validator · S
|
||||
- **RFC:** §7.2 langkah 5
|
||||
- **Kerjakan:** validasi `result` terhadap `games.result_rules`: durasi minimal, skor
|
||||
maksimum, skor per detik, outcome yang dikenal. Mengembalikan alasan, bukan error.
|
||||
- **Selesai jika:** unit test per aturan, termasuk game tanpa `result_rules` (semua lolos).
|
||||
- **Bergantung pada:** EG-105
|
||||
|
||||
### EG-303 · Complete Game · L
|
||||
- **RFC:** §7.2, §7.3 (bagian `completion_failed_at`)
|
||||
- **Kerjakan:**
|
||||
- `POST /customer/enakgame/sessions/:id/complete`, langkah §7.2 dengan base reward dari
|
||||
budget global saja. Event menyusul di EG-703, guard di EG-402.
|
||||
- Satu baris `GAME_REWARD` + `game_session_rewards` per budget, key
|
||||
`game-reward:{session}:{budget}`. Lot dengan `ComputeExpiry(CoinExpiry)`.
|
||||
- Game tidak `ACTIVE` saat complete → `RefundSession(GAME_DEACTIVATED)`.
|
||||
- Error non-bisnis → tulis `completion_failed_at` di transaksi terpisah
|
||||
(`DetachTransaction`), lalu kembalikan 5xx.
|
||||
- **Selesai jika:** test untuk:
|
||||
- Complete dua kali → satu reward, response kedua sama dengan yang pertama.
|
||||
- Hasil tidak valid → reward 0, `flagged`, session `COMPLETED`, tanpa refund.
|
||||
- Session milik customer lain → 404. Session expired → ditolak.
|
||||
- Error yang disuntikkan setelah kredit → rollback total, `completion_failed_at` terisi.
|
||||
- Request yang membawa field `reward` diabaikan (P1).
|
||||
- **Bergantung pada:** EG-204, EG-205, EG-301, EG-302
|
||||
|
||||
---
|
||||
|
||||
## Fase 4 — Economy Guard
|
||||
|
||||
### EG-401 · Counter reward + increment bersyarat · M
|
||||
- **RFC:** §5.8, §9
|
||||
- **Kerjakan:** migrasi `game_reward_counters`; repository `Consume(scope, id, day, x,
|
||||
limit)` yang mengembalikan jumlah yang **benar-benar** diterima (dipotong ke sisa
|
||||
limit, 0 bila habis). Hari dihitung di Asia/Jakarta.
|
||||
- **Selesai jika:** test DB: 20 goroutine menambah counter yang sama dengan limit 100 →
|
||||
total tepat 100, tidak pernah lewat.
|
||||
- **Bergantung pada:** EG-106
|
||||
|
||||
### EG-402 · Guard di complete · S
|
||||
- **RFC:** §9
|
||||
- **Kerjakan:** panggil `Consume` untuk `USER` dan `GLOBAL` harian (dari setting) dan
|
||||
`GAME` harian (dari `result_rules`) sebelum kredit. Batas yang memotong dicatat di
|
||||
`reward_breakdown` dan dikembalikan di response.
|
||||
- **Selesai jika:** reward 30 dengan sisa limit user 10 → kredit 10, breakdown menyebut
|
||||
limit user. Sisa 0 → session `COMPLETED` dengan reward 0.
|
||||
- **Bergantung pada:** EG-303, EG-401
|
||||
|
||||
---
|
||||
|
||||
## Fase 5 — Voucher & Redemption
|
||||
|
||||
### EG-501 · Migrasi voucher · M
|
||||
- **RFC:** §5.7
|
||||
- **Kerjakan:** `vouchers`, `voucher_codes`, `voucher_redemptions`,
|
||||
`voucher_redemption_costs`.
|
||||
- **Selesai jika:** up/down bersih. Ditolak database: voucher `STATIC` tanpa `stock`;
|
||||
`EXTERNAL` tanpa `provider`; kode `REDEEMED` tanpa `redemption_id`; dua redemption dengan
|
||||
`(customer_id, idempotency_key)` yang sama.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-502 · Admin voucher + impor kode · M
|
||||
- **RFC:** §11 (admin)
|
||||
- **Kerjakan:** CRUD `/marketing/enakgame/vouchers`, `POST /:id/codes` (CSV, kode duplikat
|
||||
dilewati dan dilaporkan), `GET /:id/codes` dengan jumlah per status. `RequireLoyaltyManager`
|
||||
dan audit.
|
||||
- **Selesai jika:** impor CSV yang sama dua kali tidak menggandakan kode.
|
||||
- **Bergantung pada:** EG-104, EG-501
|
||||
|
||||
### EG-503 · Aksi PIN `REDEEM` · S
|
||||
- **RFC:** §7.4 langkah 2
|
||||
- **Kerjakan:** `PinActionRedeem` di `customer_pin_processor.go`, ikut aturan kunci yang
|
||||
ada (5 salah, 30 menit).
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-504 · Redemption internal · L
|
||||
- **RFC:** §7.4
|
||||
- **Kerjakan:** `POST /customer/enakgame/vouchers/:id/redeem` untuk `STATIC` dan
|
||||
`CODE_POOL`, satu transaksi, langkah persis §7.4. Debit `REWARD_REDEEM` dengan key
|
||||
`redeem:{redemption}`.
|
||||
- **Selesai jika:** test untuk:
|
||||
- Stok tersisa 1, dua customer menukar bersamaan → tepat satu berhasil.
|
||||
- `CODE_POOL`: dua redemption bersamaan mendapat kode berbeda (`SKIP LOCKED`).
|
||||
- Key yang sama dua kali → satu debit, satu kode.
|
||||
- Point kurang, PIN salah, `max_per_customer` tercapai, voucher di luar masa berlaku →
|
||||
ditolak tanpa apa pun tercatat.
|
||||
- **Bergantung pada:** EG-103, EG-501, EG-502, EG-503
|
||||
|
||||
### EG-505 · Atribusi realized cost · M
|
||||
- **RFC:** §7.6, D5
|
||||
- **Kerjakan:** query rekursif §7.6 di dalam transaksi redemption, pembagian
|
||||
`face_value` proporsional dengan sisa pembulatan ke bagian terbesar, insert
|
||||
`voucher_redemption_costs`.
|
||||
- **Selesai jika:** test DB untuk rantai: `GAME_REWARD` → exchange → redeem;
|
||||
`GAME_REWARD` → transfer → exchange → redeem; campuran `GAME_REWARD` + `EARN` (contoh
|
||||
§7.6: Rp7.500 ke budget, Rp2.500 tanpa budget); `Σ cost = face_value` untuk kombinasi
|
||||
angka yang tidak habis dibagi.
|
||||
- **Bergantung pada:** EG-303, EG-504
|
||||
|
||||
### EG-506 · Customer: katalog & voucher saya · S
|
||||
- **RFC:** §11 (customer)
|
||||
- **Kerjakan:** `GET /customer/enakgame/vouchers` (stok tersedia, tanpa membocorkan
|
||||
jumlah kode per status) dan `GET /redemptions` (dengan kode voucher).
|
||||
- **Bergantung pada:** EG-504
|
||||
|
||||
### EG-507 · `VoucherCodeExpiryJob` · S
|
||||
- **RFC:** §12
|
||||
- **Kerjakan:** `AVAILABLE → EXPIRED` untuk kode lewat `expires_at`, per batch.
|
||||
- **Bergantung pada:** EG-501
|
||||
|
||||
---
|
||||
|
||||
## Fase 6 — Budget Metrics
|
||||
|
||||
### EG-601 · Perhitungan metrik & status budget · M
|
||||
- **RFC:** §10
|
||||
- **Kerjakan:** realized cost, Coin issued, remaining, utilization, forecast, exposure,
|
||||
dan status per budget. Global dibatasi periode; event tanpa batas waktu. Threshold dari
|
||||
`game_budgets.thresholds`.
|
||||
- **Selesai jika:** test dengan data tetap menghasilkan angka contoh PRD §30 (budget
|
||||
Rp100M, realized Rp60M, sisa 10 hari); Point dari `EARN` tidak ikut dihitung.
|
||||
- **Bergantung pada:** EG-505
|
||||
|
||||
### EG-602 · Endpoint metrik budget · S
|
||||
- **Kerjakan:** `GET /marketing/enakgame/budgets/:id/metrics`.
|
||||
- **Bergantung pada:** EG-601
|
||||
|
||||
---
|
||||
|
||||
## Fase 7 — Event / Campaign
|
||||
|
||||
### EG-701 · Migrasi event · S
|
||||
- **RFC:** §5.5
|
||||
- **Kerjakan:** `game_events`, `game_event_games`.
|
||||
- **Selesai jika:** up/down bersih; event tanpa `budget_id` atau dengan
|
||||
`end_at ≤ start_at` ditolak.
|
||||
- **Bergantung pada:** EG-102
|
||||
|
||||
### EG-702 · Admin event · M
|
||||
- **RFC:** §5.5, §11
|
||||
- **Kerjakan:** CRUD + status. Budget yang dipasang harus milik org yang sama dan ber-scope
|
||||
`EVENT`. Audit.
|
||||
- **Bergantung pada:** EG-203, EG-701
|
||||
|
||||
### EG-703 · Modifier event di complete · M
|
||||
- **RFC:** §7.2 langkah 7, §8 (modifier), D6
|
||||
- **Kerjakan:** cari event aktif untuk game, hitung tambahan dari multiplier dan bonus,
|
||||
pisahkan menjadi komponen per budget, terapkan `reward_limit` dan `user_daily_limit`
|
||||
event lewat counter `EVENT`, cap `max_reward`. Aturan tumpuk mengikuti default PRD §16.
|
||||
- **Selesai jika:** contoh PRD §7 (10 Coin + Ramadan 2x) menghasilkan dua baris
|
||||
`GAME_REWARD`: 10 ke budget global, 10 ke budget event; event di luar jam aktif tidak
|
||||
berpengaruh.
|
||||
- **Bergantung pada:** EG-402, EG-702
|
||||
|
||||
### EG-704 · Event di daftar game customer · S
|
||||
- **Kerjakan:** `GET /customer/enakgame/games` menyertakan event aktif per game (nama,
|
||||
banner, multiplier/bonus, berakhir kapan).
|
||||
- **Bergantung pada:** EG-206, EG-702
|
||||
|
||||
---
|
||||
|
||||
## Fase 8 — Voucher Eksternal
|
||||
|
||||
Terblokir sampai provider pertama ditentukan: API-nya menentukan bentuk adapter dan
|
||||
apakah provider menerima idempotency key (RFC §18).
|
||||
|
||||
### EG-801 · Interface provider + adapter pertama · M
|
||||
- **Kerjakan:** `VoucherProvider` (`Issue(ctx, redemptionID, voucher)`,
|
||||
`Lookup(ctx, redemptionID)`), adapter provider pertama, dan klasifikasi hasil: sukses,
|
||||
gagal pasti, tidak jelas.
|
||||
- **Bergantung pada:** EG-504
|
||||
|
||||
### EG-802 · Redemption dua tahap · M
|
||||
- **RFC:** §7.5
|
||||
- **Kerjakan:** jalur `EXTERNAL` di endpoint redeem: transaksi 1 (`PENDING` + debit),
|
||||
panggil provider di luar transaksi, transaksi 2 (`COMPLETED` + atribusi, atau `FAILED` +
|
||||
`REWARD_REDEEM_REFUND`).
|
||||
- **Selesai jika:** test dengan provider palsu untuk ketiga hasil; timeout meninggalkan
|
||||
`PENDING` dengan Point terpotong dan response "sedang diproses".
|
||||
- **Bergantung pada:** EG-505, EG-801
|
||||
|
||||
### EG-803 · `VoucherRedemptionRecoveryJob` · M
|
||||
- **RFC:** §7.5 langkah 4, §12
|
||||
- **Kerjakan:** ambil `PENDING` lewat N menit, `Lookup` ke provider, selesaikan transaksi 2.
|
||||
Setelah batas percobaan → refund + `FAILED`.
|
||||
- **Selesai jika:** tidak ada redemption yang tertinggal `PENDING` melewati batas
|
||||
percobaan; job berjalan dua kali tidak merefund dua kali.
|
||||
- **Bergantung pada:** EG-802
|
||||
|
||||
---
|
||||
|
||||
## Fase 9 — Budget Controller & Analytics
|
||||
|
||||
### EG-901 · Rekomendasi multiplier · M
|
||||
- **RFC:** §10 (rekomendasi), PRD §30–§31
|
||||
- **Kerjakan:** hitung multiplier yang membuat forecast = budget, dibatasi step maksimum dan
|
||||
min/max multiplier. `GET /budgets/:id/recommendation`.
|
||||
- **Bergantung pada:** EG-601
|
||||
|
||||
### EG-902 · Terima rekomendasi · S
|
||||
- **Kerjakan:** admin menerima rekomendasi → reward config versi baru dibuat dari config
|
||||
aktif dengan angka yang disesuaikan, tercatat di audit dengan `source = budget_controller`.
|
||||
- **Bergantung pada:** EG-202, EG-901
|
||||
|
||||
### EG-903 · Analytics · M
|
||||
- **RFC:** PRD §36
|
||||
- **Kerjakan:** endpoint dashboard game (plays, completed, rata-rata skor & reward, Coin
|
||||
issued, entry cost dibayar, Coin di-refund) dan economy (Coin generated / spent /
|
||||
expired / outstanding, Point redeemed), per org dan rentang tanggal.
|
||||
- **Bergantung pada:** EG-303, EG-505
|
||||
|
||||
---
|
||||
|
||||
## Fase 10 — Spin & Bersih-bersih
|
||||
|
||||
### EG-1001 · Spin sebagai game EnakGame · S
|
||||
- **RFC:** §14
|
||||
- **Kerjakan:** seeder / langkah admin untuk game spin baru dengan config `PROBABILITY` dan
|
||||
reward Coin. Client Phaser di luar repo ini.
|
||||
- **Selesai jika:** spin bisa dimainkan lewat `/customer/enakgame/sessions` end-to-end.
|
||||
- **Bergantung pada:** EG-402
|
||||
|
||||
### EG-1002 · Hapus alur game lama · M
|
||||
- **RFC:** §14, §15
|
||||
- **Kerjakan:** hapus route `POST /customer/spin`, `GET /customer/games`,
|
||||
`GET /customer/ferris-wheel`, admin `/marketing/games`, `/marketing/game-prizes`, dan
|
||||
`/marketing/rewards`, beserta handler, service, processor, dan test-nya
|
||||
(`GamePlayProcessor`, `SpinGameService`, dan seterusnya). **Tabel `games`,
|
||||
`game_prizes`, `game_plays` tetap ada** untuk riwayat ledger.
|
||||
- **Selesai jika:** build dan test lulus; aplikasi customer sudah tidak memanggil endpoint
|
||||
lama (konfirmasi tim aplikasi).
|
||||
- **Bergantung pada:** EG-1001. Tabel `rewards` tidak punya data produksi (dikonfirmasi
|
||||
2026-10-07), jadi tidak ada yang dipindah ke `vouchers`.
|
||||
- **Catatan:** endpoint customer lama boleh dimatikan **lebih awal**, kapan pun, untuk
|
||||
menutup temuan RFC §15 nomor 1 dan 2.
|
||||
|
||||
### EG-1003 · Bersihkan kolom lama `games` · S
|
||||
- **Kerjakan:** drop `games.is_active` dan berhenti membaca `metadata.coin_cost`.
|
||||
- **Bergantung pada:** EG-1002
|
||||
|
||||
---
|
||||
|
||||
## Yang Bisa Dimulai Sekarang
|
||||
|
||||
Bisa dikerjakan paralel tanpa menunggu apa pun:
|
||||
|
||||
- **EG-001** (matikan bayar dengan EnakPoint) → **EG-002**
|
||||
- **EG-101** → **EG-102** → **EG-105** (skema & repository)
|
||||
- **EG-103** (tipe ledger)
|
||||
- **EG-104** (audit), **EG-106** (setting limit)
|
||||
- **EG-301** (Reward Engine, kode murni tanpa database)
|
||||
- **EG-501**, **EG-503** (fondasi voucher)
|
||||
|
||||
Jalur kritis: **EG-105 → EG-204 → EG-303 → EG-402**. Hampir semua fase setelahnya
|
||||
menunggu complete game selesai.
|
||||
Reference in New Issue
Block a user