EnakGame: pay EnakCoin to play, earn EnakCoin from the result, exchange into EnakPoint, and redeem EnakPoint for vouchers only. - enakgame-prd.md: economy and business rules, including entry cost and automatic refund, monthly global budget with a separate budget per event (event = campaign), and EnakPoint being voucher-only. - rfc-enakgame.md: built on the existing wallet, ledger and lots. Game sessions with a state machine, versioned reward configs, Economy Guard counters, vouchers with internal codes and external providers, and realized cost attributed to budgets by tracing the lots spent. - tasks-enakgame.md: EG-001 to EG-1003 in eleven phases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
45 KiB
RFC: EnakGame — Game Session, Reward, Voucher & Budget
Status: Draft
Tanggal: 2026-10-07
PRD: 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), 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:
- Tabel game, reward, campaign, dan tier tidak punya
organization_id. Semua query membaca semua tenant. Tabel wallet (000090) sudah punya. - Main game sekarang tidak punya session.
GamePlayProcessor.PlayGame(processor/game_play_processor.go:141) memotong Coin, memilih hadiah, dan mencatatgame_playsdalam satu request. - Hadiah game tidak memberi apa pun. Prize hanya tercatat sebagai
game_plays.prize_iddan teks deskripsi ledger. Tidak ada kredit Coin/Point, tidak ada voucher. GAME_SPENDtanpa idempotency key (game_play_processor.go:186). Tombol main yang ditekan dua kali memotong Coin dua kali.- Tidak ada alur penukaran. Tipe ledger
REWARD_REDEEMdan refREWARD_REDEMPTIONsudah ada diwalletTypeRulesdan CHECK database, tapi belum pernah dipakai. - Wallet sudah mendukung semua kebutuhan dasar:
Credit/Debitdengan lock per customer, lot FIFO berdasarkan kedaluwarsa,idempotency_keyUNIQUE dengan replay,origin_lot_iduntuk menelusuri asal saldo,RefundExpiryuntuk refund. - Tidak ada scheduler library. Semua job adalah goroutine
time.NewTickerdiapp/app.go, aman multi-instance lewat lock wallet dan idempotency key. TxManager.WithTransactiontidak 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)
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_activetidak dipakai EnakGame dan dihapus bersama alur lama (§14). EnakGame hanya membacastatus.
5.2 game_reward_configs
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
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
-- 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
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
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_starttanggal 1,period_endakhir 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
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
-- 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
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:
walletTypeRulesdiprocessor/wallet_processor.go:487, plus konstanta diconstants/wallet.go(WalletRefTypeGameSession).- CHECK di
wallet_transactions(drop + create ulang):chk_wallet_transactions_point_only_types+REWARD_REDEEM_REFUNDchk_wallet_transactions_coin_only_types+GAME_SPEND_REFUND,GAME_REWARDchk_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:
- Baca customer →
organization_id. Tolak bila game bukan milik org tersebut ataustatus <> 'ACTIVE'. - Baca reward config
ACTIVEuntuk game. Tolak bila tidak ada. - Pastikan ada budget global untuk periode berjalan. Tolak bila belum diatur, karena reward yang nanti diterbitkan wajib menunjuk budget (D6).
LockWallet(customer).FindTransaction("game-entry:{customer}:{key}"). Bila ada → kembalikan session yang menunjuknya (replay, tidak memotong lagi).- Buat
session_idbaru.DebitCOINGAME_SPEND, refGAME_SESSION → session_id, amountgames.entry_cost. Saldo kurang →ErrWalletInsufficientBalance→ tolak tanpa apa pun tercatat. - Insert
game_sessionsdenganentry_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:
LockWallet(customer).- Baca session. Bila
customer_idbeda → 404. Bila status sudahCOMPLETED→ kembalikan hasil yang tersimpan (idempotent, PRD §20). BilaREFUNDED/EXPIRED→ tolak. - Bila
now > expires_at→ tolak (session dibiarkan untuk job, §7.3). - Bila game sudah tidak
ACTIVE→ refund (GAME_DEACTIVATED) di transaksi ini, lalu kembalikan response "game dinonaktifkan, Coin dikembalikan". - Result Validator (
games.result_rules): durasi minimal sejakstarted_at, skor maksimum, skor per detik, outcome yang dikenal. Gagal → reward 0,flagged = true, alasan direward_breakdown. Session tetapCOMPLETED, tidak ada refund. - Reward Engine (§8): hitung base dari config snapshot.
- Event modifier: event
ACTIVEyang mencakup game,start_at ≤ now < end_at. Hasilnya daftar komponen{budget_id, amount}. - Economy Guard (§9): naikkan counter dengan UPDATE bersyarat, potong komponen yang melewati limit.
UPDATE game_sessions SET status='COMPLETED' ... WHERE id=? AND status='STARTED'. 0 baris → session baru saja di-refund job → rollback, kembalikan status terbaru.- Untuk tiap komponen > 0:
CreditCOINGAME_REWARDdengan keygame-reward:{session}:{budget}, lalu insertgame_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:
- Validasi voucher
ACTIVE, dalam masa berlaku, org sama. 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.LockWallet(customer).- Cari
voucher_redemptions (customer_id, idempotency_key). Ada → kembalikan (replay). - Cek
max_per_customerdariCOUNT(*)redemptionCOMPLETED/PENDING. - 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 setREDEEMED.- Habis → tolak, tidak ada yang tercatat.
DebitPOINTREWARD_REDEEMsebesarpoint_cost, keyredeem:{redemption}.- Insert
voucher_redemptionsCOMPLETEDdengan snapshotface_value,point_cost. - 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:
- Transaksi 1: langkah 1–5 dan 7 di §7.4, lalu insert redemption
PENDING. Point sudah terpotong. - Panggil provider dengan
redemption_idsebagai idempotency key provider. - Transaksi 2, tergantung hasil:
- Sukses → simpan
external_code/external_ref,COMPLETED, atribusi cost (§7.6). - Gagal pasti (provider menolak) →
CreditPOINTREWARD_REDEEM_REFUND,FAILED. - Timeout / tidak jelas → biarkan
PENDING, response "sedang diproses".
- Sukses → simpan
VoucherRedemptionRecoveryJobmengambilPENDINGyang sudah lewat N menit, bertanya ke provider (atau mengulang dengan key yang sama), lalu menjalankan transaksi 2. Setelah batas percobaan, refund danFAILED.
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:
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:
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:
-
Session valid, belum rewarded, user & game cocok — sudah dijamin §7.2 langkah 2–4 dan 9.
-
Untuk tiap limit yang berlaku (
USERharian,GAMEharian,EVENTtotal & per user,GLOBALharian):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 →
xditurunkan ke sisa limit dan diulang. Sisa 0 berarti komponen menjadi 0. Batas yang memotong dicatat direward_breakdown, supaya customer bisa diberi tahu kenapa reward-nya lebih kecil. -
Status budget (§10)
EXHAUSTED→ terapkanexhaustion_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:
-
Double charge di
/customer/spin.GAME_SPENDdiPlayGametanpa idempotency key. -
/customer/spinmenerima 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.
-
RNG hadiah di-seed ulang dengan
UnixNanosetiap panggilan (game_play_processor.go:287). -
thresholddanfallback_prize_iddigame_prizestidak pernah dipakai. -
GET /customer/ferris-wheelmengembalikanFirst()dari game SPIN aktif tanpa urutan, sehingga game yang dikembalikan tidak pasti. -
rewards: create menolak tipeBALANCE, update menerimanya, database tidak punya CHECK. -
campaigns:GetActiveCampaignsmengikat string"now()"sebagai parameter tanggal (campaign_repository.go:114). Belum diverifikasi apakah Postgres menerimanya. -
tiers.nameUNIQUE 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
- Matikan bayar dengan EnakPoint (§14). Independen, bisa paralel.
- 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). - Session + entry cost (§7.1) dan refund +
GameSessionJob(§7.3). - Reward Engine
FIXED,SCORE_BASED,OUTCOME_BASED,PROBABILITY+ Result Validator + complete (§7.2, §8). - Economy Guard + limits (§9, §5.10).
- Voucher internal + redemption + atribusi cost (§7.4, §7.6).
- Budget metrics + status (§10).
- Events / campaign dengan budget sendiri (§5.5).
- Voucher eksternal + recovery job (§7.5). Butuh provider pertama yang konkret.
- Rekomendasi Budget Controller + analytics (§10, PRD §36).
- 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:
- Pembulatan reward. Usulan §8: bulatkan ke bawah, mengikuti K6.
- Event stacking. Sementara memakai default PRD §16.
- Budget exhaustion policy, untuk budget global dan budget event.
- Threshold Budget Controller.
- 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.