Files
apskel-pos-backend/docs/rfc-enakgame.md
efrilmandClaude Opus 5.5 3ebc09f818 docs(enakgame): PRD, RFC, and task breakdown
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>
2026-10-07 13:48:17 +07:00

45 KiB
Raw Permalink Blame History

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:

  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)

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

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_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

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:

  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:

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:

  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):

    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.