From 3ebc09f818ee8a315df957aac9aaf698a8ca8479 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 7 Oct 2026 13:48:17 +0700 Subject: [PATCH 1/2] 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 --- docs/enakgame-prd.md | 1622 ++++++++++++++++++++++++++++++++++++++++ docs/rfc-enakgame.md | 981 ++++++++++++++++++++++++ docs/tasks-enakgame.md | 503 +++++++++++++ 3 files changed, 3106 insertions(+) create mode 100644 docs/enakgame-prd.md create mode 100644 docs/rfc-enakgame.md create mode 100644 docs/tasks-enakgame.md diff --git a/docs/enakgame-prd.md b/docs/enakgame-prd.md new file mode 100644 index 0000000..a995e45 --- /dev/null +++ b/docs/enakgame-prd.md @@ -0,0 +1,1622 @@ +# EnakGame — Economy & Business Rules v1 + +| Item | Value | +| --------------------------- | ----------------------------------------------------------------------------------- | +| **Status** | Draft v1 | +| **Purpose** | Source of truth untuk perencanaan dan implementasi EnakGame menggunakan Claude Code | +| **Backend** | Go | +| **Database** | PostgreSQL | +| **Game Client** | Phaser | +| **Frontend/Backoffice** | Next.js | +| **Cache/Temporary Session** | Redis (opsional, sesuai kebutuhan implementasi) | + +--- + +## 1. Product Overview + +EnakGame adalah game portal dalam ekosistem Enaklo/F&B. + +- User membayar **Coin** untuk memainkan mini-game (entry cost), dan dapat memperoleh **Coin** sebagai virtual reward dari hasil permainan. +- Coin dapat dikonversi/digunakan sebagai **Point**, kemudian Point **hanya** dapat ditukar ke voucher yang disediakan Enaklo. +- Point **tidak** dapat digunakan sebagai alat pembayaran dan **tidak** dapat diuangkan. +- EnakGame **bukan** platform gambling dan **tidak** menggunakan uang tunai sebagai hadiah dari game. + +### Core Flow + +```text +User + ↓ +Game Lobby + ↓ +Start Game + ↓ +Pay Entry Cost (Coin) + Create Game Session + ↓ +Play Game + ↓ +Submit Result + ↓ +Result Validation + ↓ +Reward Engine + ↓ +Economy Guard + ↓ +Coin Wallet + Ledger + ↓ +Coin → Point + ↓ +Voucher Redemption + ↓ +Realized Voucher Cost + ↓ +Budget Controller +``` + +--- + +## 2. Core Business Principles + +### 2.1 Server Is Authoritative + +Client/game (Phaser) **tidak boleh** menentukan reward final. + +Client hanya mengirim hasil permainan yang diperlukan, misalnya: + +- `session_id` +- `score` +- `outcome` +- game-specific result data + +Backend menghitung reward berdasarkan configuration yang aktif. + +#### ❌ Tidak boleh + +```text +Phaser: + score = 800 + reward = 1000 Coin +→ backend menerima 1000 Coin +``` + +#### ✅ Yang benar + +```text +Phaser: + score = 800 + +Backend: + score 800 + → load active reward configuration + → calculate reward + → validate limits + → issue approved Coin +``` + +--- + +## 3. Currency Model + +EnakGame memiliki dua konsep utama: **Coin** dan **Point**. + +### 3.1 Coin + +Coin adalah **virtual game/reward currency**. + +Sumber utama: + +- Game reward +- Event reward +- Mission reward +- Bonus/promo yang diizinkan + +Penggunaan: + +- Membayar entry cost untuk memainkan game (lihat [Section 10.1](#101-game-entry-cost)) +- Dikonversi ke Point + +Coin dapat memiliki expiration (lihat [Section 4](#4-coin-expiration)). + +### 3.2 Point + +Point adalah **redemption currency**. + +Current business rule: + +```text +1 Coin = 1 Point = Rp1 +``` + +Penggunaan Point dibatasi: + +- Point **hanya** dapat ditukar ke voucher (lihat [Section 26](#26-redemption-flow)). +- Point **tidak** dapat digunakan sebagai alat pembayaran (misalnya membayar order). +- Point **tidak** dapat diuangkan (cash out). + +Nilai `Rp1` di atas adalah nilai acuan untuk perhitungan voucher dan budget, **bukan** nilai tukar ke uang tunai. + +Konversi dan expiration mengikuti sistem existing ([prd-point-coin.md](prd-point-coin.md)), tidak didesain ulang: + +- **Coin → Point** menggunakan fitur exchange existing (K3, F4): dilakukan manual oleh customer, satu arah, kurs di-configure di level organisasi (default 1 : 1). +- **Point expiration** menggunakan sistem expiration existing per lot (F12). + +> **Catatan perubahan dari sistem existing:** Berdasarkan keputusan sebelumnya ([prd-point-coin.md](prd-point-coin.md)), backend saat ini masih mengizinkan EnakPoint dipakai untuk **membayar order** (point payment, ledger `PAYMENT` / `PAYMENT_REFUND`). Aturan EnakGame di atas menggantikan keputusan tersebut, sehingga fitur point payment perlu di-update/dinonaktifkan sebelum EnakGame berjalan. Belum ada order yang dibayar dengan Point, jadi tidak ada data lama yang perlu dimigrasi. Saldo Point user tetap sebagai Point; yang berubah hanya penggunaannya (redeem voucher saja). + +Namun Coin dan Point tetap diperlakukan sebagai **konsep/domain yang berbeda** agar sistem tidak terlalu tightly coupled. + +Tujuannya agar business rule di masa depan dapat berubah tanpa membongkar Game/Reward Engine. + +--- + +## 4. Coin Expiration + +Sistem Coin sudah memiliki konsep expiration. Expiration harus tetap menjadi bagian dari economy. + +Coin yang expired: + +```text +Wallet usable balance + ↓ +berkurang + +Ledger + ↓ +mencatat expiration transaction +``` + +> **Penting:** Expiration **tidak boleh** dianggap sebagai voucher redemption. + +Kategori transaksi harus dapat dibedakan: + +```text +Wallet usable balance + ↓ +berkurang + +Ledger + ↓ +mencatat expiration transaction +``` + +Implementasi detail expiration mengikuti sistem existing dan tidak perlu didesain ulang kecuali diperlukan. + +--- + +## 5. Budget Model + +### 5.1 Budget Is Shared + +Semua game menggunakan **budget pool yang sama**. Tidak ada kewajiban setiap game memiliki budget terpisah. + +Contoh: + +```text +EnakGame Global Budget +Rp100.000.000 +│ +├── Runner +├── Spin +├── Memory +└── Puzzle +``` + +Semua reward normal game berasal dari economy/budget pool yang sama. + +Pengecualian: **Event/Campaign** memiliki budget sendiri (lihat [Section 7](#7-budget-hierarchy)). + +### 5.2 Budget Period + +- Global budget menggunakan periode **bulanan** secara default. +- Periode budget dapat di-**configure**. +- Setiap Event/Campaign memiliki budget sendiri, terpisah dari global budget bulanan. + +--- + +## 6. Budget vs Realized Cost + +### 6.1 Budget Is Based on Actual Voucher Redemption + +Budget Controller menggunakan **voucher yang benar-benar diredeem** sebagai dasar actual cost. + +Coin yang baru diterbitkan **bukan** otomatis dianggap sebagai biaya voucher. + +Contoh: + +| Metric | Jumlah | +| ------------------- | ---------- | +| Coin Generated | 20.000.000 | +| Coin Outstanding | 12.000.000 | +| Coin Expired | 3.000.000 | +| Coin Used/Converted | 5.000.000 | + +```text +Actual Voucher Cost = Rp5.000.000 +``` + +Karena `1 Point = Rp1` dan Point digunakan untuk redemption. + +### 6.1.1 Hanya Point dari EnakGame yang Dihitung ke Budget + +Satu voucher dapat dibayar dengan Point dari berbagai asal: reward game, belanja (earning order), transfer, atau adjustment. + +Realized cost yang dihitung ke budget EnakGame (global maupun event) **hanya** bagian voucher yang dibayar dengan Point yang berasal dari **reward EnakGame**. Bagian yang dibayar dengan Point dari belanja tetap dicatat untuk reporting Finance, tetapi **tidak** mengurangi budget mana pun. + +```text +Voucher face value = Rp10.000, point cost = 8.000 +Point dipakai = 6.000 (dari reward game) + 2.000 (dari belanja) + +Dihitung ke budget = Rp7.500 +Tidak ke budget = Rp2.500 +``` + +### 6.2 Important Distinction + +Sistem harus membedakan: + +| Konsep | Definisi | +| ----------------- | ------------------------------------------------------------ | +| **Issuance** | Berapa Coin yang dikeluarkan oleh Reward Engine | +| **Outstanding** | Berapa Coin masih berada di user/economy | +| **Redemption** | Berapa Point benar-benar digunakan untuk mendapatkan voucher | +| **Realized Cost** | Nilai voucher yang benar-benar menjadi cost bisnis | + +Budget Controller terutama menggunakan **realized redemption/cost**, bukan sekadar total Coin issuance. + +--- + +## 7. Budget Hierarchy + +Secara konsep: + +```text +GLOBAL BUDGET (bulanan, configurable) +│ +└── Regular Game Activity (reward normal semua game) + +EVENT/CAMPAIGN BUDGET (per event, terpisah) +│ +└── Tambahan reward dari event (multiplier + bonus) +``` + +**Event dan Campaign adalah hal yang sama.** Setiap event memiliki **budget sendiri**, terpisah dari global budget. + +Pembagian biaya saat event berjalan: + +| Komponen reward | Dibiayai oleh | +| ------------------------------------ | --------------- | +| Reward normal game (base reward) | Global budget | +| Tambahan dari event (multiplier/bonus) | Budget event | + +Contoh: + +```text +Normal Reward 10 Coin → Global budget +Ramadan 2x +10 Coin → Budget event Ramadan +────────────────────────── +Final Reward 20 Coin +``` + +Realized cost dari Point yang berasal dari tambahan event dihitung ke budget event tersebut, **kapan pun** Point itu ditukar ke voucher, termasuk setelah event berakhir. + +--- + +## 8. Budget Metrics + +Minimum metric yang harus tersedia: + +- Total Budget +- Allocated Budget +- Realized Cost +- Remaining Budget +- Budget Utilization +- Forecasted Cost +- Forecasted Remaining Budget +- Budget Status + +Contoh status: + +- `HEALTHY` +- `WARNING` +- `CRITICAL` +- `EXHAUSTED` + +Threshold harus **configurable**. + +--- + +## 9. Game Management + +Game Management mengelola semua game yang tersedia di EnakGame. + +Minimum information: + +- `id` +- `name` +- `slug` +- `description` +- `thumbnail` +- game URL/path +- `version` +- `status` +- `configuration` +- `created_at` +- `updated_at` + +Game status minimal: + +- `DRAFT` +- `ACTIVE` +- `INACTIVE` +- `ARCHIVED` + +Game harus dapat memiliki reward configuration. + +--- + +## 10. Game Session + +Setiap permainan yang dapat menghasilkan reward **harus** memiliki session. + +Flow: + +```text +User + ↓ +Start Game + ↓ +Debit Entry Cost (Coin) + Create Game Session + ↓ +Phaser Game + ↓ +Submit Result + ↓ +Validate Session + ↓ +Calculate Reward + ↓ +Issue Reward +``` + +Session harus dapat mencegah: + +- duplicate completion +- duplicate reward +- expired session +- invalid session +- user mismatch +- game mismatch + +Recommended concept: `game_session_id` menjadi **idempotency key** untuk reward completion. + +> **Penting:** Satu session tidak boleh menghasilkan reward berkali-kali. + +### 10.1 Game Entry Cost + +Semua game **wajib berbayar** menggunakan **Coin**. Tidak ada game gratis. + +- Entry cost di-configure per game sebagai bagian dari game configuration, dengan nilai minimal **1 Coin**. +- Entry cost dibayar dengan **Coin**, bukan Point. +- Coin dipotong saat **Start Game**, yaitu saat session dibuat — bukan saat submit result. +- Debit Coin dan pembuatan session terjadi dalam **satu database transaction**. Jika salah satu gagal, tidak ada Coin yang terpotong dan tidak ada session yang terbuat. +- Jika usable balance Coin tidak cukup, session **tidak** dibuat dan user tidak dapat bermain. +- Entry cost yang berlaku dicatat di session (snapshot), sehingga perubahan configuration tidak mengubah session yang sudah berjalan. +- Debit dicatat di ledger sebagai `SPENT` dengan reference `game_session_id` dan `game_id`. +- Satu session hanya boleh didebit **satu kali** (`game_session_id` menjadi idempotency key untuk debit). + +```text +Start Game + → load game configuration (entry cost) + → check Coin usable balance + → [1 transaction] debit Coin (SPENT) + create session + → return session_id ke Phaser +``` + +#### Entry Cost & Budget + +Entry cost **tidak** mengurangi atau meng-offset perhitungan budget. + +Budget Controller dan Budget Metrics hanya memperhitungkan **reward** (Coin yang diterbitkan) dan realized voucher cost. Coin yang dibayar untuk bermain tidak dianggap sebagai pemasukan yang menambah budget. + +```text +Reward issued = 1.000.000 Coin +Entry cost paid = 400.000 Coin + +Budget memakai → 1.000.000 Coin (reward), bukan 600.000 (net) +``` + +### 10.2 Entry Cost Refund + +Entry cost dapat dikembalikan (refund) ke user untuk session yang tidak dapat diselesaikan. + +- Refund mengembalikan **jumlah Coin yang sama** dengan entry cost yang tercatat di session. +- Refund dicatat di ledger sebagai `REFUND` dengan reference `game_session_id` dan reference ke transaksi `SPENT` asalnya. +- Refund **wajib idempotent**: satu session maksimal satu kali refund. +- Session yang sudah di-refund **tidak** boleh menghasilkan reward, dan session yang sudah rewarded **tidak** boleh di-refund. +- Refund bukan reward: tidak melewati Reward Engine, tidak dihitung sebagai Coin issued, dan tidak dihitung di budget. +- Refund harus auditable (siapa/sistem apa yang memicu dan alasannya). + +Kondisi refund: + +| Kondisi | Refund | +| -------------------------------------------------------- | -------------------------- | +| System error (session gagal diselesaikan karena sistem) | **Ya, otomatis** oleh sistem | +| Game di-nonaktifkan saat session sedang berjalan | **Ya, otomatis** oleh sistem | +| Session expired/abandoned oleh user | **Tidak** | + +- Refund dijalankan **otomatis** oleh sistem, tanpa perlu request user atau approval admin. +- Session yang ditinggalkan user tidak di-refund, agar user tidak dapat memulai game lalu meninggalkannya saat hasilnya tidak menguntungkan. + +--- + +## 11. Reward Engine + +Reward Engine adalah komponen yang menentukan berapa reward yang **secara teoritis** berhak diterima user berdasarkan result dan configuration. + +Reward Engine **tidak** bertanggung jawab sendirian untuk memutuskan apakah reward boleh diterbitkan. + +Flow: + +```text +Game Result + ↓ +Reward Engine + ↓ +Base Reward + ↓ +Event Modifier / Bonus + ↓ +Final Calculated Reward + ↓ +Economy Guard +``` + +--- + +## 12. Supported Reward Types + +Reward Engine dirancang agar **extensible**. + +Jenis reward minimum yang dapat didukung: + +### `FIXED` + +```text +Play completed → 5 Coin +``` + +### `SCORE_BASED` + +| Score | Reward | +| -------- | ------- | +| 0–100 | 1 Coin | +| 101–500 | 5 Coin | +| 501–1000 | 10 Coin | +| 1001+ | 20 Coin | + +### `OUTCOME_BASED` + +| Outcome | Reward | +| --------- | ------- | +| `PERFECT` | 20 Coin | +| `GOOD` | 10 Coin | +| `NORMAL` | 5 Coin | +| `FAIL` | 0 Coin | + +### `PROBABILITY` + +Reward berdasarkan probability yang **dihitung server**. + +Contoh: + +| Probability | Reward | +| ----------- | --------- | +| 0.1% | 1000 Coin | +| 1% | 100 Coin | +| 10% | 10 Coin | +| 88.9% | 0 Coin | + +Probability harus tervalidasi. + +### `MULTIPLIER` + +Base reward dikalikan modifier. + +```text +Base = 10 Coin +Multiplier = 2x + +Final = 20 Coin +``` + +### `TIERED` + +Reward berdasarkan tier/user/game progression. + +--- + +Jenis reward dapat ditambah di masa depan tanpa mengubah seluruh engine. + +--- + +## 13. Reward Configuration + +Reward configuration harus memiliki **lifecycle/version**. + +Contoh: + +| Config | Reward | +| ----------------------- | ------- | +| Runner Reward Config v1 | 10 Coin | +| Runner Reward Config v2 | 8 Coin | + +> **Penting:** Jangan overwrite configuration lama jika configuration tersebut sudah pernah digunakan dalam transaksi. + +Tujuan: + +- audit +- debugging +- historical accuracy +- mengetahui rule yang berlaku saat user bermain + +Setiap reward transaction idealnya dapat dilacak ke configuration/version yang digunakan. + +--- + +## 14. Normal Reward + Event Reward + +Normal reward dan event modifier **boleh aktif bersamaan**. + +Contoh: + +```text +Normal Reward 10 Coin +Ramadan Event 2x multiplier +Mission Bonus +5 Coin +───────────────────────── +Final Reward 25 Coin +``` + +Event **tidak boleh** mengubah permanent/base configuration game. Event bekerja sebagai **layer/override/modifier**. + +Setelah event selesai: + +```text +Normal Reward 10 Coin +``` + +--- + +## 15. Event Management + +Event digunakan untuk seasonal/campaign activity. + +Contoh: + +- Ramadan +- Christmas +- Independence Day +- Anniversary +- F&B campaign + +Minimum event data: + +- `id` +- `name` +- `slug` +- `description` +- `banner` +- `start_at` +- `end_at` +- `timezone` +- `status` +- `priority` +- reward configuration/modifier +- budget sendiri (lihat [Section 7](#7-budget-hierarchy)) +- rules + +Event dapat menentukan: + +- participating games +- reward multiplier +- bonus reward +- mission +- daily limit +- user limit +- leaderboard +- event-specific campaign rules + +--- + +## 16. Multiple Event Handling + +Karena event dapat overlap, sistem harus memiliki **priority/stacking rule**. + +Contoh: + +```text +Normal Reward ++ Event A ++ Event B +``` + +Sistem harus memiliki aturan jelas apakah: + +- hanya event priority tertinggi yang berlaku +- modifier dapat ditumpuk +- bonus dapat ditumpuk +- ada maximum multiplier + +Default recommendation: + +| Komponen | Rule | +| ------------------ | --------------------- | +| Reward Modifier | Controlled stacking | +| Bonus | Independently tracked | +| Maximum Reward Cap | Always enforced | + +> **Penting:** Final stacking rules harus ditentukan sebelum production. + +--- + +## 17. Economy Guard + +Economy Guard menentukan apakah calculated reward **benar-benar boleh diterbitkan**. + +Minimum checks: + +- session valid +- session belum rewarded +- user valid +- game valid +- reward configuration active +- user daily limit +- game daily limit +- event limit +- global limit +- maximum reward +- budget/economy status +- fraud/risk checks +- idempotency + +Contoh: + +```text +Reward Engine + → 20 Coin + +Economy Guard + → daily limit OK + → event limit OK + → duplicate NO + → budget status OK + +Approved + → issue 20 Coin +``` + +--- + +## 18. Coin Wallet + +Wallet menyimpan **current usable balance**. + +Namun wallet **bukan** satu-satunya source of truth. Source of truth untuk audit adalah **ledger**. + +| Komponen | Digunakan untuk | +| ---------- | ---------------------------------------------------------------- | +| **Wallet** | fast balance lookup, current balance | +| **Ledger** | transaction history, audit, reconciliation, debugging, reporting | + +--- + +## 19. Coin Ledger + +Setiap perubahan balance **harus** menghasilkan ledger transaction. + +Minimum transaction concepts: + +- `EARNED` +- `BONUS` +- `SPENT` +- `EXPIRED` +- `ADJUSTMENT` +- `REVERSAL` +- `CONVERSION` +- `REFUND` + +`SPENT` mencakup pembayaran entry cost game. `REFUND` adalah pengembalian entry cost (lihat [Section 10.2](#102-entry-cost-refund)). + +Transaction harus dapat memiliki reference: + +- `game_session_id` +- `game_id` +- `event_id` +- `voucher_redemption_id` +- `mission_id` +- admin adjustment reference + +Idealnya setiap transaction memiliki: + +- `balance_before` +- `amount` +- `balance_after` + +--- + +## 20. Idempotency + +Reward transaction **wajib idempotent**. + +Contoh: + +```text +POST /game-session/complete +session_id = ABC +``` + +Request pertama: + +```text +Reward = 10 Coin +Status = SUCCESS +``` + +Request kedua dengan session yang sama: + +```text +Tidak membuat reward baru. +Return existing reward/result. +``` + +Database harus memiliki **unique constraint/strategy** yang menjamin satu session tidak bisa menghasilkan duplicate reward. + +Hal yang sama berlaku untuk entry cost: satu session maksimal satu debit `SPENT` dan satu `REFUND`. + +--- + +## 21. Voucher Management + +Voucher Management adalah **core module tersendiri**. + +Voucher adalah benefit yang dapat ditukar menggunakan Point. + +Minimum voucher data: + +- `id` +- `name` +- `description` +- `image` +- provider/merchant +- voucher value +- point cost +- stock +- validity +- terms +- status +- redemption rules + +Sumber voucher mendukung **keduanya**: + +- **Internal voucher codes** — kode dikelola sendiri (lihat [Section 25](#25-voucher-stock)). +- **External API** — kode/voucher diterbitkan oleh provider eksternal saat redemption. + +--- + +## 22. Voucher Value vs Point Cost + +Jangan menganggap `Voucher Value = Point Cost` selamanya. + +Current conversion: + +```text +1 Coin = 1 Point = Rp1 +``` + +Tetapi sebuah voucher dapat memiliki: + +| Voucher Value | Point Cost | +| ------------- | ---------- | +| Rp10.000 | 8.000 | +| Rp10.000 | 12.000 | + +Hal ini memungkinkan Product/Finance mengatur **subsidy, promotion, atau margin**. + +--- + +## 23. Voucher Types + +Voucher Management harus **extensible**. + +Contoh: + +| Type | Contoh | +| ----------------------- | ---------------------------------------------------------- | +| **Fixed Value** | Rp5.000, Rp10.000, Rp20.000 | +| **Percentage Discount** | 10%, 20%, 50% | +| **Free Item** | Free Coffee, Free Food | +| **Merchant Benefit** | Benefit yang hanya berlaku pada merchant/location tertentu | + +Jenis voucher dapat ditambah sesuai kebutuhan. + +--- + +## 24. Voucher Inventory + +Jika voucher menggunakan unique code, sistem harus memiliki inventory. + +Status minimal: + +- `AVAILABLE` +- `RESERVED` +- `REDEEMED` +- `EXPIRED` +- `CANCELLED` + +Flow normal: + +```text +AVAILABLE → RESERVED → REDEEMED +``` + +Jika redemption gagal/timeout: + +```text +RESERVED → AVAILABLE +``` + +Jika voucher expired: + +```text +AVAILABLE → EXPIRED +``` + +--- + +## 25. Voucher Stock + +Voucher dapat menggunakan salah satu dari: + +### Static Stock + +Admin memasukkan jumlah stock. + +```text +Stock = 1.000 +``` + +### Code Pool + +Admin/provider memasukkan unique voucher codes. + +```text +CODE-001 +CODE-002 +CODE-003 +... +``` + +System harus mengetahui stock available secara **reliable**. + +--- + +## 26. Redemption Flow + +Recommended flow: + +```text +User + ↓ +Select Voucher + ↓ +Check Point Balance + ↓ +Check Voucher Availability + ↓ +Reserve Voucher + ↓ +Deduct Point + ↓ +Issue Voucher + ↓ +Mark Voucher Redeemed + ↓ +Create Redemption Record + ↓ +Record Realized Cost +``` + +Transaction harus **atomic**. + +> **Penting:** Jangan sampai Point sudah dikurangi **+** voucher gagal diberikan tanpa recovery/rollback. + +--- + +## 27. Redemption Idempotency + +Redemption juga **harus idempotent**. + +Satu redemption request tidak boleh menghasilkan: + +- dua voucher +- dua Point deduction +- dua cost records + +Gunakan idempotency/reference key. + +--- + +## 28. Realized Cost + +Setiap successful voucher redemption menghasilkan **realized cost**. + +Contoh: + +```text +Voucher Value = Rp10.000 +Point Used = 10.000 + +Realized Cost = Rp10.000 +``` + +Realized cost menggunakan **voucher face value**. + +Sistem tetap menyimpan field berikut secara terpisah: + +- `voucher_value` — face value, menjadi dasar realized cost dan budget +- `point_cost` — Point yang dibayar user +- `business_cost` — opsional, untuk reporting Finance jika berbeda dari face value; **tidak** dipakai untuk budget + +--- + +## 29. Budget Controller + +Budget Controller bertugas mengontrol reward economy berdasarkan **actual redemption dan forecast**. + +### Input + +Minimum input: + +- global budget +- realized voucher cost +- remaining budget +- remaining days +- historical redemption +- coin issuance +- active users +- plays +- average reward +- event status +- redemption rate +- target utilization + +### Output + +Budget Controller dapat menghasilkan: + +- budget status +- forecast cost +- recommended reward multiplier +- recommended reward rate +- warning +- automatic adjustment +- stop reward recommendation + +--- + +## 30. Redemption-Based Forecast + +Contoh kondisi: + +| Metric | Nilai | +| -------------- | ------ | +| Global Budget | Rp100M | +| Realized Cost | Rp60M | +| Remaining | Rp40M | +| Remaining Days | 10 | + +Jika current burn rate terlalu tinggi: + +```text +Forecast = Rp115M +``` + +System dapat menghitung adjustment. + +Contoh: + +| Item | Nilai | +| ---------------------- | -------- | +| Current Reward | 10 Coin | +| Recommended Multiplier | 0.85x | +| New Effective Reward | 8.5 Coin | + +> **Penting:** Rounding rule harus ditentukan agar reward final tidak menghasilkan pecahan Coin jika Coin integer. + +--- + +## 31. Dynamic Rate Safety + +Reward rate **tidak boleh** berubah secara liar setiap request. + +Gunakan: + +- configuration version +- `effective_at` +- cooldown +- min/max multiplier +- adjustment step +- forecast window +- audit log + +Contoh: + +```text +Current = 10 Coin +Allowed adjustment step = 10% + +Next possible: 9 Coin atau 11 Coin +``` + +❌ Jangan: + +```text +10 → 7 → 12 → 5 → 14 (dalam waktu singkat) +``` + +Tujuannya menjaga **predictability** dan **user trust**. + +--- + +## 32. Budget Status + +Recommended: + +| Status | Arti | +| ----------- | ------------------------------------- | +| `HEALTHY` | Budget aman | +| `WARNING` | Burn rate mulai tinggi | +| `CRITICAL` | Forecast berpotensi melewati budget | +| `EXHAUSTED` | Budget sudah mencapai limit | + +Behavior setiap status harus **configurable**. + +--- + +## 33. Automatic vs Approval + +Sistem sebaiknya mendukung dua mode: + +### `RECOMMENDATION MODE` + +```text +System calculates recommendation + ↓ +Admin/Product/Finance approves + ↓ +Publish +``` + +### `AUTOMATIC MODE` + +```text +System calculates + ↓ +Guardrails + ↓ +Auto publish +``` + +Automatic mode hanya boleh berjalan dengan: + +- min reward +- max reward +- min multiplier +- max multiplier +- maximum daily adjustment +- budget safety threshold +- audit log +- rollback capability + +> **Default recommendation untuk production awal:** `RECOMMENDATION MODE` +> +> Setelah economy memiliki data historis yang cukup, automatic mode dapat diaktifkan untuk rule tertentu. + +--- + +## 34. Budget Exhaustion + +Jika budget exhausted, system harus memiliki policy. + +Possible policy: + +1. Stop rewards +2. Reduce rewards to minimum +3. Allow non-budget rewards only +4. Disable affected event +5. Continue game but no reward + +Policy harus **configurable**. + +Game tetap dapat dimainkan meskipun reward sementara tidak tersedia, kecuali Product menentukan game harus ikut disabled. + +--- + +## 35. Limits + +Minimum limit concepts: + +| Limit | Definisi | +| -------------------- | ----------------------------------------------------- | +| **User Daily Limit** | Jumlah Coin maksimal yang dapat diperoleh user per hari | +| **Game Daily Limit** | Total reward game per hari | +| **Event Limit** | Reward maksimal dari event | +| **Global Limit** | Global economy protection | +| **Session Limit** | Satu session hanya dapat rewarded sekali | + +Semua limit harus dapat di-**configure**. + +Jika reward melewati limit, reward **dipotong ke sisa limit** (bukan dibatalkan seluruhnya). Jika sisa limit 0, reward menjadi 0. + +--- + +## 36. Reporting & Analytics + +Dashboard minimal: + +### Game + +- DAU +- total plays +- completed games +- average score +- total Coin issued +- average reward +- reward per play +- total Coin spent for entry cost +- total Coin refunded + +### Economy + +- Coin generated +- Coin spent +- Coin expired +- Coin outstanding +- Point balance +- Point redeemed + +### Voucher + +- redemption count +- voucher value +- point spent +- realized cost +- stock +- redemption rate + +### Budget + +- total budget +- realized cost +- remaining budget +- utilization +- forecast +- burn rate + +### Event + +- participants +- plays +- Coin issued +- redemption +- event cost +- event performance + +--- + +## 37. Audit Log + +Admin changes harus dapat **diaudit**. + +Minimal audit: + +- who +- what +- before +- after +- timestamp +- reason +- source + +Contoh: + +```text +Who : Admin A +Change : Reward 10 Coin → 8 Coin +Reason : Budget optimization +Timestamp : 2027-03-10 10:00 +``` + +Audit terutama **wajib** untuk: + +- reward configuration +- event configuration +- budget +- voucher +- Point/Coin adjustment +- automatic controller changes + +--- + +## 38. Security & Anti-Abuse + +Minimum protection: + +- server-side reward calculation +- session validation +- idempotency +- rate limiting +- duplicate detection +- suspicious score detection +- impossible score detection +- concurrent request protection +- atomic wallet update +- atomic redemption +- audit trail + +> **Penting:** Jangan mempercayai reward amount dari client. + +--- + +## 39. Recommended System Architecture + +```text + ┌──────────────────┐ + │ Next.js Web │ + │ Game Portal │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Go API │ + └────────┬─────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ▼ ▼ ▼ + Game Management Game Session User/Economy + │ │ │ + └────────────────────┼────────────────────┘ + ▼ + Result Validator + │ + ▼ + Reward Engine + │ + ▼ + Economy Guard + │ + ┌──────┴──────┐ + ▼ ▼ + Budget Controller Event + │ │ + └──────┬──────┘ + ▼ + Coin Wallet + │ + ▼ + Coin Ledger + │ + ▼ + Point Layer + │ + ▼ + Voucher Management + │ + ▼ + Redemption Engine + │ + ▼ + Realized Cost + │ + ▼ + Budget Controller +``` + +--- + +## 40. Recommended Development Order + +Jangan langsung membuat semua modul sekaligus. + +### Phase 1 — Business Rules + +Lock: + +- Coin +- Point +- Voucher +- Budget +- Redemption +- Reward +- Event +- Limits +- Expiration + +### Phase 2 — Domain & Database + +Design: + +- `games` +- `game_sessions` +- `reward_configs` +- `events` +- `event_reward_configs` +- `wallets` +- `coin_transactions` +- `budgets` +- `budget_transactions` +- `vouchers` +- `voucher_inventory` +- `redemptions` +- `audit_logs` + +Exact schema harus ditentukan setelah business rules final. + +### Phase 3 — Game Management + +Build: + +- Game CRUD +- Game status +- Game configuration +- Game version + +### Phase 4 — Game Session + +Build: + +- create session +- entry cost debit (Coin) saat create session +- entry cost refund +- validate session +- complete session +- idempotency + +### Phase 5 — Reward Engine + +Build: + +- fixed reward +- score based +- outcome based +- probability +- multiplier +- tiered +- reward configuration versioning + +### Phase 6 — Coin Economy + +Build: + +- wallet +- ledger +- earning +- expiration integration +- adjustment +- transaction history + +### Phase 7 — Voucher Management + +Build: + +- voucher CRUD +- voucher type +- voucher value +- point cost +- stock +- code pool +- expiration +- status + +### Phase 8 — Redemption + +Build: + +- voucher reservation +- Point deduction +- voucher issue +- rollback +- idempotency +- realized cost + +### Phase 9 — Event Management + +Build: + +- event CRUD +- participating games +- reward modifiers +- missions +- event limits +- event leaderboard + +### Phase 10 — Budget Controller + +Build: + +- budget pool +- realized cost tracking +- burn rate +- forecast +- recommendation +- automatic mode +- guardrails + +### Phase 11 — Analytics + +Build: + +- game analytics +- economy analytics +- voucher analytics +- budget dashboard +- event analytics + +### Phase 12 — Phaser Integration + +Integrate real games with: + +- game session +- result submission +- reward result +- leaderboard +- missions + +--- + +## 41. Important Implementation Rules for Claude Code + +Claude Code **must** follow these principles: + +1. Do not invent business rules that are not defined. +2. Do not let Phaser/client determine reward amount. +3. Do not mutate historical reward configuration that has already been used in transactions. +4. Every reward completion must be idempotent. +5. Every wallet mutation must have a ledger record. +6. Voucher redemption must be atomic/recoverable. +7. Budget actual cost is based on successful voucher redemption, according to the current business rule. +8. All games use the same global/shared budget pool. +9. Normal reward and event reward can be active simultaneously. +10. Event configuration must not permanently mutate normal game reward configuration. +11. Automatic reward adjustment must have guardrails. +12. All important admin/economy changes must be auditable. +13. Use database transactions for wallet, redemption, and other financial/economic mutations. +14. Avoid premature microservices. Start with a modular Go backend unless scale requires otherwise. +15. Prefer clear domain boundaries inside the existing backend. +16. Entry cost is debited in Coin when the session is created, in the same transaction as the session. +17. Entry cost and its refund never offset the budget; budget only counts reward and realized voucher cost. +18. Point can only be redeemed for vouchers. It must never be usable as payment or cashed out. + +--- + +## 42. Current Decisions + +These decisions are **confirmed for v1**: + +| Decision | Rule | +| ----------------------- | -------------------------------- | +| Backend | Go | +| Database | PostgreSQL | +| Game engine | Phaser | +| Temporary session | Redis optional | +| Coin | Virtual game currency | +| Point | Redemption currency | +| Coin : Point | 1 : 1 | +| Coin : Rupiah | 1 Coin = Rp1 (nilai acuan, bukan cash out) | +| Point usage | Voucher redemption only | +| Point as payment | Not allowed | +| Point cash out | Not allowed | +| Game entry cost | Paid in Coin | +| Entry cost debit | At session creation (Start Game) | +| Entry cost refund | Automatic, idempotent; only for system error / game deactivated mid-session | +| Abandoned/expired session | No refund | +| Entry cost vs budget | Not counted; budget = reward only | +| Coin expiration | Existing system | +| Point expiration | Existing system | +| Coin → Point | Existing exchange (manual, configurable rate) | +| Budget model | Shared/global pool | +| Budget period | Monthly (default), configurable | +| Event = Campaign | Same concept | +| Budget counts | Only Point originating from EnakGame rewards | +| Budget actual cost | Successful voucher redemption | +| Realized cost basis | Voucher face value | +| Voucher source | Internal codes + external API | +| Free game | Not allowed (entry cost ≥ 1 Coin) | +| Event budget | Own budget per event, separate from global; funds the event's extra reward | +| Over limit | Reward capped to remaining limit | +| Game reward Coin transfer | Allowed | +| Voucher redemption PIN | Required | +| Legacy games (spin, ferris wheel) | Removed; spin rebuilt as EnakGame game | +| Game budget | Shared pool, not isolated per game | +| Normal + Event reward | Can run together | +| Reward authority | Backend | +| Reward idempotency | Required | +| Wallet | Required | +| Ledger | Required | +| Voucher Management | Core module | +| Redemption | Atomic/idempotent | +| Budget Controller | Required | +| Dynamic reward | Supported | +| Auto adjustment | Supported with guardrails | +| Default adjustment mode | Recommendation/approval | +| Event | Layer over normal game reward | +| Historical configs | Must be versioned | +| Audit | Required | + +--- + +## 43. Open Decisions Before Database Design + +The following should remain **explicitly unresolved** until Product/Finance decides them: + +1. **Reward rounding** + - integer Coin only + - allow fractional internal calculation but round final reward +2. **Event stacking** + - priority only + - controlled stacking + - maximum multiplier +3. **Budget exhaustion policy** (global dan event) +4. **Automatic Budget Controller thresholds** +5. **Voucher reservation timeout** + +Sudah diputuskan (lihat [Section 42](#42-current-decisions)): budget period, event/campaign budget, budget attribution, redemption cost model, Coin → Point conversion, Point expiration, voucher provider, free game, entry cost refund conditions. + +These decisions should be finalized before production schema/API is considered final. + +--- + +## 44. Definition of Done for Economy v1 + +Economy v1 is considered technically ready when: + +- [ ] Game session cannot be rewarded twice. +- [ ] Entry cost is debited exactly once per session, atomically with session creation. +- [ ] Session cannot start when Coin balance is insufficient. +- [ ] Entry cost refund is idempotent, and a session cannot be both refunded and rewarded. +- [ ] Sessions failed by system error or game deactivation are refunded automatically; abandoned/expired sessions are not. +- [ ] Entry cost does not affect budget calculation. +- [ ] Point cannot be used as payment or cashed out. +- [ ] Client cannot directly choose reward amount. +- [ ] Reward configuration is versioned. +- [ ] Reward Engine calculates reward correctly. +- [ ] Economy Guard enforces limits. +- [ ] Wallet balance is consistent with ledger. +- [ ] Coin expiration is correctly represented. +- [ ] Voucher stock cannot go negative. +- [ ] Redemption is atomic. +- [ ] Redemption is idempotent. +- [ ] Successful redemption records realized cost. +- [ ] Budget Controller can calculate current utilization. +- [ ] Budget Controller can forecast future cost. +- [ ] Reward adjustment has guardrails. +- [ ] Event reward does not corrupt normal reward configuration. +- [ ] Admin changes are auditable. +- [ ] Finance can reconcile voucher cost against redemption records. + +--- + +## 45. Guiding Principle + +| Component | Responsibility | +| ---------------------- | ------------------------------------ | +| **Product/Finance** | Defines the budget | +| **Game Management** | Defines the game | +| **Reward Engine** | Calculates the reward | +| **Economy Guard** | Protects the economy | +| **Budget Controller** | Protects the budget | +| **Voucher Management** | Defines what users can redeem | +| **Redemption** | Records the actual business cost | +| **Ledger** | Provides the audit trail | + +The goal is not simply to build a collection of games. diff --git a/docs/rfc-enakgame.md b/docs/rfc-enakgame.md new file mode 100644 index 0000000..1b85d96 --- /dev/null +++ b/docs/rfc-enakgame.md @@ -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. diff --git a/docs/tasks-enakgame.md b/docs/tasks-enakgame.md new file mode 100644 index 0000000..25377bb --- /dev/null +++ b/docs/tasks-enakgame.md @@ -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. -- 2.54.0 From 2c9753fae79c1a8574d213834d7aea23bc62e907 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 7 Oct 2026 13:48:29 +0700 Subject: [PATCH 2/2] feat(loyalty): remove paying with EnakPoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnakPoint can only be redeemed for vouchers now: it can no longer pay for orders and is never cashed out (docs/enakgame-prd.md §3.2, EG-001, EG-002). No order was ever paid with EnakPoint, so there is no data to move. Removed: - POST /customer/wallet/payment-code, POST /customer/orders/:id/pay-with-points and GET /orders/:id/point-payment/preview, with their processors, repositories, services, handlers and tests. - The point payment method type: paying, splitting and refunding with it, the outlet filter on the method list, and the system-method guard. - points and payment_code on CreatePayment; points_used and point_value on payments; accepts_point_payment on the customer outlets. - The outlet point_payment settings. A PUT that still sends them is rejected as an unknown field. - The EnakPoint split in the payment method analytics. - PAYMENT and PAYMENT_REFUND from the wallet type rules. Tests that used them as a generic EnakPoint debit use REWARD_REDEEM. - The EnakPoint-paid part from the earning basis, which is subtotal − discount again. Migration 000102 drops the trigger, the point methods and their index, the payments columns, and the outlet settings, and restores the method type CHECK without point. payments.payment_method_id is ON DELETE RESTRICT, so it fails rather than lose a payment made with EnakPoint. The integration docs list the removed endpoints and fields, and the EnakPoint & EnakCoin PRD and tasks note what is superseded. Co-Authored-By: Claude Opus 5.5 --- docs/api-enakpoint.md | 127 ++---- docs/backoffice-enakpoint.md | 43 +- docs/integration-enakpoint.md | 192 +++------ docs/mobile-customer-enakpoint.md | 83 ++-- docs/prd-point-coin.md | 12 + docs/tasks-point-coin.md | 7 + go.mod | 2 - go.sum | 4 - internal/app/app.go | 18 +- internal/constants/loyalty.go | 9 +- internal/constants/payment.go | 3 - internal/constants/wallet.go | 25 +- internal/contract/analytics_contract.go | 6 - internal/contract/customer_pin_contract.go | 11 - internal/contract/order_contract.go | 40 +- internal/contract/payment_method_contract.go | 14 +- internal/entities/analytics.go | 2 - internal/entities/payment.go | 21 +- .../handler/customer_order_payment_handler.go | 35 -- internal/handler/customer_pin_handler.go | 13 - internal/handler/customer_wallet_db_test.go | 8 +- internal/handler/loyalty_settings_db_test.go | 22 +- internal/handler/order_handler.go | 13 - internal/handler/payment_method_handler.go | 10 - internal/handler/point_payment_handler.go | 28 -- internal/mappers/order_mapper.go | 2 - internal/mappers/payment_method_mapper.go | 1 - internal/models/analytics.go | 15 +- internal/models/customer_order.go | 5 +- internal/models/customer_outlet.go | 2 - internal/models/customer_pin.go | 8 - internal/models/loyalty.go | 9 - internal/models/payment.go | 9 +- internal/models/payment_method.go | 12 +- internal/models/wallet.go | 19 - internal/processor/analytics_processor.go | 29 +- .../processor/analytics_processor_test.go | 33 -- .../processor/customer_order_processor.go | 2 - .../customer_order_processor_test.go | 6 +- .../processor/customer_outlet_processor.go | 11 +- .../customer_outlet_processor_test.go | 7 +- internal/processor/customer_pin_processor.go | 4 + internal/processor/earning_calculator.go | 13 +- internal/processor/earning_calculator_test.go | 64 ++- internal/processor/earning_processor.go | 6 +- .../processor/earning_reversal_db_test.go | 4 +- .../processor/loyalty_settings_processor.go | 4 - .../loyalty_settings_processor_test.go | 44 +- internal/processor/order_processor.go | 155 +------ internal/processor/payment_code_processor.go | 101 ----- .../processor/payment_code_processor_test.go | 136 ------ .../processor/payment_method_processor.go | 39 +- internal/processor/point_payment_db_test.go | 401 ------------------ .../processor/point_payment_method_db_test.go | 118 ------ internal/processor/point_payment_processor.go | 336 --------------- .../processor/point_payment_processor_test.go | 39 -- internal/processor/point_payment_refund.go | 157 ------- internal/processor/point_refund_db_test.go | 151 ------- .../processor/wallet_exchange_processor.go | 4 + .../processor/wallet_expiry_processor_test.go | 2 +- internal/processor/wallet_move_db_test.go | 2 +- internal/processor/wallet_processor.go | 24 +- .../processor/wallet_processor_db_test.go | 2 +- internal/processor/wallet_processor_test.go | 40 +- .../processor/wallet_query_processor_test.go | 12 +- .../processor/wallet_trace_processor_test.go | 8 +- internal/repository/analytics_repository.go | 3 +- .../repository/customer_order_repository.go | 4 +- internal/repository/earning_repository.go | 26 +- .../loyalty_settings_repository_test.go | 1 - .../repository/payment_code_repository.go | 103 ----- .../repository/payment_method_repository.go | 2 - .../repository/point_payment_repository.go | 339 --------------- .../wallet_reconciliation_repository_test.go | 4 +- internal/router/router.go | 9 +- internal/router/router_test.go | 12 +- .../service/customer_order_payment_service.go | 34 -- internal/service/customer_pin_service.go | 18 +- internal/service/order_service.go | 3 +- internal/service/order_service_table_test.go | 8 - internal/service/payment_method_service.go | 17 +- internal/service/point_payment_service.go | 55 --- internal/transformer/analytics_transformer.go | 7 - internal/transformer/order_transformer.go | 4 - .../validator/payment_method_validator.go | 1 - ...00102_remove_point_payment_method.down.sql | 35 ++ .../000102_remove_point_payment_method.up.sql | 25 ++ 87 files changed, 423 insertions(+), 3071 deletions(-) delete mode 100644 internal/handler/customer_order_payment_handler.go delete mode 100644 internal/handler/point_payment_handler.go delete mode 100644 internal/processor/payment_code_processor.go delete mode 100644 internal/processor/payment_code_processor_test.go delete mode 100644 internal/processor/point_payment_db_test.go delete mode 100644 internal/processor/point_payment_method_db_test.go delete mode 100644 internal/processor/point_payment_processor.go delete mode 100644 internal/processor/point_payment_processor_test.go delete mode 100644 internal/processor/point_payment_refund.go delete mode 100644 internal/processor/point_refund_db_test.go delete mode 100644 internal/repository/payment_code_repository.go delete mode 100644 internal/repository/point_payment_repository.go delete mode 100644 internal/service/customer_order_payment_service.go delete mode 100644 internal/service/point_payment_service.go create mode 100644 migrations/000102_remove_point_payment_method.down.sql create mode 100644 migrations/000102_remove_point_payment_method.up.sql diff --git a/docs/api-enakpoint.md b/docs/api-enakpoint.md index 6ccf437..8173218 100644 --- a/docs/api-enakpoint.md +++ b/docs/api-enakpoint.md @@ -2,7 +2,9 @@ 30 Sep 2026 -Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat. +Semua endpoint EnakPoint (`POINT`, hanya untuk ditukar ke voucher) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat. + +> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus, karena EnakPoint sekarang hanya bisa ditukar ke voucher: tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus ada di Referensi → Endpoint dan field yang dihapus. ## Konvensi umum @@ -28,7 +30,7 @@ Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk **Error PIN** membawa `data` yang tidak `null`: `{"code": "PIN_INVALID", "remaining_attempts": 3}`, `{"code": "PIN_LOCKED", "locked_until": "…"}`, atau `{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": "…"}`. Endpoint yang menerima `pin` bisa mengembalikan salah satunya. PIN selalu dikirim sebagai string 6 digit. -**Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`. `POST /payments` wajib `X-Idempotency-Key` seperti pembayaran lain. +**Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`. **Waktu.** Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya. @@ -41,9 +43,9 @@ Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk | GET | `/customer/wallet/expiring` | Saldo yang akan kedaluwarsa, per currency dan tanggal | | PUT | `/customer/devices` | Daftarkan token FCM device | | DELETE | `/customer/devices/:device_id` | Hapus device saat logout | -| GET | `/customer/outlets` | Outlet aktif di organisasi customer, dengan `accepts_point_payment`, `earns_points`, `earns_coins` | +| GET | `/customer/outlets` | Outlet aktif di organisasi customer, dengan `earns_points`, `earns_coins` | | GET | `/customer/orders` | Riwayat order customer (`page`, `limit`), dengan `points_earned` / `coins_earned` | -| GET | `/customer/orders/:id` | Detail order: item, pembayaran, EnakPoint yang dipakai; order customer lain → `404` | +| GET | `/customer/orders/:id` | Detail order: item, pembayaran, EnakPoint/EnakCoin yang didapat; order customer lain → `404` | Registrasi (`POST /customer-auth/register/start`) menerima `organization_id` opsional: bila tidak dikirim dan hanya ada satu organisasi, customer masuk ke organisasi itu. Contoh request dan response lengkap untuk outlet dan order ada di [`mobile-customer-enakpoint.md`](./mobile-customer-enakpoint.md) §4.4–§4.5. @@ -74,7 +76,7 @@ Registrasi (`POST /customer-auth/register/start`) menerima `organization_id` ops | `page` | int | Default 1 | | `limit` | int | 1–100, default 20 | | `currency` | `POINT` \| `COIN` | Opsional | -| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,PAYMENT` | +| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,TRANSFER_IN` | | `from`, `to` | `YYYY-MM-DD` | Tanggal WIB, inklusif | ```json @@ -125,7 +127,7 @@ Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `f ## Customer app: PIN -PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu. +PIN 6 digit wajib untuk exchange dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu. | Method | Path | Body | Response | | --- | --- | --- | --- | @@ -136,37 +138,21 @@ PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta custome | POST | `/customer/pin/reset` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN | 1. **Buat PIN:** minta OTP dengan `purpose: "pin_setup"` (dikirim lewat WhatsApp), lalu `POST /customer/pin` dengan `otp_token` dari response OTP dan kode yang diterima customer. -2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; pembayaran dan exchange tetap bisa. +2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; exchange tetap bisa. 3. **Ganti PIN:** `PUT /customer/pin` dengan PIN lama. PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama (`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`). OTP yang diminta terlalu cepat dijawab `429`. Penanganan `PIN_INVALID`, `PIN_LOCKED`, dan `TRANSFER_BLOCKED` ada di Konvensi umum. -## Customer app: bayar, exchange, transfer, game +## Customer app: exchange, transfer, game | Method | Path | PIN | Idempotency-Key | | --- | --- | --- | --- | -| POST | `/customer/wallet/payment-code` | Ya | – | -| POST | `/customer/orders/:id/pay-with-points` | Ya | – | | GET | `/customer/wallet/exchange/preview?coins=` | – | – | | POST | `/customer/wallet/exchange` | Ya | Wajib | | GET | `/customer/wallet/transfer/recipient?phone=` | – | – | | POST | `/customer/wallet/transfer` | Ya | Wajib | | POST | `/customer/spin` | – | – | -### POST /customer/wallet/payment-code - -Body `{ "pin": "482913" }`. Response: - -```json -{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" } -``` - -Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR untuk kasir. Berlaku 2 menit, sekali pakai, hanya untuk customer ini; kode baru membatalkan kode lama. - -### POST /customer/orders/:id/pay-with-points - -Body `{ "points": 12500, "pin": "482913" }`. Hanya untuk order milik customer yang login (order lain `404`). Response sama dengan pembayaran POS (bagian POS). Batas dan aturan penolakan juga sama. - ### GET /customer/wallet/exchange/preview?coins=30 ```json @@ -238,69 +224,9 @@ Body `{ "spin_id": "" }`. Memotong EnakCoin sebesar `metadata.coin_cost EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong. -## POS: pembayaran EnakPoint +## POS: earning, void, dan refund -Kasir memakai endpoint pembayaran yang sudah ada dengan payment method bertipe `point`, disetujui customer lewat kode bayar dari aplikasinya; PIN tidak pernah diketik di perangkat kasir. - -| Method | Path | Keterangan | -| --- | --- | --- | -| GET | `/orders/:id/point-payment/preview` | Batas pembayaran EnakPoint untuk order ini | -| POST | `/payments` | Bayar dengan method EnakPoint (`points` + `payment_code`) | -| POST | `/payments/:id/refund` | Refund pembayaran EnakPoint, kembali sebagai EnakPoint | - -1. Customer membuat kode di aplikasi (`POST /customer/wallet/payment-code`) dan menunjukkan angka atau QR-nya. -2. POS memanggil preview untuk tombol "pakai maksimal". -3. POS memanggil `POST /payments` dengan kode tersebut. Sisa tagihan dibayar dengan method lain seperti biasa. - -### GET /orders/:id/point-payment/preview - -```json -{ - "order_id": "…", - "customer_id": "…", - "eligible": true, - "point_balance": 12500, - "point_value": 1, - "remaining_amount": 87500, - "min_payment_points": 1, - "max_payment_percent": 100, - "max_points": 12500, - "max_amount": 12500 -} -``` - -Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak menerima EnakPoint, saldo di bawah minimal, dst.). Batas yang dipakai: - -``` -batas_rupiah = min(sisa_tagihan, total × max_payment_percent / 100 − sudah_dibayar_EnakPoint) -maks_point = min(saldo, floor(batas_rupiah / point_value)) -``` - -### POST /payments - -Header `X-Idempotency-Key` wajib. - -```json -{ - "order_id": "…", - "payment_method_id": "", - "points": 12500, - "payment_code": "482913" -} -``` - -- `amount` tidak perlu dikirim; backend menghitung `points × point_value` dan tidak pernah melebihi sisa tagihan (tidak ada kembalian). -- `payment_code` boleh angka yang diketik atau hasil scan QR apa adanya (`enakpoint:482913`). -- Response pembayaran membawa `points_used` dan `point_value` untuk struk; response order membawa `points_earned` dan `coins_earned`. -- Ditolak `304` bila: order tanpa customer atau walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar batas, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method EnakPoint dipakai sebagai split. Kode terpakai begitu diterima; bila pembayaran lalu ditolak, minta kode baru. -- Method EnakPoint dibuat otomatis per organisasi, tidak bisa dihapus atau diubah tipenya, dan tidak muncul di daftar method `?outlet_id=` bila outlet tidak menerima EnakPoint. - -### Void dan refund - -- **Void order:** semua EnakPoint yang dipakai kembali sebagai EnakPoint. -- **`POST /payments/:id/refund` pada pembayaran EnakPoint:** kembali `floor(rupiah_direfund / point_value_saat_bayar)`; sisa di bawah 1 EnakPoint hangus. -- **Refund order ke tunai/method lain** hanya sebesar bagian non-EnakPoint; mencoba merefund bagian EnakPoint secara tunai ditolak `304`. -- EnakPoint yang kembali memakai tanggal kedaluwarsa asal, minimal 7 hari sejak refund. Earning order ikut ditarik; bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan. +EnakPoint bukan payment method: tidak ada lagi tipe `point`, dan `POST /payments` memakai `amount` seperti pembayaran lain. Response order membawa `points_earned` dan `coins_earned` untuk struk. Saat order di-void atau direfund, EnakPoint dan EnakCoin yang didapat dari order itu ikut ditarik (`EARN_REVERSAL`); bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan. ## Dashboard @@ -308,7 +234,7 @@ Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke | Method | Path | Keterangan | | --- | --- | --- | -| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning dan penerimaan EnakPoint per outlet | +| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning EnakPoint dan EnakCoin per outlet | | GET, PUT | `/marketing/loyalty-settings` | Nilai EnakPoint, kurs, transfer, kedaluwarsa (`?dry_run=true` untuk preview) | | GET | `/marketing/loyalty-settings/history` | Riwayat perubahan setting (`page`, `limit`, `outlet_id`) | | GET | `/marketing/customers/:id/wallet` | Saldo, lot aktif, riwayat dengan nama asli | @@ -324,12 +250,11 @@ Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang; ```json { "point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, - "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, - "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 } + "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null } } ``` -Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. `earn_mode` adalah `PER_AMOUNT` (setiap `earn_per_amount` rupiah mendapat `earn_value`) atau `PERCENTAGE` (`earn_percent` persen dari basis). Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `earn_percent` 0–100 dengan maks. 2 angka desimal, `max_payment_percent` 0–100. +Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. `earn_mode` adalah `PER_AMOUNT` (setiap `earn_per_amount` rupiah mendapat `earn_value`) atau `PERCENTAGE` (`earn_percent` persen dari basis). Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `earn_percent` 0–100 dengan maks. 2 angka desimal. Objek `point_payment` sudah dihapus; `PUT` yang masih mengirimnya ditolak `310` (field tidak dikenal). ### /marketing/loyalty-settings @@ -380,7 +305,7 @@ Response menambahkan: ```json { - "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "PAYMENT", "amount": -30, "…": "…" }, + "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "TRANSFER_OUT", "amount": -30, "…": "…" }, "lots": [ { "amount": 30, @@ -393,7 +318,7 @@ Response menambahkan: } ``` -Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer, exchange, atau refund sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`. +Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer atau exchange sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`. ### PIN customer @@ -407,8 +332,6 @@ Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat | --- | --- | --- | --- | | `EARN` | + | Didapat dari order lunas | `ORDER` | | `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` | -| `PAYMENT` | − | Membayar order (EnakPoint saja) | `PAYMENT` | -| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` | | `EXCHANGE_OUT` | − | EnakCoin ditukar | `WALLET_TX` (baris `EXCHANGE_IN`) | | `EXCHANGE_IN` | + | EnakPoint hasil tukar | `WALLET_TX` (baris `EXCHANGE_OUT`) | | `TRANSFER_OUT` | − | Dikirim ke customer lain | `WALLET_TX` (baris `TRANSFER_IN`) | @@ -438,4 +361,20 @@ Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi p | `GET /customer/points` | `GET /customer/wallet` → `point_balance` | | `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` | +### Endpoint dan field yang dihapus + +Bayar dengan EnakPoint dihapus pada 7 Okt 2026 karena EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Tidak ada penggantinya; jangan dipanggil lagi. + +| Dihapus | Catatan | +| --- | --- | +| `POST /customer/wallet/payment-code` | Kode bayar untuk kasir | +| `POST /customer/orders/:id/pay-with-points` | Bayar order dari app / self-order | +| `GET /orders/:id/point-payment/preview` | Batas pembayaran EnakPoint di POS | +| Payment method tipe `point`; field `points` dan `payment_code` di `POST /payments` | `amount` kembali wajib seperti pembayaran lain | +| `points_used`, `point_value` di response pembayaran dan di `payments` pada `GET /customer/orders/:id` | – | +| `accepts_point_payment` di `GET /customer/outlets` | – | +| `point_payment` (`accept_payment`, `min_payment_points`, `max_payment_percent`) di `/outlets/:outlet_id/loyalty-settings` | `PUT` yang masih mengirimnya ditolak `310` | +| `summary.point_amount`, `summary.points_used`, `summary.total_with_points`, serta `points_used` dan `counts_as_cash_in` per baris di analytics payment method | `summary.total_amount` kembali total semua method; persentase dihitung dari total itu | +| Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` | Tidak ditulis lagi | + Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md). diff --git a/docs/backoffice-enakpoint.md b/docs/backoffice-enakpoint.md index 82a992a..ae256da 100644 --- a/docs/backoffice-enakpoint.md +++ b/docs/backoffice-enakpoint.md @@ -4,6 +4,8 @@ Backoffice perlu tujuh layar untuk mengelola program loyalitas: setting per outlet, setting per organisasi (termasuk kedaluwarsa), wallet customer, telusuri mutasi, PIN customer, riwayat setting, dan biaya main game. +> **Perubahan 7 Okt 2026:** bayar dengan EnakPoint sudah dihapus karena EnakPoint sekarang hanya bisa ditukar ke voucher, tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Akibatnya setting outlet tidak lagi punya `point_payment`, method "EnakPoint" (tipe `point`) tidak ada lagi di Payment Method, dan laporan per payment method tidak lagi membawa `point_amount`, `points_used`, `total_with_points`, atau `counts_as_cash_in`; `summary.total_amount` kembali total semua method. + ## Layar yang perlu dibuat Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin atau Manager, dan otomatis dibatasi ke organisasi user tersebut. Data customer atau outlet organisasi lain dijawab `404`. @@ -20,21 +22,20 @@ Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin a Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada. -**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang bisa membayar order; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan. +**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang hanya bisa ditukar ke voucher, bukan alat bayar; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan. **Format response.** Sukses `{ "success": true, "data": … }`; gagal `{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Tampilkan `cause` sebagai pesan (lihat bagian Pesan error). ## Setting loyalitas outlet -Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order, dan apakah outlet menerima pembayaran EnakPoint. Semua nilai default mati sampai owner menyalakannya. +Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order. Semua nilai default mati sampai owner menyalakannya. -`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak. +`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak (termasuk `point_payment` yang sudah dihapus). ```json { "point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, - "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, - "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 } + "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null } } ``` @@ -47,17 +48,14 @@ Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari ord | `earn_percent` | … % dari belanja (mode `PERCENTAGE`) | %, boleh desimal | 1 | 0–100, maks. 2 angka desimal | | `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 | | `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 | -| `point_payment.accept_payment` | Terima pembayaran EnakPoint | toggle | mati | – | -| `min_payment_points` | Minimal EnakPoint per pembayaran | angka | 1 | ≥ 1 | -| `max_payment_percent` | Maksimal porsi order dibayar EnakPoint | % | 100 | 0–100 | **Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`, atau pada mode `PERCENTAGE`: `earn_percent × point_value`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1). **Mode earning.** Tampilkan hanya field mode yang dipilih (`earn_per_amount` + `earn_value`, atau `earn_percent`). Field mode lain tetap tersimpan di server, jadi tidak perlu dikosongkan saat owner berpindah mode. Pada mode `PERCENTAGE` jumlah yang didapat adalah `floor(basis × earn_percent ÷ 100)`, mis. 2,5% dari Rp 87.500 = 2.187 EnakPoint. -**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak, dan bagian yang dibayar EnakPoint tidak ikut dihitung. +**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak. -Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan". Mematikan `accept_payment` langsung menyembunyikan method EnakPoint di kasir outlet itu. +Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan". ## Setting loyalitas organisasi @@ -102,7 +100,7 @@ Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku s | `coins_as_points_before` → `coins_as_points_after` | Bila semua ditukar: … EnakPoint → … EnakPoint | | `coin_rupiah_before` → `coin_rupiah_after` | Setara potongan Rp … → Rp … | -Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: pembayaran, refund, dan exchange yang sudah terjadi memakai nilai saat itu. +Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: exchange yang sudah terjadi memakai kurs saat itu. ## Pengaturan kedaluwarsa @@ -159,7 +157,7 @@ Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo da ### Saldo, lot, dan riwayat -`GET /marketing/customers/:id/wallet?page=1&limit=20¤cy=POINT&type=PAYMENT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer) +`GET /marketing/customers/:id/wallet?page=1&limit=20¤cy=POINT&type=TRANSFER_OUT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer) ```json { @@ -189,7 +187,7 @@ Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo da - **Saldo:** tampilkan `spendable_*` sebagai saldo utama. `point_balance` / `coin_balance` bisa sedikit lebih besar selama ada lot yang sudah lewat tanggal tapi belum diproses job kedaluwarsa (paling lama sekitar 15 menit). - **Lot:** tabel paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Beri tanda untuk `expired: true`. -- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment atau kasir penerima pembayaran), `outlet`, `reason`, dan `metadata` (kurs, nilai EnakPoint yang dibekukan, shortfall). +- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment), `outlet`, `reason`, dan `metadata` (kurs, rumus earning, shortfall). ### Adjustment manual @@ -214,7 +212,7 @@ Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-tr ```json { - "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "PAYMENT", "amount": -30, "description": "Bayar #ORD-0456 di Outlet Kemang (Rp 30)", "reference_type": "PAYMENT", "reference_id": "…", "created_at": "…" }, + "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "TRANSFER_OUT", "amount": -30, "description": "Transfer ke Ri*** (08**-****-9012)", "reference_type": "WALLET_TX", "reference_id": "…", "created_at": "…" }, "lots": [ { "amount": 30, @@ -229,7 +227,7 @@ Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-tr Tampilkan tiap `lots[]` sebagai rantai dari atas ke bawah: jumlah yang lewat lot itu, lalu setiap langkah `chain` dengan pemilik, tipe, dan deskripsinya. Langkah terakhir selalu `EARN`, `ADJUSTMENT`, atau `MIGRATION`; bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya. -## PIN, riwayat setting, game, dan method EnakPoint +## PIN, riwayat setting, dan game ### PIN & keamanan customer @@ -270,22 +268,12 @@ Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aks Semua game (spin, raffle, minigame) memakai EnakCoin yang sama. Biaya per main diisi di `metadata.coin_cost` saat membuat atau mengedit game (`/marketing/games`): bilangan bulat ≥ 1, default 1 bila kosong. Nilai pecahan, 0, atau teks membuat game tidak bisa dimainkan. Karena `metadata` dikirim utuh, pertahankan key metadata lain saat menyimpan. Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint. -### Method pembayaran EnakPoint - -Method "EnakPoint" (tipe `point`) dibuat otomatis untuk setiap organisasi. Di layar Payment Method (`/payment-methods`): - -- Tampilkan sebagai method sistem: tombol hapus dan pilihan ubah tipe disembunyikan; backend menolaknya (`304`). Nama boleh diganti. -- Tipe `point` tidak ditawarkan saat membuat method baru. -- Kasir hanya melihatnya di outlet yang menyalakan "Terima pembayaran EnakPoint". - -Di laporan per payment method, EnakPoint tampil terpisah dan **tidak** dihitung sebagai kas masuk. - ## Pesan error dan checklist | `code` | HTTP | Kapan terjadi di backoffice | Yang ditampilkan | | --- | --- | --- | --- | | `303`, `310` | 400 | Body tidak valid, field tak dikenal di `PUT` setting, UUID salah | Pesan umum "Data tidak valid" + `cause` untuk developer | -| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong, hapus/ubah method EnakPoint | `cause` di dekat field atau di toast | +| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong | `cause` di dekat field atau di toast | | `404` | 404 | Customer, outlet, atau mutasi bukan milik organisasi ini | "Data tidak ditemukan" | | `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" | @@ -302,8 +290,7 @@ Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalt - [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`. - [ ] Tombol Telusuri ada di setiap baris riwayat. - [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log. -- [ ] Method EnakPoint tampil sebagai method sistem. - [ ] Form game punya input `coin_cost`. - [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …". -Pembayaran EnakPoint belum boleh dirilis ke outlet sebelum tinjauan keuangan (N2) dan legal (N3) selesai, dan transfer menunggu tinjauan legal (N3). Layar backoffice boleh disiapkan lebih dulu. +Transfer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar backoffice boleh disiapkan lebih dulu. diff --git a/docs/integration-enakpoint.md b/docs/integration-enakpoint.md index f7afdf0..c533fa6 100644 --- a/docs/integration-enakpoint.md +++ b/docs/integration-enakpoint.md @@ -6,6 +6,12 @@ lama tetap jalan sebagai alias (lihat §8) Panduan untuk memakai saldo loyalitas dari sisi klien. Alasan di balik setiap aturan ada di [`prd-point-coin.md`](./prd-point-coin.md). +> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus (migrasi +> `000102`). EnakPoint sekarang hanya bisa ditukar ke voucher, tidak bisa dipakai +> sebagai alat bayar dan tidak bisa dicairkan +> ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus +> ada di §8. + --- ## 1. Konsep inti @@ -13,21 +19,21 @@ ada di [`prd-point-coin.md`](./prd-point-coin.md). | | EnakPoint (`POINT`) | EnakCoin (`COIN`) | |---|---|---| | Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin | -| Dipakai untuk | **Membayar order** | **Main game**, ditukar ke EnakPoint | +| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint | | Bisa ditransfer | Ya | Ya | | Bisa kedaluwarsa | Ya, bila diaktifkan owner | Ya, bila diaktifkan owner | Aturan yang berlaku di seluruh dokumen ini: 1. **Semua jumlah bilangan bulat.** Tidak ada "setengah EnakPoint". -2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, tidak ada kembalian, dan - bagian order yang dibayar EnakPoint hanya bisa kembali sebagai EnakPoint. Tampilkan - nilai rupiahnya sebagai **"setara potongan Rp …"**, bukan "saldo Rp …". -3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): bayar, - buat kode bayar, exchange, transfer. Main game tidak butuh PIN. +2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, dan EnakPoint tidak bisa + dipakai membayar order. Tampilkan nilai rupiahnya sebagai **"setara potongan + Rp …"**, bukan "saldo Rp …". +3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): exchange + dan transfer. Main game tidak butuh PIN. 4. **Wallet milik customer di satu organisasi.** Saldo berlaku di semua outlet organisasi itu. Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan - kedaluwarsa diatur per organisasi; earning dan penerimaan pembayaran per outlet. + kedaluwarsa diatur per organisasi; earning per outlet. 5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah dihapus. Koreksi muncul sebagai baris baru. @@ -91,7 +97,7 @@ Semua endpoint customer memakai header `Authorization: Bearer `. ### 2.2 Riwayat -`GET /api/v1/customer/wallet/transactions?page=1&limit=20¤cy=POINT&type=EARN,PAYMENT&from=2026-09-01&to=2026-09-30` +`GET /api/v1/customer/wallet/transactions?page=1&limit=20¤cy=POINT&type=EARN,TRANSFER_IN&from=2026-09-01&to=2026-09-30` Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma. `from` / `to` tanggal WIB, inklusif. @@ -119,8 +125,8 @@ Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipis - `amount` bertanda: positif menambah saldo, negatif mengurangi. - Penambahan punya `source`, pengurangan punya `destination`. Keduanya berbentuk - `{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, pembayaran, game - play, dst.). + `{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, game play, + dst.). - `description` sudah siap tampil dan tidak berubah walau nama outlet atau customer berubah belakangan. Nama lawan transfer sudah disamarkan. - Dua baris exchange atau transfer berbagi `group_id` yang sama. @@ -129,8 +135,6 @@ Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipis |---|---|---|---| | `EARN` | + | Didapat dari order lunas | `ORDER` | | `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` | -| `PAYMENT` | − | Membayar order | `PAYMENT` | -| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` | | `EXCHANGE_OUT` / `EXCHANGE_IN` | − / + | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) | | `TRANSFER_OUT` / `TRANSFER_IN` | − / + | Transfer antar customer | `WALLET_TX` (baris pasangannya) | | `GAME_SPEND` | − | Main game | `GAME_PLAY` | @@ -219,7 +223,7 @@ menghasilkan `429`. - **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /api/v1/customer/pin/reset` dengan body yang sama seperti §3.2. Reset juga membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**; - pembayaran dan exchange tetap bisa. + exchange tetap bisa. ### 3.4 Menangani error PIN @@ -247,123 +251,19 @@ reinstall atau ganti HP. --- -## 4. Membayar dengan EnakPoint +## 4. Earning, void, dan refund -Ada dua jalur. Keduanya memakai logika perhitungan yang sama. - -### 4.1 Batas pembayaran - -EnakPoint maksimal yang bisa dipakai untuk satu order: - -``` -batas_rupiah = min(sisa_tagihan, total_order × max_payment_percent / 100 − yang_sudah_dibayar_EnakPoint) -maks_point = min(saldo_customer, floor(batas_rupiah / point_value)) -``` - -Ditambah minimal `min_payment_points` per pembayaran. Nominal rupiah pembayaran selalu -`points × point_value` dan **tidak pernah melebihi sisa tagihan**, jadi tidak ada -kembalian. Sisa tagihan dibayar dengan method lain seperti biasa (split). - -### 4.2 POS — kode bayar dari aplikasi customer - -PIN **tidak pernah** diketik di perangkat kasir. Customer menyetujui di HP-nya sendiri: - -1. **Customer app:** `POST /api/v1/customer/wallet/payment-code` dengan `{ "pin": "482913" }`. - - ```json - { "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" } - ``` - - Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR. Kode berlaku **2 menit**, - sekali pakai, dan hanya untuk customer itu. Membuat kode baru membatalkan kode lama. - -2. **POS:** tampilkan batas untuk tombol "pakai maksimal": - - `GET /api/v1/orders/:id/point-payment/preview` - - ```json - { - "order_id": "…", - "customer_id": "…", - "eligible": true, - "point_balance": 12500, - "point_value": 1, - "remaining_amount": 87500, - "min_payment_points": 1, - "max_payment_percent": 100, - "max_points": 12500, - "max_amount": 12500 - } - ``` - - Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak - menerima EnakPoint, saldo di bawah minimal, dst.). - -3. **POS:** bayar lewat endpoint pembayaran yang sudah ada, dengan payment method - bertipe `point`: - - `POST /api/v1/payments` (header `X-Idempotency-Key` wajib seperti pembayaran lain) - - ```json - { - "order_id": "…", - "payment_method_id": "", - "points": 12500, - "payment_code": "482913" - } - ``` - - `amount` tidak perlu dikirim; backend menghitungnya. `payment_code` boleh berupa - angka yang diketik kasir atau hasil scan QR apa adanya (`enakpoint:482913`). - -Response pembayaran membawa `points_used` dan `point_value` untuk struk, misalnya -"EnakPoint: 12.500 (Rp 12.500)". Jika pembayaran ini melunasi order, order menjadi -`completed`; jika belum, sisanya dibayar dengan method lain. - -Pembayaran ditolak (`304`, `cause` menjelaskan) bila: order tanpa customer atau -customer walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar -batas §4.1, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method -EnakPoint dipakai sebagai split (bayar bagian EnakPoint sebagai pembayaran tersendiri, -lalu split sisanya seperti biasa). Kode bayar dipakai habis begitu diterima, sebelum -batas dicek ulang; bila pembayaran lalu ditolak (misalnya saldo berubah), minta -customer membuat kode baru. - -**Method EnakPoint** dibuat otomatis untuk setiap organisasi dan tidak bisa dihapus -atau diubah tipenya (namanya boleh diganti). Daftar payment method yang dikirim -`?outlet_id=` tidak menampilkannya bila outlet itu tidak menerima EnakPoint. - -### 4.3 Customer app / self-order — bayar order sendiri - -`POST /api/v1/customer/orders/:id/pay-with-points` - -```json -{ "points": 12500, "pin": "482913" } -``` - -Hanya untuk order milik customer yang login; order lain dijawab `404`. Response sama -dengan response pembayaran di §4.2. - -### 4.4 Void dan refund - -- **Void order:** semua EnakPoint yang dipakai kembali ke customer sebagai EnakPoint. -- **Refund pembayaran EnakPoint** (`POST /api/v1/payments/:id/refund` pada pembayaran - EnakPoint): yang kembali `floor(rupiah_direfund / point_value_saat_bayar)`. Perubahan - nilai EnakPoint setelah pembayaran tidak mengubah jumlah yang kembali; sisa di bawah - 1 EnakPoint hangus. -- **Refund order ke tunai / method lain** hanya boleh sebesar bagian yang dibayar - dengan method lain. Bagian EnakPoint harus direfund lewat pembayaran EnakPoint-nya - sendiri; mencoba lewat tunai dijawab `304`. -- EnakPoint yang kembali mengikuti tanggal kedaluwarsa asalnya, tapi minimal 7 hari - sejak refund. -- EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila - saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah - diblokir karena ini. - -### 4.5 Earning di layar order dan struk +EnakPoint bukan payment method: tidak ada payment method bertipe `point`, dan +`POST /api/v1/payments` memakai `amount` seperti pembayaran lain. Kode bayar, +bayar dari aplikasi, dan preview pembayaran EnakPoint sudah dihapus (§8). Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak -menghasilkan apa-apa). Earning dihitung dari `subtotal − discount − bagian yang -dibayar EnakPoint`, sebelum pajak, dan diberikan saat order lunas. +menghasilkan apa-apa). Earning dihitung dari `subtotal − discount`, sebelum pajak, dan +diberikan saat order lunas. + +EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila +saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah +diblokir karena ini. --- @@ -504,6 +404,24 @@ Semua yang bernama token sudah dihapus: `GET /customer/tokens`, `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining`, dan nilai `TOKENS` di campaign. Pakai `coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`. +Bayar dengan EnakPoint juga sudah dihapus (7 Okt 2026, migrasi `000102`) karena +EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). +Tidak ada penggantinya: + +- Endpoint `POST /customer/wallet/payment-code`, `POST /customer/orders/:id/pay-with-points`, + dan `GET /orders/:id/point-payment/preview`. +- Payment method tipe `point`, serta field `points` dan `payment_code` di + `POST /payments`; `amount` kembali wajib seperti pembayaran lain. +- `points_used` dan `point_value` di response pembayaran dan di `payments` pada + `GET /customer/orders/:id`; `accepts_point_payment` di `GET /customer/outlets`. +- Objek `point_payment` (`accept_payment`, `min_payment_points`, + `max_payment_percent`) di setting outlet (§9.1). +- Di analytics payment method: `point_amount`, `points_used`, `total_with_points` di + `summary`, serta `points_used` dan `counts_as_cash_in` per baris. + `summary.total_amount` kembali total semua method, dan persentase dihitung dari total + itu. +- Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` tidak ditulis lagi. + --- ## 9. Dashboard @@ -517,12 +435,12 @@ Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager. ```json { "point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, - "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, - "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 } + "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null } } ``` -Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan +Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. `PUT` yang masih +mengirim `point_payment` ditolak `310` (field tidak dikenal). Response menambahkan `point_value` organisasi dan `point_cashback_percent` (`earn_value × point_value / earn_per_amount × 100`, atau `earn_percent × point_value` pada `earn_mode` `PERCENTAGE`). **Tampilkan persentase ini di @@ -597,10 +515,10 @@ Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit= Adjustment tidak disertai pembayaran uang, jadi jangan pakai alasan "pencairan". - `GET /api/v1/marketing/wallet-transactions/:id/trace` — telusuri satu mutasi per - butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer, - exchange, atau refund sampai ke earning/adjustment/migrasi pertama. Contoh: dari - pembayaran B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A - yang mentransfer ke B. + butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer atau + exchange sampai ke earning/adjustment/migrasi pertama. Contoh: dari transfer keluar + B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A yang + mentransfer ke B. ### 9.4 PIN customer @@ -624,10 +542,8 @@ Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit= - [ ] Baca `coins_used` / `coins_remaining` dan `/customer/wallet`, bukan field lama. **POS** -- [ ] Scan QR atau ketik kode bayar, jangan pernah meminta PIN customer di layar kasir. -- [ ] Pakai `point-payment/preview` untuk tombol "pakai maksimal". -- [ ] Cetak `points_used`, `points_earned`, dan `coins_earned` di struk. -- [ ] Refund bagian EnakPoint lewat pembayaran EnakPoint-nya, bukan tunai. +- [ ] Cetak `points_earned` dan `coins_earned` di struk. +- [ ] Jangan menampilkan EnakPoint sebagai payment method (§4). **Dashboard** - [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan diff --git a/docs/mobile-customer-enakpoint.md b/docs/mobile-customer-enakpoint.md index 1c7a8d2..5c01d74 100644 --- a/docs/mobile-customer-enakpoint.md +++ b/docs/mobile-customer-enakpoint.md @@ -12,20 +12,26 @@ aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan dulu. | | EnakPoint (`POINT`) | EnakCoin (`COIN`) | |---|---|---| | Didapat dari | Belanja (order lunas), koreksi admin, tukar EnakCoin | Belanja, koreksi admin | -| Dipakai untuk | **Membayar order** | **Main game**, ditukar ke EnakPoint | +| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint | | Bisa dikirim ke customer lain | Ya | Ya | | Bisa kedaluwarsa | Ya, bila owner mengaktifkan | Ya, bila owner mengaktifkan | Tidak ada lagi "token". Semua yang dulu token sekarang EnakCoin, dan endpoint serta field bernama token sudah dihapus dari API. +> **Perubahan 7 Okt 2026:** bayar dengan EnakPoint (kode bayar di kasir, bayar order +> dari app) sudah dihapus dari backend. EnakPoint sekarang hanya bisa ditukar ke +> voucher, tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan +> ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Jangan membangun layar bayar atau kode +> bayar; endpoint dan field yang dihapus ada di §9. + ### Aturan yang wajib dipatuhi di UI 1. **Semua jumlah bilangan bulat.** Tidak ada desimal pada EnakPoint atau EnakCoin. 2. **Saldo bukan uang.** Nilai rupiah EnakPoint selalu ditulis **"setara potongan - Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada fitur tarik tunai. -3. **PIN 6 digit wajib** untuk: membuat kode bayar, tukar - EnakCoin, dan transfer. **Main game tidak butuh PIN.** Melihat saldo dan riwayat + Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada fitur tarik tunai, dan + EnakPoint tidak bisa dipakai membayar. +3. **PIN 6 digit wajib** untuk: tukar EnakCoin dan transfer. **Main game tidak butuh PIN.** Melihat saldo dan riwayat tidak butuh PIN. 4. **PIN terpisah dari password login** dan selalu dikirim sebagai **string** (supaya nol di depan tidak hilang). Jangan pernah menyimpan PIN di perangkat, log, atau @@ -107,7 +113,6 @@ Endpoint **tukar** dan **transfer** wajib header `Idempotency-Key` (string unik, | Saldo akan kedaluwarsa | `GET /customer/wallet/expiring` | – | | Daftar outlet | `GET /customer/outlets` | – | | Riwayat order + detail | `GET /customer/orders`, `GET /customer/orders/:id` | – | -| Kode bayar (angka + QR) | `POST /customer/wallet/payment-code` | Ya | | Tukar EnakCoin | `GET …/exchange/preview`, `POST /customer/wallet/exchange` | Ya | | Transfer | `GET …/transfer/recipient`, `POST /customer/wallet/transfer` | Ya | | PIN (buat, ganti, lupa) | `/customer/pin/*` | – | @@ -141,7 +146,7 @@ Tampilkan: - Bila `nearest_expiring.point` / `.coin` tidak `null`: banner "{amount} EnakPoint akan kedaluwarsa pada {date}" yang membuka layar §4.3. - 5 mutasi terakhir dari `recent_transactions`, dengan tautan "Lihat semua" ke §4.2. -- Tombol aksi: Bayar di kasir (§7.1), Tukar EnakCoin (§8.1), Transfer (§8.2), Main game (§9). +- Tombol aksi: Tukar EnakCoin (§7.1), Transfer (§7.2), Main game (§8). Muat ulang beranda setelah setiap transaksi dan saat menerima push (§5). @@ -157,7 +162,7 @@ Query (semua opsional): | `page` | `1` | Mulai dari 1 | | `limit` | `20` | 1–100, default 20 | | `currency` | `POINT` | `POINT` atau `COIN`; untuk tab EnakPoint / EnakCoin | -| `type` | `EARN,PAYMENT` | Satu atau beberapa tipe dipisah koma, untuk filter | +| `type` | `EARN,TRANSFER_IN` | Satu atau beberapa tipe dipisah koma, untuk filter | | `from`, `to` | `2026-09-01` | Tanggal WIB, inklusif | ```json @@ -196,8 +201,6 @@ Label tipe: |---|---|---| | `EARN` | Dari belanja | + | | `EARN_REVERSAL` | Dibatalkan (order di-void/refund) | − | -| `PAYMENT` | Bayar pesanan | − | -| `PAYMENT_REFUND` | Pengembalian pembayaran | + | | `EXCHANGE_OUT` | Ditukar ke EnakPoint | − | | `EXCHANGE_IN` | Hasil tukar EnakCoin | + | | `TRANSFER_OUT` | Transfer keluar | − | @@ -235,7 +238,6 @@ berdasarkan nama. "id": "…", "name": "Gokuna Kemang", "address": "Jl. Kemang Raya 10", - "accepts_point_payment": true, "earns_points": true, "earns_coins": false } @@ -243,8 +245,6 @@ berdasarkan nama. ``` - `address` bisa `null`. -- `accepts_point_payment`: kasir di outlet ini menerima pembayaran EnakPoint. Pakai - untuk label "Bisa bayar pakai EnakPoint". - `earns_points` / `earns_coins`: belanja di outlet ini memberi EnakPoint / EnakCoin. - Belum ada telepon, koordinat, atau jam buka; data itu belum disimpan di backend. @@ -318,8 +318,7 @@ hanya masuk ke sini bila kasir mengaitkannya ke customer. } ], "payments": [ - { "id": "…", "method_name": "EnakPoint", "method_type": "point", "amount": 12500, "status": "completed", "refund_amount": 0, "points_used": 12500, "point_value": 1, "created_at": "…" }, - { "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 86500, "status": "completed", "refund_amount": 0, "created_at": "…" } + { "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 99000, "status": "completed", "refund_amount": 0, "created_at": "…" } ] } ``` @@ -327,7 +326,6 @@ hanya masuk ke sini bila kasir mengaitkannya ke customer. - Order customer lain atau yang tidak ada → `404`. - `points_earned` / `coins_earned`: yang didapat dari order ini; 0 bila tidak ada. - Item timbangan membawa `weight` dan `unit_name`; tampilkan "1 × 4,2 ons". -- Pembayaran EnakPoint membawa `points_used`; tampilkan "EnakPoint 12.500 (Rp 12.500)". - Order yang `is_void` atau `is_refund` tetap tampil, beri label "Dibatalkan" / "Direfund". @@ -405,7 +403,7 @@ Minta OTP lagi terlalu cepat → `429`: tampilkan hitung mundur. 2. `POST /customer/pin/reset` dengan `{ "otp_token", "otp_code", "pin", "confirm_pin" }`. Reset juga membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**; -bayar dan tukar tetap bisa. Beri tahu customer hal ini di layar sukses. +tukar tetap bisa. Beri tahu customer hal ini di layar sukses. ### 6.5 Menangani error PIN @@ -428,41 +426,9 @@ ditolak. Penghitung ada di server, jadi jangan membuat penghitung sendiri di app --- -## 7. Membayar dengan EnakPoint +## 7. Tukar dan transfer -App customer tidak membuat atau membayar order; order hanya bisa dilihat (§4.5). -EnakPoint hanya dipakai membayar di kasir, lewat kode bayar dari app. Jangan membangun -layar checkout atau memanggil `POST /customer/orders/:id/pay-with-points`. - -### 7.1 Di kasir — kode bayar - -Customer tidak pernah mengetik PIN di mesin kasir. Alurnya: - -1. Customer membuka "Bayar di kasir" dan memasukkan PIN. -2. `POST /api/v1/customer/wallet/payment-code` dengan `{ "pin": "482913" }`: - - ```json - { "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" } - ``` - -3. Tampilkan `code` besar (angka) **dan** QR dari `qr_payload` (string apa adanya). -4. Tampilkan hitung mundur ke `expires_at` (2 menit). Setelah habis, sembunyikan kode - dan tampilkan tombol "Buat kode baru". -5. Kasir memindai/mengetik kode dan memilih jumlah EnakPoint. App tidak menerima - callback; setelah customer kembali ke beranda, muat ulang saldo. - -Kode sekali pakai. Membuat kode baru membatalkan kode lama. - -### 7.2 Refund - -Bila order yang dibayar EnakPoint dibatalkan atau direfund, EnakPoint kembali sebagai -EnakPoint (tidak pernah tunai) dan muncul di riwayat sebagai `PAYMENT_REFUND`. - ---- - -## 8. Tukar dan transfer - -### 8.1 Tukar EnakCoin → EnakPoint +### 7.1 Tukar EnakCoin → EnakPoint 1. Customer mengetik jumlah EnakCoin. Panggil preview (debounce saat mengetik): @@ -503,7 +469,7 @@ EnakPoint (tidak pernah tunai) dan muncul di riwayat sebagai `PAYMENT_REFUND`. 3. Layar sukses: saldo baru, dan bila `lots[].expires_at` ada, "EnakPoint ini berlaku sampai {tanggal}". -### 8.2 Transfer +### 7.2 Transfer 1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah. 2. Cek penerima: @@ -556,7 +522,7 @@ Penerima mendapat push `WALLET_TRANSFER_IN`. --- -## 9. Game (memakai EnakCoin) +## 8. Game (memakai EnakCoin) `POST /api/v1/customer/spin` dengan `{ "spin_id": "" }`. Tanpa PIN. @@ -577,7 +543,7 @@ Penerima mendapat push `WALLET_TRANSFER_IN`. --- -## 10. Yang sudah dihapus / deprecated +## 9. Yang sudah dihapus / deprecated Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada): @@ -586,6 +552,12 @@ Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada): | `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` | | `total_tokens`, `tokens_history` | `coin_balance`, `GET /customer/wallet/transactions?currency=COIN` | | `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` | +| `POST /customer/wallet/payment-code` | Tidak ada; EnakPoint tidak bisa untuk bayar | +| `POST /customer/orders/:id/pay-with-points` | Tidak ada; EnakPoint tidak bisa untuk bayar | +| `GET /orders/:id/point-payment/preview` (POS) | Tidak ada; EnakPoint tidak bisa untuk bayar | +| `accepts_point_payment` di `GET /customer/outlets` | – | +| `points_used`, `point_value` di `payments` pada `GET /customer/orders/:id` | – | +| Tipe mutasi `PAYMENT`, `PAYMENT_REFUND` di riwayat | Tidak ditulis lagi | Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru): @@ -596,7 +568,7 @@ Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru): --- -## 11. Checklist selesai +## 10. Checklist selesai - [ ] Beranda menampilkan saldo EnakPoint ("setara potongan Rp …"), EnakCoin, dan banner kedaluwarsa terdekat. - [ ] Riwayat dengan tab per mata uang, filter tipe/tanggal, infinite scroll, label tipe sesuai §4.2. @@ -605,10 +577,9 @@ Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru): - [ ] Penanganan tap untuk keempat tipe push. - [ ] PIN diminta hanya saat aksi yang membutuhkan; alur buat, ganti, dan lupa PIN lewat OTP. - [ ] Keempat error PIN ditangani di semua layar yang meminta PIN. -- [ ] Kode bayar: angka + QR, hitung mundur 2 menit, tombol buat ulang. - [ ] Tukar dengan preview, kelipatan kurs, konfirmasi, `Idempotency-Key`, retry dengan key sama. - [ ] Transfer dengan cek penerima tersamar, konfirmasi, `Idempotency-Key`, retry dengan key sama. - [ ] Game memakai `coins_used` / `coins_remaining` dan menampilkan biaya per game. - [ ] Riwayat order dengan pagination dan layar detail (item, pembayaran, EnakPoint/EnakCoin yang didapat). -- [ ] Tidak ada pemakaian endpoint atau field di §10. +- [ ] Tidak ada pemakaian endpoint atau field di §9. - [ ] PIN tidak pernah disimpan, di-log, atau dikirim ke analytics. diff --git a/docs/prd-point-coin.md b/docs/prd-point-coin.md index 5e07b65..d09ca03 100644 --- a/docs/prd-point-coin.md +++ b/docs/prd-point-coin.md @@ -7,6 +7,14 @@ dengan EnakPoint, exchange EnakCoin → EnakPoint, transfer antar customer, keda saldo, PIN customer, pengaturan per outlet dan per organisasi, migrasi dari Token **Out of scope:** Penukaran reward, tier otomatis, eksekusi campaign rules (lihat §11) +> **Catatan 2026-10-07:** F9 (Bayar Order dengan EnakPoint), bagian pembayaran dari K2, +> dan bagian terkait (aturan kembalian/refund EnakPoint di K7, PIN untuk bayar dan kode +> bayar di K8, payment method EnakPoint, ledger `PAYMENT` / `PAYMENT_REFUND`, endpoint +> pembayaran di §9, dan bagian pembayaran fase 3 di §13) digantikan oleh +> [`enakgame-prd.md`](./enakgame-prd.md) §3.2: EnakPoint hanya bisa ditukar ke voucher, +> tidak bisa dipakai membayar dan tidak bisa dicairkan. Fitur tersebut sudah dihapus dari +> backend (migrasi `000102`). Dokumen ini dibiarkan apa adanya sebagai riwayat keputusan. + --- ## 1. Latar Belakang @@ -389,6 +397,10 @@ beredar. Karena itu: ### F9 — Bayar Order dengan EnakPoint +> **Dihapus 2026-10-07:** digantikan oleh [`enakgame-prd.md`](./enakgame-prd.md) §3.2 +> (EnakPoint hanya untuk voucher) dan sudah dihapus dari backend (migrasi `000102`); +> lihat catatan di awal dokumen. + **Payment method.** Setiap organisasi otomatis punya satu payment method sistem bernama **EnakPoint** dengan tipe baru `point` di `payment_methods`. Method ini tidak bisa dihapus atau diubah tipenya. Muncul di kasir hanya jika outlet mengaktifkan diff --git a/docs/tasks-point-coin.md b/docs/tasks-point-coin.md index 1129ad6..af58651 100644 --- a/docs/tasks-point-coin.md +++ b/docs/tasks-point-coin.md @@ -3,6 +3,13 @@ **Sumber:** [PRD EnakPoint & EnakCoin](prd-point-coin.md) **Tanggal:** 2026-09-29 +> **Catatan 2026-10-07:** fase 3 (Pembayaran EnakPoint) dihapus sesuai +> [`enakgame-prd.md`](./enakgame-prd.md) §3.2: EnakPoint hanya bisa ditukar ke voucher, +> bukan alat bayar. Payment method EnakPoint, kode bayar, bayar di kasir & app, refund +> EnakPoint, dan EnakPoint di laporan (PC-303 – PC-308) sudah dihapus dari backend +> (migrasi `000102`). PIN customer (PC-301) dan pengaturan loyalitas organisasi (PC-302) +> tetap dipakai. Sisa dokumen ini dibiarkan sebagai riwayat. + Setiap task menyebut bagian PRD 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. diff --git a/go.mod b/go.mod index f58d285..fa0aba6 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,6 @@ require ( github.com/subosito/gotenv v1.4.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect - github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect @@ -108,7 +107,6 @@ require ( require ( firebase.google.com/go/v4 v4.19.0 - github.com/alicebob/miniredis/v2 v2.39.0 github.com/aws/aws-sdk-go v1.55.7 github.com/boombuler/barcode v1.1.0 github.com/golang-jwt/jwt/v5 v5.2.3 diff --git a/go.sum b/go.sum index b178a33..3f6c466 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,6 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= -github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g= -github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= @@ -351,8 +349,6 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= -github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= diff --git a/internal/app/app.go b/internal/app/app.go index e477e73..ece9b5e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -161,8 +161,6 @@ func (a *App) Initialize(cfg *config.Config) error { validators.walletValidator, services.loyaltySettingsService, services.customerPinService, - services.pointPaymentService, - services.customerOrderPaymentService, services.customerWalletService, services.customerDeviceService, services.customerOutletService, @@ -399,8 +397,6 @@ type processors struct { loyaltySettingsProcessor *processor.LoyaltySettingsProcessor earningProcessor *processor.EarningProcessor customerPinProcessor *processor.CustomerPinProcessor - paymentCodeProcessor *processor.PaymentCodeProcessor - pointPaymentProcessor *processor.PointPaymentProcessor walletExchangeProcessor *processor.WalletExchangeProcessor walletTransferProcessor *processor.WalletTransferProcessor walletTraceProcessor *processor.WalletTraceProcessor @@ -418,7 +414,6 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) // Customer PIN (docs/prd-point-coin.md F11) customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, customerDeviceProcessor) - paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor) inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo) @@ -426,9 +421,6 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor // Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3) earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) orderProcessor.SetLoyalty(earningProcessor) - // Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9) - pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) - orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor) // Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4) walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) // Send EnakPoint or EnakCoin to another customer; the recipient gets a push through FCM (docs/prd-point-coin.md F5) @@ -444,7 +436,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo), inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo), orderProcessor: orderProcessor, - paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo, loyaltySettingsProcessor), + paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo), fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient), customerProcessor: processor.NewCustomerProcessor(repos.customerRepo), analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo), @@ -482,8 +474,6 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor loyaltySettingsProcessor: loyaltySettingsProcessor, earningProcessor: earningProcessor, customerPinProcessor: customerPinProcessor, - paymentCodeProcessor: paymentCodeProcessor, - pointPaymentProcessor: pointPaymentProcessor, walletExchangeProcessor: walletExchangeProcessor, walletTransferProcessor: walletTransferProcessor, walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)), @@ -536,8 +526,6 @@ type services struct { walletAdminService *service.WalletAdminServiceImpl loyaltySettingsService *service.LoyaltySettingsServiceImpl customerPinService *service.CustomerPinServiceImpl - pointPaymentService *service.PointPaymentServiceImpl - customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl customerWalletService *service.CustomerWalletServiceImpl customerDeviceService *service.CustomerDeviceServiceImpl customerOutletService *service.CustomerOutletServiceImpl @@ -625,9 +613,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor, processors.walletTraceProcessor), loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo), - customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), - pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor), - customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor), + customerPinService: service.NewCustomerPinService(processors.customerPinProcessor), customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor), customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor), customerOutletService: service.NewCustomerOutletService(processors.customerOutletProcessor), diff --git a/internal/constants/loyalty.go b/internal/constants/loyalty.go index 3c1283f..e4d06ca 100644 --- a/internal/constants/loyalty.go +++ b/internal/constants/loyalty.go @@ -4,7 +4,7 @@ package constants // outlet_settings and organization keys in organization_settings. A key that was // never set takes the default in the PRD. -// Per outlet (F1): what an order earns, and whether EnakPoint can pay. +// Per outlet (F1): what an order earns. const ( LoyaltyPointEnabledKey = "loyalty.point.enabled" LoyaltyPointEarnModeKey = "loyalty.point.earn_mode" @@ -21,10 +21,6 @@ const ( LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value" LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount" LoyaltyCoinMaxPerOrderKey = "loyalty.coin.max_per_order" - - LoyaltyPointAcceptPaymentKey = "loyalty.point.accept_payment" - LoyaltyPointMinPaymentPointsKey = "loyalty.point.min_payment_points" - LoyaltyPointMaxPaymentPercentKey = "loyalty.point.max_payment_percent" ) // Per organization (F2, F12): the value of EnakPoint, the exchange rate, transfers and @@ -81,9 +77,6 @@ const ( LoyaltyEarnModeDefault = LoyaltyEarnModePerAmount LoyaltyEarnPercentDefault = float64(1) - LoyaltyMinPaymentPointsDefault = int64(1) - LoyaltyMaxPaymentPercentDefault = int64(100) - LoyaltyPointValueDefault = int64(1) LoyaltyExchangeAmountDefault = int64(1) diff --git a/internal/constants/payment.go b/internal/constants/payment.go index 75138db..a95330e 100644 --- a/internal/constants/payment.go +++ b/internal/constants/payment.go @@ -8,9 +8,6 @@ const ( PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet" PaymentMethodTypeQR PaymentMethodType = "qr" PaymentMethodTypeEDC PaymentMethodType = "edc" - // Paying with EnakPoint (docs/prd-point-coin.md F9). Not accepted as a payment method - // type until that phase ships. - PaymentMethodTypePoint PaymentMethodType = "point" ) type PaymentStatus string diff --git a/internal/constants/wallet.go b/internal/constants/wallet.go index 2892fdd..7a0e84f 100644 --- a/internal/constants/wallet.go +++ b/internal/constants/wallet.go @@ -14,26 +14,23 @@ func IsValidWalletCurrency(currency string) bool { // Ledger row types. §8.1 of the PRD lists, per type, which currency it may use, which // way it moves the balance, and which reference it must carry. const ( - WalletTxTypeEarn = "EARN" - WalletTxTypeEarnReversal = "EARN_REVERSAL" - WalletTxTypePayment = "PAYMENT" - WalletTxTypePaymentRefund = "PAYMENT_REFUND" - WalletTxTypeExchangeOut = "EXCHANGE_OUT" - WalletTxTypeExchangeIn = "EXCHANGE_IN" - WalletTxTypeTransferOut = "TRANSFER_OUT" - WalletTxTypeTransferIn = "TRANSFER_IN" - WalletTxTypeGameSpend = "GAME_SPEND" - WalletTxTypeExpire = "EXPIRE" - WalletTxTypeAdjustment = "ADJUSTMENT" - WalletTxTypeMigration = "MIGRATION" - WalletTxTypeRewardRedeem = "REWARD_REDEEM" + WalletTxTypeEarn = "EARN" + WalletTxTypeEarnReversal = "EARN_REVERSAL" + WalletTxTypeExchangeOut = "EXCHANGE_OUT" + WalletTxTypeExchangeIn = "EXCHANGE_IN" + WalletTxTypeTransferOut = "TRANSFER_OUT" + WalletTxTypeTransferIn = "TRANSFER_IN" + WalletTxTypeGameSpend = "GAME_SPEND" + WalletTxTypeExpire = "EXPIRE" + WalletTxTypeAdjustment = "ADJUSTMENT" + WalletTxTypeMigration = "MIGRATION" + WalletTxTypeRewardRedeem = "REWARD_REDEEM" ) // What a ledger row's reference_id points at: where the value came from for a // credit, or where it went for a debit. const ( WalletRefTypeOrder = "ORDER" - WalletRefTypePayment = "PAYMENT" WalletRefTypeWalletTx = "WALLET_TX" WalletRefTypeGamePlay = "GAME_PLAY" WalletRefTypeLot = "LOT" diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 2b4e3e5..331673a 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -28,11 +28,7 @@ type PaymentMethodAnalyticsResponse struct { // PaymentMethodSummary represents the summary of payment method analytics type PaymentMethodSummary struct { - // Money actually received; EnakPoint is reported apart (docs/prd-point-coin.md F9). TotalAmount float64 `json:"total_amount"` - PointAmount float64 `json:"point_amount"` - PointsUsed int64 `json:"points_used"` - TotalWithPoints float64 `json:"total_with_points"` TotalOrders int64 `json:"total_orders"` TotalPayments int64 `json:"total_payments"` AverageOrderValue float64 `json:"average_order_value"` @@ -46,8 +42,6 @@ type PaymentMethodAnalyticsData struct { OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` Percentage float64 `json:"percentage"` - PointsUsed int64 `json:"points_used"` - CountsAsCashIn bool `json:"counts_as_cash_in"` } type SalesAnalyticsRequest struct { diff --git a/internal/contract/customer_pin_contract.go b/internal/contract/customer_pin_contract.go index cb550cc..9b089eb 100644 --- a/internal/contract/customer_pin_contract.go +++ b/internal/contract/customer_pin_contract.go @@ -26,14 +26,3 @@ type ResetCustomerPinRequest = CreateCustomerPinRequest type RemoveCustomerPinRequest struct { Reason string `json:"reason" binding:"required"` } - -// IssuePaymentCodeRequest is POST /customer/wallet/payment-code. -type IssuePaymentCodeRequest struct { - Pin string `json:"pin" binding:"required"` -} - -// PayWithPointsRequest is POST /customer/orders/:id/pay-with-points. -type PayWithPointsRequest struct { - Points int64 `json:"points" binding:"required,min=1"` - Pin string `json:"pin" binding:"required"` -} diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index b95b492..0dc8b6a 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -186,13 +186,9 @@ type SetOrderCustomerResponse struct { } type CreatePaymentRequest struct { - OrderID uuid.UUID `json:"order_id" validate:"required"` - PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"` - // For the EnakPoint method: how many to use and the code the customer shows. The - // amount is then computed from them. - Points *int64 `json:"points,omitempty"` - PaymentCode *string `json:"payment_code,omitempty"` - Amount float64 `json:"amount" validate:"min=0"` + OrderID uuid.UUID `json:"order_id" validate:"required"` + PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"` + Amount float64 `json:"amount" validate:"required,min=0"` TransactionID *string `json:"transaction_id,omitempty" validate:"omitempty"` SplitNumber int `json:"split_number,omitempty" validate:"omitempty,min=1"` SplitTotal int `json:"split_total,omitempty" validate:"omitempty,min=1"` @@ -208,23 +204,19 @@ type CreatePaymentOrderItemRequest struct { } type PaymentResponse struct { - ID uuid.UUID `json:"id"` - OrderID uuid.UUID `json:"order_id"` - PaymentMethodID uuid.UUID `json:"payment_method_id"` - PaymentMethodName string `json:"payment_method_name"` - PaymentMethodType string `json:"payment_method_type"` - Amount float64 `json:"amount"` - Status string `json:"status"` - TransactionID *string `json:"transaction_id,omitempty"` - SplitNumber int `json:"split_number"` - SplitTotal int `json:"split_total"` - SplitType *string `json:"split_type,omitempty"` - SplitDescription *string `json:"split_description,omitempty"` - RefundAmount float64 `json:"refund_amount"` - // Set for a payment with EnakPoint, for "EnakPoint: 50.000 (Rp 50.000)" on the - // receipt (docs/prd-point-coin.md F9). - PointsUsed *int64 `json:"points_used,omitempty"` - PointValue *float64 `json:"point_value,omitempty"` + ID uuid.UUID `json:"id"` + OrderID uuid.UUID `json:"order_id"` + PaymentMethodID uuid.UUID `json:"payment_method_id"` + PaymentMethodName string `json:"payment_method_name"` + PaymentMethodType string `json:"payment_method_type"` + Amount float64 `json:"amount"` + Status string `json:"status"` + TransactionID *string `json:"transaction_id,omitempty"` + SplitNumber int `json:"split_number"` + SplitTotal int `json:"split_total"` + SplitType *string `json:"split_type,omitempty"` + SplitDescription *string `json:"split_description,omitempty"` + RefundAmount float64 `json:"refund_amount"` RefundReason *string `json:"refund_reason,omitempty"` RefundedAt *time.Time `json:"refunded_at,omitempty"` RefundedBy *uuid.UUID `json:"refunded_by,omitempty"` diff --git a/internal/contract/payment_method_contract.go b/internal/contract/payment_method_contract.go index e7e2155..9153b7a 100644 --- a/internal/contract/payment_method_contract.go +++ b/internal/contract/payment_method_contract.go @@ -18,7 +18,7 @@ type CreatePaymentMethodRequest struct { type UpdatePaymentMethodRequest struct { Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"` + Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"` Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"` Configuration map[string]interface{} `json:"configuration,omitempty"` IsActive *bool `json:"is_active,omitempty"` @@ -38,13 +38,11 @@ type PaymentMethodResponse struct { type ListPaymentMethodsRequest struct { OrganizationID *uuid.UUID `json:"organization_id,omitempty"` - // When set, EnakPoint is left out unless the outlet accepts it (F9). - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"` - IsActive *bool `json:"is_active,omitempty"` - Search string `json:"search,omitempty"` - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` + Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"` + IsActive *bool `json:"is_active,omitempty"` + Search string `json:"search,omitempty"` + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` } type ListPaymentMethodsResponse struct { diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 2994c8a..0d2859f 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -14,8 +14,6 @@ type PaymentMethodAnalytics struct { TotalAmount float64 `json:"total_amount"` OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` - // EnakPoint used, for the EnakPoint method. - PointsUsed int64 `json:"points_used"` } // SalesAnalytics represents sales analytics data diff --git a/internal/entities/payment.go b/internal/entities/payment.go index f1bf058..1e7ea29 100644 --- a/internal/entities/payment.go +++ b/internal/entities/payment.go @@ -13,16 +13,13 @@ const ( PaymentMethodTypeCash PaymentMethodType = "cash" PaymentMethodTypeCard PaymentMethodType = "card" PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet" - // The system method for paying with EnakPoint (docs/prd-point-coin.md F9). One per - // organization; it cannot be created, deleted or retyped through the API. - PaymentMethodTypePoint PaymentMethodType = "point" ) type PaymentMethod struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` Name string `gorm:"not null;size:100" json:"name" validate:"required,min=1,max=100"` - Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet point"` + Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet"` Processor *string `gorm:"size:100" json:"processor"` Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"` IsActive bool `gorm:"default:true" json:"is_active"` @@ -72,16 +69,12 @@ type Payment struct { SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"` SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"` RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"` - // Set only for a payment with EnakPoint: how many were used, and the rupiah value of - // one then, frozen so a refund returns exactly what was used. - PointsUsed *int64 `json:"points_used,omitempty"` - PointValue *float64 `gorm:"type:decimal(10,2)" json:"point_value,omitempty"` - RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"` - RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"` - RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"` + RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"` + RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"` diff --git a/internal/handler/customer_order_payment_handler.go b/internal/handler/customer_order_payment_handler.go deleted file mode 100644 index 29f7071..0000000 --- a/internal/handler/customer_order_payment_handler.go +++ /dev/null @@ -1,35 +0,0 @@ -package handler - -import ( - "github.com/gin-gonic/gin" - - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" -) - -// CustomerOrderPaymentHandler serves POST /customer/orders/:id/pay-with-points -// (docs/prd-point-coin.md F9). The body holds the PIN, so it is never logged. -type CustomerOrderPaymentHandler struct { - payments service.CustomerOrderPaymentService -} - -func NewCustomerOrderPaymentHandler(payments service.CustomerOrderPaymentService) *CustomerOrderPaymentHandler { - return &CustomerOrderPaymentHandler{payments: payments} -} - -func (h *CustomerOrderPaymentHandler) PayWithPoints(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerOrderPaymentHandler::PayWithPoints") - if !ok { - return - } - orderID, ok := parseUUIDParam(c, "id", "CustomerOrderPaymentHandler::PayWithPoints") - if !ok { - return - } - var req contract.PayWithPointsRequest - if !bindPinRequest(c, &req, "CustomerOrderPaymentHandler::PayWithPoints") { - return - } - util.HandleResponse(c.Writer, c.Request, h.payments.PayWithPoints(c.Request.Context(), customerID, orderID, &req, pinRequestInfo(c)), "CustomerOrderPaymentHandler::PayWithPoints") -} diff --git a/internal/handler/customer_pin_handler.go b/internal/handler/customer_pin_handler.go index c0f9185..65279f2 100644 --- a/internal/handler/customer_pin_handler.go +++ b/internal/handler/customer_pin_handler.go @@ -136,16 +136,3 @@ func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) { func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo { return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()} } - -// IssuePaymentCode is POST /customer/wallet/payment-code. -func (h *CustomerPinHandler) IssuePaymentCode(c *gin.Context) { - customerID, ok := customerIDFromGin(c, "CustomerPinHandler::IssuePaymentCode") - if !ok { - return - } - var req contract.IssuePaymentCodeRequest - if !bindPinRequest(c, &req, "CustomerPinHandler::IssuePaymentCode") { - return - } - util.HandleResponse(c.Writer, c.Request, h.pinService.IssuePaymentCode(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::IssuePaymentCode") -} diff --git a/internal/handler/customer_wallet_db_test.go b/internal/handler/customer_wallet_db_test.go index 6cc88d6..2b36685 100644 --- a/internal/handler/customer_wallet_db_test.go +++ b/internal/handler/customer_wallet_db_test.go @@ -82,9 +82,9 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { return err } if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{ - CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, - Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet, - Description: "Bayar #ORD-2"}}); err != nil { + CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem, + Amount: 50, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: payment, OutletID: &outlet, + Description: "Tukar voucher"}}); err != nil { return err } _, err := wallet.Credit(ctx, processor.WalletCreditInput{ @@ -162,7 +162,7 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { assert.Contains(t, tx, "source", tx["type"]) assert.NotContains(t, tx, "destination") } else { - assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"]) + assert.Equal(t, map[string]any{"type": "REWARD_REDEMPTION", "id": payment.String()}, tx["destination"]) assert.NotContains(t, tx, "source") } } diff --git a/internal/handler/loyalty_settings_db_test.go b/internal/handler/loyalty_settings_db_test.go index 57d6804..54ec394 100644 --- a/internal/handler/loyalty_settings_db_test.go +++ b/internal/handler/loyalty_settings_db_test.go @@ -93,7 +93,7 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { got := data(body) assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(100), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"]) assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(25000), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"]) - assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"]) + assert.NotContains(t, got, "point_payment", "EnakPoint cannot pay (docs/enakgame-prd.md §3.2)") assert.EqualValues(t, 1, got["point_value"]) assert.EqualValues(t, 1, got["point_cashback_percent"]) @@ -133,16 +133,16 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { // Values out of bounds, unknown fields and bad JSON are refused and change nothing. for name, bad := range map[string]string{ - "earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`, - "negative earn_value": `{"coin": {"earn_value": -1}}`, - "earn mode unknown": `{"point": {"earn_mode": "PERCENT"}}`, - "earn_percent over 100": `{"coin": {"earn_percent": 101}}`, - "negative min_order": `{"point": {"min_order_amount": -5}}`, - "negative max_per_order": `{"point": {"max_per_order": -1}}`, - "payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`, - "unknown field": `{"point": {"earn_per_amout": 50}}`, - "wrong type": `{"point": {"enabled": "yes"}}`, - "not json": `enabled=true`, + "earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`, + "negative earn_value": `{"coin": {"earn_value": -1}}`, + "earn mode unknown": `{"point": {"earn_mode": "PERCENT"}}`, + "earn_percent over 100": `{"coin": {"earn_percent": 101}}`, + "negative min_order": `{"point": {"min_order_amount": -5}}`, + "negative max_per_order": `{"point": {"max_per_order": -1}}`, + "point_payment removed": `{"point_payment": {"accept_payment": true}}`, + "unknown field": `{"point": {"earn_per_amout": 50}}`, + "wrong type": `{"point": {"enabled": "yes"}}`, + "not json": `enabled=true`, } { status, _ = call(http.MethodPut, "/manager"+path, bad) assert.Equal(t, http.StatusBadRequest, status, name) diff --git a/internal/handler/order_handler.go b/internal/handler/order_handler.go index df26367..fa3971b 100644 --- a/internal/handler/order_handler.go +++ b/internal/handler/order_handler.go @@ -1,11 +1,8 @@ package handler import ( - "errors" - "apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/processor" "apskel-pos-be/internal/service" "apskel-pos-be/internal/transformer" "apskel-pos-be/internal/util" @@ -205,11 +202,6 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) { } if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil { - // Refusing to hand EnakPoint back as cash is a bad request, not a server fault. - if errors.Is(err, processor.ErrPointPaymentRejected) { - util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::RefundOrder") - return - } util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder") return } @@ -233,11 +225,6 @@ func (h *OrderHandler) CreatePayment(c *gin.Context) { response, err := h.orderService.CreatePayment(c.Request.Context(), modelReq) if err != nil { - // A refused EnakPoint payment is the cashier's or customer's to fix, not a server fault. - if errors.Is(err, processor.ErrPointPaymentRejected) { - util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::CreatePayment") - return - } util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::CreatePayment", err.Error())}), "OrderHandler::CreatePayment") return } diff --git a/internal/handler/payment_method_handler.go b/internal/handler/payment_method_handler.go index 290d0df..49a914a 100644 --- a/internal/handler/payment_method_handler.go +++ b/internal/handler/payment_method_handler.go @@ -113,16 +113,6 @@ func (h *PaymentMethodHandler) ListPaymentMethods(c *gin.Context) { req.OrganizationID = &contextInfo.OrganizationID - // At the cashier, EnakPoint is listed only where the outlet accepts it (F9). - if outletStr := c.Query("outlet_id"); outletStr != "" { - if outletID, err := uuid.Parse(outletStr); err == nil { - req.OutletID = &outletID - } - } else if contextInfo.OutletID != uuid.Nil { - outletID := contextInfo.OutletID - req.OutletID = &outletID - } - if isActiveStr := c.Query("is_active"); isActiveStr != "" { if isActive, err := strconv.ParseBool(isActiveStr); err == nil { req.IsActive = &isActive diff --git a/internal/handler/point_payment_handler.go b/internal/handler/point_payment_handler.go deleted file mode 100644 index 3c1f3e2..0000000 --- a/internal/handler/point_payment_handler.go +++ /dev/null @@ -1,28 +0,0 @@ -package handler - -import ( - "github.com/gin-gonic/gin" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" -) - -// PointPaymentHandler serves GET /orders/:id/point-payment/preview -// (docs/prd-point-coin.md F9). -type PointPaymentHandler struct { - pointPaymentService service.PointPaymentService -} - -func NewPointPaymentHandler(pointPaymentService service.PointPaymentService) *PointPaymentHandler { - return &PointPaymentHandler{pointPaymentService: pointPaymentService} -} - -func (h *PointPaymentHandler) Preview(c *gin.Context) { - orderID, ok := parseUUIDParam(c, "id", "PointPaymentHandler::Preview") - if !ok { - return - } - ctx := c.Request.Context() - util.HandleResponse(c.Writer, c.Request, h.pointPaymentService.Preview(ctx, appcontext.FromGinContext(ctx), orderID), "PointPaymentHandler::Preview") -} diff --git a/internal/mappers/order_mapper.go b/internal/mappers/order_mapper.go index e4b5803..b25cfae 100644 --- a/internal/mappers/order_mapper.go +++ b/internal/mappers/order_mapper.go @@ -189,8 +189,6 @@ func PaymentEntityToResponse(payment *entities.Payment) *models.PaymentResponse SplitType: (*string)(payment.SplitType), SplitDescription: payment.SplitDescription, RefundAmount: payment.RefundAmount, - PointsUsed: payment.PointsUsed, - PointValue: payment.PointValue, RefundReason: payment.RefundReason, RefundedAt: payment.RefundedAt, RefundedBy: payment.RefundedBy, diff --git a/internal/mappers/payment_method_mapper.go b/internal/mappers/payment_method_mapper.go index 526acac..651e721 100644 --- a/internal/mappers/payment_method_mapper.go +++ b/internal/mappers/payment_method_mapper.go @@ -134,7 +134,6 @@ func ListPaymentMethodsContractToModel(req *contract.ListPaymentMethodsRequest) return &models.ListPaymentMethodsRequest{ OrganizationID: req.OrganizationID, - OutletID: req.OutletID, Type: paymentMethodType, IsActive: req.IsActive, Search: req.Search, diff --git a/internal/models/analytics.go b/internal/models/analytics.go index ca11f70..2307636 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -33,14 +33,7 @@ type PaymentMethodAnalyticsResponse struct { // PaymentMethodSummary represents the summary of payment method analytics type PaymentMethodSummary struct { - // Money actually received. EnakPoint is not money in (docs/prd-point-coin.md F9, - // K7) and is reported apart; its accounting treatment waits on note N2. - TotalAmount float64 `json:"total_amount"` - // Rupiah paid with EnakPoint, and how many EnakPoint that was. - PointAmount float64 `json:"point_amount"` - PointsUsed int64 `json:"points_used"` - // TotalAmount plus PointAmount: the value of the orders paid. - TotalWithPoints float64 `json:"total_with_points"` + TotalAmount float64 `json:"total_amount"` TotalOrders int64 `json:"total_orders"` TotalPayments int64 `json:"total_payments"` AverageOrderValue float64 `json:"average_order_value"` @@ -54,11 +47,7 @@ type PaymentMethodAnalyticsData struct { TotalAmount float64 `json:"total_amount"` OrderCount int64 `json:"order_count"` PaymentCount int64 `json:"payment_count"` - // Share of the money received; 0 for EnakPoint, which is not money in. - Percentage float64 `json:"percentage"` - PointsUsed int64 `json:"points_used"` - // False for EnakPoint. - CountsAsCashIn bool `json:"counts_as_cash_in"` + Percentage float64 `json:"percentage"` } // SalesAnalyticsRequest represents the request for sales analytics diff --git a/internal/models/customer_order.go b/internal/models/customer_order.go index c5e5fad..fff0f80 100644 --- a/internal/models/customer_order.go +++ b/internal/models/customer_order.go @@ -61,10 +61,7 @@ type CustomerOrderPayment struct { Amount float64 `json:"amount"` Status string `json:"status"` RefundAmount float64 `json:"refund_amount"` - // Set for a payment with EnakPoint. - PointsUsed *int64 `json:"points_used,omitempty"` - PointValue *float64 `json:"point_value,omitempty"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at"` } // ListCustomerOrdersQuery is GET /customer/orders. diff --git a/internal/models/customer_outlet.go b/internal/models/customer_outlet.go index 191266d..53699b5 100644 --- a/internal/models/customer_outlet.go +++ b/internal/models/customer_outlet.go @@ -8,8 +8,6 @@ type CustomerOutlet struct { ID uuid.UUID `json:"id"` Name string `json:"name"` Address *string `json:"address"` - // The cashier accepts EnakPoint as payment here. - AcceptsPointPayment bool `json:"accepts_point_payment"` // Orders here earn EnakPoint / EnakCoin. EarnsPoints bool `json:"earns_points"` EarnsCoins bool `json:"earns_coins"` diff --git a/internal/models/customer_pin.go b/internal/models/customer_pin.go index 6b3616c..fe5477b 100644 --- a/internal/models/customer_pin.go +++ b/internal/models/customer_pin.go @@ -37,11 +37,3 @@ type CustomerPinRequestInfo struct { IPAddress string UserAgent string } - -// PaymentCode is what POST /customer/wallet/payment-code returns: a one-time code the -// customer shows the cashier, as digits or as a QR of QRPayload. -type PaymentCode struct { - Code string `json:"code"` - QRPayload string `json:"qr_payload"` - ExpiresAt time.Time `json:"expires_at"` -} diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go index 5733bdb..336692e 100644 --- a/internal/models/loyalty.go +++ b/internal/models/loyalty.go @@ -13,8 +13,6 @@ import ( type OutletLoyaltySettings struct { Point LoyaltyEarnSettings `json:"point"` Coin LoyaltyEarnSettings `json:"coin"` - // Paying with EnakPoint. EnakCoin cannot pay, so it has no counterpart. - PointPayment LoyaltyPointPaymentSettings `json:"point_payment"` } // LoyaltyEarnSettings is how much of one currency an order earns, nothing below @@ -36,13 +34,6 @@ type LoyaltyEarnSettings struct { MaxPerOrder *int64 `json:"max_per_order"` } -type LoyaltyPointPaymentSettings struct { - AcceptPayment bool `json:"accept_payment"` - MinPaymentPoints int64 `json:"min_payment_points"` - // Largest share of the order total, 0–100, that EnakPoint may pay. - MaxPaymentPercent int64 `json:"max_payment_percent"` -} - // OrganizationLoyaltySettings are the loyalty settings shared by every outlet of an // organization (docs/prd-point-coin.md F2, F12). type OrganizationLoyaltySettings struct { diff --git a/internal/models/payment.go b/internal/models/payment.go index 1269b9e..bf35894 100644 --- a/internal/models/payment.go +++ b/internal/models/payment.go @@ -28,10 +28,8 @@ type Payment struct { } type CreatePaymentRequest struct { - OrderID uuid.UUID `validate:"required"` - PaymentMethodID uuid.UUID `validate:"required"` - Points *int64 - PaymentCode *string + OrderID uuid.UUID `validate:"required"` + PaymentMethodID uuid.UUID `validate:"required"` Amount float64 `validate:"required,min=0"` TransactionID *string `validate:"omitempty"` SplitNumber int `validate:"omitempty,min=1"` @@ -62,9 +60,6 @@ type PaymentResponse struct { SplitType *string SplitDescription *string RefundAmount float64 - // Set for a payment with EnakPoint. - PointsUsed *int64 - PointValue *float64 RefundReason *string RefundedAt *time.Time RefundedBy *uuid.UUID diff --git a/internal/models/payment_method.go b/internal/models/payment_method.go index b7ea272..398586d 100644 --- a/internal/models/payment_method.go +++ b/internal/models/payment_method.go @@ -51,13 +51,11 @@ type PaymentMethodResponse struct { type ListPaymentMethodsRequest struct { OrganizationID *uuid.UUID - // When set, EnakPoint is left out unless the outlet accepts it (F9). - OutletID *uuid.UUID - Type *constants.PaymentMethodType - IsActive *bool - Search string - Page int `validate:"min=1"` - Limit int `validate:"min=1,max=100"` + Type *constants.PaymentMethodType + IsActive *bool + Search string + Page int `validate:"min=1"` + Limit int `validate:"min=1,max=100"` } type ListPaymentMethodsResponse struct { diff --git a/internal/models/wallet.go b/internal/models/wallet.go index 9412444..3f06f90 100644 --- a/internal/models/wallet.go +++ b/internal/models/wallet.go @@ -139,25 +139,6 @@ type AdminWalletAdjustmentResult struct { Replayed bool `json:"replayed"` } -// PointPaymentPreview is GET /orders/:id/point-payment/preview (docs/prd-point-coin.md -// F9): whether the order can be paid with EnakPoint and at most how much, for the -// cashier's "use maximum" button. -type PointPaymentPreview struct { - OrderID uuid.UUID `json:"order_id"` - CustomerID *uuid.UUID `json:"customer_id"` - Eligible bool `json:"eligible"` - // Why not, when not eligible. - Reason string `json:"reason,omitempty"` - PointBalance int64 `json:"point_balance"` - PointValue int64 `json:"point_value"` - RemainingAmount float64 `json:"remaining_amount"` - MinPaymentPoints int64 `json:"min_payment_points"` - MaxPaymentPercent int64 `json:"max_payment_percent"` - MaxPoints int64 `json:"max_points"` - // Rupiah covered by MaxPoints. - MaxAmount int64 `json:"max_amount"` -} - // CustomerWalletExpiringList is GET /customer/wallet/expiring (docs/prd-point-coin.md // F6): everything that will expire, per currency and day, soonest first. type CustomerWalletExpiringList struct { diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 804747f..dec5f89 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -63,37 +63,27 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, return nil, fmt.Errorf("failed to get payment method analytics: %w", err) } - // EnakPoint is not money in (docs/prd-point-coin.md F9, K7): it is listed as its own - // method but left out of the money received and of the shares. How it is booked - // waits on note N2. - var cashAmount, pointAmount float64 - var pointsUsed int64 + var totalAmount float64 var totalOrders int64 var totalPayments int64 for _, data := range analyticsData { - if data.PaymentMethodType == string(constants.PaymentMethodTypePoint) { - pointAmount += data.TotalAmount - pointsUsed += data.PointsUsed - } else { - cashAmount += data.TotalAmount - } + totalAmount += data.TotalAmount totalOrders += data.OrderCount totalPayments += data.PaymentCount } - // The value of an order includes what EnakPoint paid, so the average does too. var averageOrderValue float64 if totalOrders > 0 { - averageOrderValue = (cashAmount + pointAmount) / float64(totalOrders) + averageOrderValue = totalAmount / float64(totalOrders) } + // Calculate percentages var resultData []models.PaymentMethodAnalyticsData for _, data := range analyticsData { - cashIn := data.PaymentMethodType != string(constants.PaymentMethodTypePoint) var percentage float64 - if cashIn && cashAmount > 0 { - percentage = (data.TotalAmount / cashAmount) * 100 + if totalAmount > 0 { + percentage = (data.TotalAmount / totalAmount) * 100 } resultData = append(resultData, models.PaymentMethodAnalyticsData{ @@ -104,16 +94,11 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, OrderCount: data.OrderCount, PaymentCount: data.PaymentCount, Percentage: percentage, - PointsUsed: data.PointsUsed, - CountsAsCashIn: cashIn, }) } summary := models.PaymentMethodSummary{ - TotalAmount: cashAmount, - PointAmount: pointAmount, - PointsUsed: pointsUsed, - TotalWithPoints: cashAmount + pointAmount, + TotalAmount: totalAmount, TotalOrders: totalOrders, TotalPayments: totalPayments, AverageOrderValue: averageOrderValue, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index aef6306..30817c1 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -10,7 +10,6 @@ import ( "apskel-pos-be/internal/models" "github.com/google/uuid" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -574,38 +573,6 @@ func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *t require.Len(t, result.DailyTransactions, 2) } -// EnakPoint is listed as its own method but is not money in (F9, K7). -func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) { - repo := &analyticsRepositoryStub{paymentMethods: []*entities.PaymentMethodAnalytics{ - {PaymentMethodName: "Tunai", PaymentMethodType: "cash", TotalAmount: 70000, OrderCount: 2, PaymentCount: 2}, - {PaymentMethodName: "Kartu", PaymentMethodType: "card", TotalAmount: 20000, OrderCount: 1, PaymentCount: 1}, - {PaymentMethodName: "EnakPoint", PaymentMethodType: "point", TotalAmount: 30000, OrderCount: 1, PaymentCount: 1, PointsUsed: 30000}, - }} - p := NewAnalyticsProcessorImpl(repo, nil) - - got, err := p.GetPaymentMethodAnalytics(context.Background(), &models.PaymentMethodAnalyticsRequest{ - OrganizationID: uuid.New(), DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now(), - }) - require.NoError(t, err) - assert.Equal(t, 90000.0, got.Summary.TotalAmount, "money in leaves EnakPoint out") - assert.Equal(t, 30000.0, got.Summary.PointAmount) - assert.Equal(t, int64(30000), got.Summary.PointsUsed) - assert.Equal(t, 120000.0, got.Summary.TotalWithPoints) - assert.Equal(t, int64(4), got.Summary.TotalOrders) - assert.Equal(t, 30000.0, got.Summary.AverageOrderValue, "the value of an order includes what EnakPoint paid") - - byType := map[string]models.PaymentMethodAnalyticsData{} - for _, d := range got.Data { - byType[d.PaymentMethodType] = d - } - assert.True(t, byType["cash"].CountsAsCashIn) - assert.False(t, byType["point"].CountsAsCashIn) - assert.InDelta(t, 77.78, byType["cash"].Percentage, 0.01, "shares are of the money received") - assert.InDelta(t, 22.22, byType["card"].Percentage, 0.01) - assert.Zero(t, byType["point"].Percentage) - assert.Equal(t, int64(30000), byType["point"].PointsUsed) -} - // A parent category with its own owner fee percent moves the owner limit away from the // default share, and the team limit shrinks by the same amount. func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) { diff --git a/internal/processor/customer_order_processor.go b/internal/processor/customer_order_processor.go index 3a44014..512817c 100644 --- a/internal/processor/customer_order_processor.go +++ b/internal/processor/customer_order_processor.go @@ -129,8 +129,6 @@ func (p *CustomerOrderProcessor) Detail(ctx context.Context, customerID, orderID Amount: pay.Amount, Status: pay.Status, RefundAmount: pay.RefundAmount, - PointsUsed: pay.PointsUsed, - PointValue: pay.PointValue, CreatedAt: pay.CreatedAt, }) } diff --git a/internal/processor/customer_order_processor_test.go b/internal/processor/customer_order_processor_test.go index 380e9ad..6c31f76 100644 --- a/internal/processor/customer_order_processor_test.go +++ b/internal/processor/customer_order_processor_test.go @@ -110,7 +110,6 @@ func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) { repo, customer, mine, _ := newCustomerOrderTest() variant, unit := "Large", "ons" weight := 4.2 - points, value := int64(12500), 1.0 repo.items = map[uuid.UUID][]repository.CustomerOrderItemRow{ mine: { {ProductName: "Kopi Susu", VariantName: &variant, Quantity: 2, UnitPrice: 25000, TotalPrice: 50000, Status: "completed"}, @@ -119,7 +118,7 @@ func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) { } repo.payments = map[uuid.UUID][]repository.CustomerOrderPaymentRow{ mine: { - {MethodName: "EnakPoint", MethodType: "point", Amount: 12500, Status: "completed", PointsUsed: &points, PointValue: &value}, + {MethodName: "Card", MethodType: "card", Amount: 12500, Status: "completed"}, {MethodName: "Cash", MethodType: "cash", Amount: 86500, Status: "completed"}, }, } @@ -135,7 +134,8 @@ func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) { assert.Equal(t, []map[string]interface{}{}, got.Items[0].Modifiers, "no modifiers is an empty list, not null") assert.Equal(t, 4.2, *got.Items[1].Weight) require.Len(t, got.Payments, 2) - assert.Equal(t, int64(12500), *got.Payments[0].PointsUsed) + assert.Equal(t, "Card", got.Payments[0].MethodName) + assert.Equal(t, float64(12500), got.Payments[0].Amount) } func TestCustomerOrders_AnotherCustomersOrderIsNotFound(t *testing.T) { diff --git a/internal/processor/customer_outlet_processor.go b/internal/processor/customer_outlet_processor.go index 79c65a1..b2cfd99 100644 --- a/internal/processor/customer_outlet_processor.go +++ b/internal/processor/customer_outlet_processor.go @@ -37,12 +37,11 @@ func (p *CustomerOutletProcessor) List(ctx context.Context, customerID uuid.UUID return nil, err } list = append(list, models.CustomerOutlet{ - ID: o.ID, - Name: o.Name, - Address: o.Address, - AcceptsPointPayment: settings.PointPayment.AcceptPayment, - EarnsPoints: settings.Point.Enabled, - EarnsCoins: settings.Coin.Enabled, + ID: o.ID, + Name: o.Name, + Address: o.Address, + EarnsPoints: settings.Point.Enabled, + EarnsCoins: settings.Coin.Enabled, }) } return list, nil diff --git a/internal/processor/customer_outlet_processor_test.go b/internal/processor/customer_outlet_processor_test.go index 8f05519..4ed00b3 100644 --- a/internal/processor/customer_outlet_processor_test.go +++ b/internal/processor/customer_outlet_processor_test.go @@ -48,17 +48,14 @@ func TestCustomerOutlets_ListsTheCustomersOrganizationWithLoyaltyFlags(t *testin }, } settings := outletSettingsFake{ - kemang: { - Point: models.LoyaltyEarnSettings{Enabled: true}, - PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: true}, - }, + kemang: {Point: models.LoyaltyEarnSettings{Enabled: true}}, } got, err := NewCustomerOutletProcessor(repo, settings).List(context.Background(), customer) require.NoError(t, err) assert.Equal(t, []models.CustomerOutlet{ {ID: blokm, Name: "Blok M"}, - {ID: kemang, Name: "Kemang", Address: &addr, AcceptsPointPayment: true, EarnsPoints: true}, + {ID: kemang, Name: "Kemang", Address: &addr, EarnsPoints: true}, }, got) } diff --git a/internal/processor/customer_pin_processor.go b/internal/processor/customer_pin_processor.go index 0de4f19..7143735 100644 --- a/internal/processor/customer_pin_processor.go +++ b/internal/processor/customer_pin_processor.go @@ -47,6 +47,10 @@ const ( PinActionTransfer PinAction = "TRANSFER" ) +type pinVerifier interface { + VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error +} + // Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9). const ( PinErrNotSet = "PIN_NOT_SET" diff --git a/internal/processor/earning_calculator.go b/internal/processor/earning_calculator.go index f4b7b84..af6c4a2 100644 --- a/internal/processor/earning_calculator.go +++ b/internal/processor/earning_calculator.go @@ -21,7 +21,7 @@ type EarningLine struct { // EarningResult is what an order earns. type EarningResult struct { - // subtotal − discount − the part paid with EnakPoint, in rupiah, never negative. + // subtotal − discount, in rupiah, never negative. // Tax and anything else added on top of the subtotal are not part of it (Q1). Basis float64 Point EarningLine @@ -49,17 +49,16 @@ func (r EarningResult) Metadata(line EarningLine) entities.Metadata { // CalculateEarning applies the earning formula of docs/prd-point-coin.md F1: // -// basis = subtotal − discount_amount − paid with EnakPoint +// basis = subtotal − discount_amount // amount = 0 if basis < min_order_amount // amount = floor(basis / earn_per_amount) × earn_value in PER_AMOUNT mode // amount = floor(basis × earn_percent / 100) in PERCENTAGE mode // amount = min(amount, max_per_order) if max_per_order is set // -// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so -// floor never lands one short on a value like 87500.00 that float64 cannot hold -// exactly. It has no side effects. -func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings models.OutletLoyaltySettings) EarningResult { - basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount) - toCents(pointPaidAmount) +// Money is handled in whole cents so floor never lands one short on a value like +// 87500.00 that float64 cannot hold exactly. It has no side effects. +func CalculateEarning(order *entities.Order, settings models.OutletLoyaltySettings) EarningResult { + basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount) if basisCents < 0 { basisCents = 0 } diff --git a/internal/processor/earning_calculator_test.go b/internal/processor/earning_calculator_test.go index 2fe987d..d4b4ff1 100644 --- a/internal/processor/earning_calculator_test.go +++ b/internal/processor/earning_calculator_test.go @@ -22,35 +22,29 @@ func TestCalculateEarning_PRDExample(t *testing.T) { // Subtotal after discount Rp 87.500, paid in full in cash. order := &entities.Order{Subtotal: 97500, DiscountAmount: 10000, TaxAmount: 9625, TotalAmount: 97125} - got := CalculateEarning(order, 0, prdEarningSettings()) + got := CalculateEarning(order, prdEarningSettings()) assert.Equal(t, 87500.0, got.Basis) assert.Equal(t, int64(875), got.Point.Amount) assert.Equal(t, int64(3), got.Coin.Amount) - - // Rp 20.000 of it paid with EnakPoint earns nothing. - got = CalculateEarning(order, 20000, prdEarningSettings()) - assert.Equal(t, 67500.0, got.Basis) - assert.Equal(t, int64(675), got.Point.Amount) - assert.Equal(t, int64(2), got.Coin.Amount) } func TestCalculateEarning_TaxIsNotPartOfTheBasis(t *testing.T) { withoutTax := &entities.Order{Subtotal: 50000} withTax := &entities.Order{Subtotal: 50000, TaxAmount: 5500, TotalAmount: 55500} - assert.Equal(t, CalculateEarning(withoutTax, 0, prdEarningSettings()), CalculateEarning(withTax, 0, prdEarningSettings())) - assert.Equal(t, int64(500), CalculateEarning(withTax, 0, prdEarningSettings()).Point.Amount) + assert.Equal(t, CalculateEarning(withoutTax, prdEarningSettings()), CalculateEarning(withTax, prdEarningSettings())) + assert.Equal(t, int64(500), CalculateEarning(withTax, prdEarningSettings()).Point.Amount) } func TestCalculateEarning_BelowMinimum(t *testing.T) { s := prdEarningSettings() s.Point.MinOrderAmount = 50000 - assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount) - assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount, "the minimum itself earns") - // The minimum applies to the basis, after discount and EnakPoint. - assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000}, 15000, s).Point.Amount) + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Point.Amount) + assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, s).Point.Amount, "the minimum itself earns") + // The minimum applies to the basis, after discount. + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000, DiscountAmount: 15000}, s).Point.Amount) // Coin has its own minimum. - assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Coin.Amount) + assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Coin.Amount) } func TestCalculateEarning_MaxPerOrder(t *testing.T) { @@ -58,36 +52,36 @@ func TestCalculateEarning_MaxPerOrder(t *testing.T) { max := int64(300) s.Point.MaxPerOrder = &max - got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) + got := CalculateEarning(&entities.Order{Subtotal: 87500}, s) assert.Equal(t, int64(300), got.Point.Amount) assert.True(t, got.Point.Capped) assert.Equal(t, int64(300), got.Metadata(got.Point)["max_per_order"]) - got = CalculateEarning(&entities.Order{Subtotal: 20000}, 0, s) + got = CalculateEarning(&entities.Order{Subtotal: 20000}, s) assert.Equal(t, int64(200), got.Point.Amount) assert.False(t, got.Point.Capped) zero := int64(0) s.Point.MaxPerOrder = &zero - assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount) } func TestCalculateEarning_DisabledEarnsNothing(t *testing.T) { s := prdEarningSettings() s.Point.Enabled = false - got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) + got := CalculateEarning(&entities.Order{Subtotal: 87500}, s) assert.Equal(t, int64(0), got.Point.Amount) assert.Equal(t, int64(3), got.Coin.Amount, "each currency is switched on its own") s.Coin.Enabled = false - got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) + got = CalculateEarning(&entities.Order{Subtotal: 87500}, s) assert.Equal(t, int64(0), got.Coin.Amount) assert.Equal(t, 87500.0, got.Basis, "the basis is still reported") // The defaults of an outlet that never set anything earn nothing. var defaults models.OutletLoyaltySettings loadLoyaltyFields(outletLoyaltyFields(&defaults), nil, "test") - got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, defaults) + got = CalculateEarning(&entities.Order{Subtotal: 87500}, defaults) assert.Equal(t, int64(0), got.Point.Amount) assert.Equal(t, int64(0), got.Coin.Amount) } @@ -96,22 +90,22 @@ func TestCalculateEarning_EdgeCases(t *testing.T) { s := prdEarningSettings() // floor, not round. - assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount) + assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, s).Point.Amount) // Values float64 cannot hold exactly do not lose a point: computed in float64 this // basis divides to 4956.999…, which a naive floor turns into 4956. per250 := prdEarningSettings() per250.Point.EarnPerAmount = 250 - assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 749.11}, 156.37, per250).Point.Amount) - // Paying more with EnakPoint than the basis leaves nothing, never a negative amount. - got := CalculateEarning(&entities.Order{Subtotal: 10000}, 15000, s) + assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 905.48}, per250).Point.Amount) + // A discount larger than the subtotal leaves nothing, never a negative amount. + got := CalculateEarning(&entities.Order{Subtotal: 10000, DiscountAmount: 15000}, s) assert.Equal(t, 0.0, got.Basis) assert.Equal(t, int64(0), got.Point.Amount) // earn_value multiplies. s.Point.EarnValue = 5 - assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) + assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount) // A zero earn_value earns nothing even when enabled. s.Point.EarnValue = 0 - assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount) } func TestCalculateEarning_Percentage(t *testing.T) { @@ -120,26 +114,24 @@ func TestCalculateEarning_Percentage(t *testing.T) { s.Point.EarnPercent = 1 // 1% of the basis; the per-amount settings are ignored, and coin keeps its own mode. - got := CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 0, s) + got := CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, s) assert.Equal(t, int64(875), got.Point.Amount) assert.Equal(t, int64(3), got.Coin.Amount) - // The part paid with EnakPoint earns nothing in this mode either. - assert.Equal(t, int64(675), CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 20000, s).Point.Amount) // Decimals, and floor, not round: 2.5% of 87.500 is 2187.5. s.Point.EarnPercent = 2.5 - assert.Equal(t, int64(2187), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) + assert.Equal(t, int64(2187), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount) s.Point.EarnPercent = 0.01 - assert.Equal(t, int64(8), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount) + assert.Equal(t, int64(8), CalculateEarning(&entities.Order{Subtotal: 87599.99}, s).Point.Amount) // The minimum and the cap apply as in the other mode. s.Point.EarnPercent = 10 s.Point.MinOrderAmount = 50000 max := int64(6000) s.Point.MaxPerOrder = &max - assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount) - assert.Equal(t, int64(5000), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount) - got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s) + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Point.Amount) + assert.Equal(t, int64(5000), CalculateEarning(&entities.Order{Subtotal: 50000}, s).Point.Amount) + got = CalculateEarning(&entities.Order{Subtotal: 87500}, s) assert.Equal(t, int64(6000), got.Point.Amount) assert.True(t, got.Point.Capped) @@ -149,11 +141,11 @@ func TestCalculateEarning_Percentage(t *testing.T) { // A zero percent earns nothing even when enabled. s.Point.EarnPercent = 0 - assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount) + assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount) } func TestCalculateEarning_MetadataSnapshot(t *testing.T) { - got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings()) + got := CalculateEarning(&entities.Order{Subtotal: 87500}, prdEarningSettings()) assert.Equal(t, entities.Metadata{ "basis": 87500.0, "earn_per_amount": int64(100), "earn_value": int64(1), "min_order_amount": int64(0), "capped": false, }, got.Metadata(got.Point)) diff --git a/internal/processor/earning_processor.go b/internal/processor/earning_processor.go index b820ea1..d58300d 100644 --- a/internal/processor/earning_processor.go +++ b/internal/processor/earning_processor.go @@ -86,11 +86,7 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID) if err != nil { return nil, err } - pointPaid, err := p.orders.PointPaidAmount(ctx, orderID) - if err != nil { - return nil, err - } - result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, pointPaid, *settings) + result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, *settings) if result.Point.Amount == 0 && result.Coin.Amount == 0 { return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil } diff --git a/internal/processor/earning_reversal_db_test.go b/internal/processor/earning_reversal_db_test.go index d17dd8e..8f4ca8c 100644 --- a/internal/processor/earning_reversal_db_test.go +++ b/internal/processor/earning_reversal_db_test.go @@ -140,8 +140,8 @@ func TestEarningReversal_AgainstPostgres(t *testing.T) { spent := paidOrder(spender) committed(t, txm, func(ctx context.Context) (*WalletResult, error) { return wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ - CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, - Amount: 800, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet, + CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem, + Amount: 800, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: uuid.New(), OutletID: &outlet, Description: "Bayar"}}) }) exec(`UPDATE orders SET is_void = true WHERE id = ?`, spent) diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go index 437e897..0acf248 100644 --- a/internal/processor/loyalty_settings_processor.go +++ b/internal/processor/loyalty_settings_processor.go @@ -257,10 +257,6 @@ func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField { percentLoyaltyField(constants.LoyaltyCoinEarnPercentKey, &s.Coin.EarnPercent, constants.LoyaltyEarnPercentDefault), intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax), optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0), - - boolLoyaltyField(constants.LoyaltyPointAcceptPaymentKey, &s.PointPayment.AcceptPayment, false), - intLoyaltyField(constants.LoyaltyPointMinPaymentPointsKey, &s.PointPayment.MinPaymentPoints, constants.LoyaltyMinPaymentPointsDefault, 1, noLoyaltyMax), - intLoyaltyField(constants.LoyaltyPointMaxPaymentPercentKey, &s.PointPayment.MaxPaymentPercent, constants.LoyaltyMaxPaymentPercentDefault, 0, 100), } } diff --git a/internal/processor/loyalty_settings_processor_test.go b/internal/processor/loyalty_settings_processor_test.go index 7248c37..3a193ba 100644 --- a/internal/processor/loyalty_settings_processor_test.go +++ b/internal/processor/loyalty_settings_processor_test.go @@ -107,9 +107,8 @@ func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) { s, err := p.Outlet(context.Background(), uuid.New()) require.NoError(t, err) assert.Equal(t, models.OutletLoyaltySettings{ - Point: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil}, - Coin: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 25000, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil}, - PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100}, + Point: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil}, + Coin: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 25000, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil}, }, *s) } @@ -130,11 +129,10 @@ func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing. func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) { repo := &loyaltyRepoFake{ outletValues: map[string]string{ - constants.LoyaltyPointEnabledKey: "true", - constants.LoyaltyPointEarnPerAmountKey: " 1000 ", - constants.LoyaltyPointMaxPerOrderKey: "500", - constants.LoyaltyPointMaxPaymentPercentKey: "50", - "loyalty.unknown": "ignored", + constants.LoyaltyPointEnabledKey: "true", + constants.LoyaltyPointEarnPerAmountKey: " 1000 ", + constants.LoyaltyPointMaxPerOrderKey: "500", + "loyalty.unknown": "ignored", }, orgValues: map[string]string{ constants.LoyaltyPointValueKey: "100", @@ -151,7 +149,6 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) { assert.True(t, outlet.Point.Enabled) assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount) assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder) - assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent) org, err := p.Organization(context.Background(), uuid.New()) require.NoError(t, err) @@ -168,11 +165,10 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) { func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) { repo := &loyaltyRepoFake{ outletValues: map[string]string{ - constants.LoyaltyPointEnabledKey: "yes please", - constants.LoyaltyPointEarnPerAmountKey: "0", - constants.LoyaltyCoinEarnValueKey: "-1", - constants.LoyaltyPointMaxPerOrderKey: "abc", - constants.LoyaltyPointMaxPaymentPercentKey: "150", + constants.LoyaltyPointEnabledKey: "yes please", + constants.LoyaltyPointEarnPerAmountKey: "0", + constants.LoyaltyCoinEarnValueKey: "-1", + constants.LoyaltyPointMaxPerOrderKey: "abc", }, orgValues: map[string]string{ constants.LoyaltyPointValueKey: "0", @@ -188,7 +184,6 @@ func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) { assert.Equal(t, int64(100), outlet.Point.EarnPerAmount) assert.Equal(t, int64(1), outlet.Coin.EarnValue) assert.Nil(t, outlet.Point.MaxPerOrder) - assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent) for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} { repo.orgValues = map[string]string{} @@ -288,17 +283,14 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) { ctx := context.Background() for name, mutate := range map[string]func(*models.OutletLoyaltySettings){ - "earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 }, - "negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 }, - "earn mode unknown": func(s *models.OutletLoyaltySettings) { s.Point.EarnMode = "PERCENT" }, - "negative earn_percent": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = -1 }, - "earn_percent over 100": func(s *models.OutletLoyaltySettings) { s.Coin.EarnPercent = 100.5 }, - "earn_percent 3 decimals": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = 1.125 }, - "negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 }, - "negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) }, - "payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 }, - "negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 }, - "min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 }, + "earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 }, + "negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 }, + "earn mode unknown": func(s *models.OutletLoyaltySettings) { s.Point.EarnMode = "PERCENT" }, + "negative earn_percent": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = -1 }, + "earn_percent over 100": func(s *models.OutletLoyaltySettings) { s.Coin.EarnPercent = 100.5 }, + "earn_percent 3 decimals": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = 1.125 }, + "negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 }, + "negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) }, } { s, err := p.Outlet(ctx, outlet) require.NoError(t, err) diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 0afc945..021093e 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" - "apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/logger" @@ -21,9 +20,6 @@ type OrderProcessor interface { CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) - // PayWithPointsInApp pays the customer's own order with EnakPoint from the app or a - // self-order, approved by their PIN (docs/prd-point-coin.md F9). - PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error) VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error @@ -115,9 +111,6 @@ type OrderProcessorImpl struct { inventoryMovementService InventoryMovementService productOutletPriceRepo repository.ProductOutletPriceRepository loyalty OrderLoyalty - pointPayments *PointPaymentProcessor - paymentCodes paymentCodeRedeemer - pins pinVerifier } // OrderLoyalty is what the order flow tells and asks the loyalty program @@ -144,58 +137,6 @@ func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) { p.loyalty = loyalty } -type paymentCodeRedeemer interface { - Redeem(ctx context.Context, code string, customerID uuid.UUID) error -} - -// SetPointPayments enables paying with the EnakPoint method. CreatePayment hands such -// payments to pointPayments approved by the code the customer shows at the cashier, -// and PayWithPointsInApp approved by the customer's PIN (F9). -func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer, pins pinVerifier) { - p.pointPayments = pointPayments - p.paymentCodes = codes - p.pins = pins -} - -// createPointPayment is CreatePayment for the EnakPoint method. It never uses the -// generic payment path, which would record the payment without taking any balance. -func (p *OrderProcessorImpl) createPointPayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) { - if p.pointPayments == nil || p.paymentCodes == nil { - return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected) - } - if req.Points == nil || req.PaymentCode == nil || *req.PaymentCode == "" { - return nil, fmt.Errorf("%w: points and the customer's payment code are required", ErrPointPaymentRejected) - } - var cashier *uuid.UUID - if id := appcontext.FromContext(ctx).UserID; id != uuid.Nil { - cashier = &id - } - code := *req.PaymentCode - result, err := p.pointPayments.Pay(ctx, PointPaymentInput{ - OrderID: req.OrderID, - PaymentMethodID: req.PaymentMethodID, - Points: *req.Points, - CashierID: cashier, - Authorize: func(ctx context.Context, customerID uuid.UUID) error { - if err := p.paymentCodes.Redeem(ctx, code, customerID); err != nil { - return fmt.Errorf("%w: %v", ErrPointPaymentRejected, err) - } - return nil - }, - }) - if err != nil { - return nil, err - } - if result.Completed { - p.onOrderPaid(ctx, req.OrderID) - } - payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID) - if err != nil { - return nil, fmt.Errorf("failed to retrieve created payment: %w", err) - } - return mappers.PaymentEntityToResponse(payment), nil -} - // onOrderPaid is the single place every path that completes an order's payment goes // through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called // after the payment has committed. The hook runs detached from the caller's @@ -213,12 +154,6 @@ func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) // never block or fail the void or refund. func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) { ctx = repository.DetachTransaction(context.WithoutCancel(ctx)) - // EnakPoint paid on the order comes back first: the customer is owed it (F9). - if p.pointPayments != nil { - if _, err := p.pointPayments.RefundForOrder(ctx, orderID); err != nil { - logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::onOrderRefunded -> failed to return EnakPoint; calling it again is safe") - } - } if p.loyalty != nil { p.loyalty.OnOrderRefunded(ctx, orderID) } @@ -896,18 +831,6 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req reason = *req.Reason } - // An order refund is handed back in cash or another method, so it cannot cover what - // was paid with EnakPoint (K7). Checked before anything is written. - if p.pointPayments != nil { - planned, err := p.plannedOrderRefund(ctx, id, req) - if err != nil { - return err - } - if err := p.pointPayments.EnsureOrderRefundAllowed(ctx, id, planned); err != nil { - return err - } - } - // Process refund based on request type if req.RefundAmount != nil { // Full or partial refund by amount @@ -999,13 +922,10 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea return nil, fmt.Errorf("order is already fully paid") } - method, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID) + _, err = p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID) if err != nil { return nil, fmt.Errorf("payment method not found: %w", err) } - if method.Type == entities.PaymentMethodTypePoint { - return p.createPointPayment(ctx, req) - } totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID) if err != nil { @@ -1390,11 +1310,6 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil if err != nil { return nil, fmt.Errorf("payment method not found: %w", err) } - // Splitting with EnakPoint would record a payment without taking any balance; an - // EnakPoint part goes through CreatePayment and the rest is split as usual (F9). - if payment.Type == entities.PaymentMethodTypePoint { - return nil, fmt.Errorf("%w: pay the EnakPoint part as its own payment, not as a split", ErrPointPaymentRejected) - } customer := &entities.Customer{} if req.CustomerID != uuid.Nil { @@ -1841,71 +1756,3 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Con func stringPtr(s string) *string { return &s } - -// PayWithPointsInApp pays an order with EnakPoint on the customer's own request, in the -// app or a self-order. The session alone is not enough: the customer's PIN approves -// it (K8). An order that is not the customer's own is reported as not found, so the -// endpoint does not reveal other customers' orders. -func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) { - if p.pointPayments == nil || p.pins == nil { - return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected) - } - organizationID, owner, err := p.pointPayments.OrderOwner(ctx, orderID) - if err != nil { - return nil, err - } - if owner == nil || *owner != customerID { - return nil, repository.ErrPointPaymentOrderNotFound - } - methodID, err := p.pointPayments.PointMethodID(ctx, organizationID) - if err != nil { - return nil, err - } - result, err := p.pointPayments.Pay(ctx, PointPaymentInput{ - OrderID: orderID, - PaymentMethodID: methodID, - Points: points, - Authorize: func(ctx context.Context, customerID uuid.UUID) error { - return p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info) - }, - }) - if err != nil { - return nil, err - } - if result.Completed { - p.onOrderPaid(ctx, orderID) - } - payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID) - if err != nil { - return nil, fmt.Errorf("failed to retrieve created payment: %w", err) - } - return mappers.PaymentEntityToResponse(payment), nil -} - -// plannedOrderRefund is the total RefundOrder is about to hand back, computed the same -// way it will be, without writing anything. -func (p *OrderProcessorImpl) plannedOrderRefund(ctx context.Context, orderID uuid.UUID, req *models.RefundOrderRequest) (float64, error) { - if req.RefundAmount != nil { - return *req.RefundAmount, nil - } - var total float64 - for _, itemRefund := range req.OrderItems { - if itemRefund.RefundAmount != nil { - total += *itemRefund.RefundAmount - continue - } - orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID) - if err != nil { - return 0, fmt.Errorf("order item not found: %w", err) - } - if orderItem.OrderID != orderID { - return 0, fmt.Errorf("order item does not belong to this order") - } - quantity := itemRefund.RefundQuantity - if quantity == 0 { - quantity = orderItem.Quantity - } - total += entities.RoundMoney(orderItem.BillableQuantityFor(quantity) * orderItem.UnitPrice) - } - return total, nil -} diff --git a/internal/processor/payment_code_processor.go b/internal/processor/payment_code_processor.go deleted file mode 100644 index 7c7038c..0000000 --- a/internal/processor/payment_code_processor.go +++ /dev/null @@ -1,101 +0,0 @@ -package processor - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "math/big" - "strings" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -const ( - paymentCodeDigits = 6 - paymentCodeTTL = 2 * time.Minute - paymentCodeAttempts = 5 - // PaymentCodeQRPrefix marks a scanned QR as an EnakPoint payment code. - PaymentCodeQRPrefix = "enakpoint:" -) - -// ErrPaymentCodeInvalid means the code was never issued, has expired, has been used, -// or belongs to another customer. -var ErrPaymentCodeInvalid = errors.New("payment code is invalid or expired") - -type pinVerifier interface { - VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error -} - -// PaymentCodeProcessor issues and redeems the one-time codes that let a cashier take a -// customer's EnakPoint (docs/prd-point-coin.md F9, K8). The customer approves with -// their PIN on their own phone and shows the code; the PIN is never typed on the -// cashier's device. -type PaymentCodeProcessor struct { - codes repository.PaymentCodeRepository - pins pinVerifier - now func() time.Time -} - -func NewPaymentCodeProcessor(codes repository.PaymentCodeRepository, pins pinVerifier) *PaymentCodeProcessor { - return &PaymentCodeProcessor{codes: codes, pins: pins, now: time.Now} -} - -// Issue checks the customer's PIN and returns a fresh 6-digit code, valid for two -// minutes and bound to the customer. A new code retires the previous one. -func (p *PaymentCodeProcessor) Issue(ctx context.Context, customerID uuid.UUID, pin string, info models.CustomerPinRequestInfo) (*models.PaymentCode, error) { - if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info); err != nil { - return nil, err - } - for attempt := 0; attempt < paymentCodeAttempts; attempt++ { - code, err := randomDigits(paymentCodeDigits) - if err != nil { - return nil, err - } - err = p.codes.Save(ctx, code, customerID, paymentCodeTTL) - if errors.Is(err, repository.ErrPaymentCodeTaken) { - continue - } - if err != nil { - return nil, err - } - return &models.PaymentCode{ - Code: code, - QRPayload: PaymentCodeQRPrefix + code, - ExpiresAt: p.now().Add(paymentCodeTTL), - }, nil - } - return nil, fmt.Errorf("could not draw a free payment code after %d attempts", paymentCodeAttempts) -} - -// Redeem uses a code up for a payment by the given customer. It accepts the code as -// typed or as scanned from the QR. Every failure is ErrPaymentCodeInvalid. -func (p *PaymentCodeProcessor) Redeem(ctx context.Context, code string, customerID uuid.UUID) error { - code = strings.TrimPrefix(strings.TrimSpace(code), PaymentCodeQRPrefix) - if len(code) != paymentCodeDigits { - return ErrPaymentCodeInvalid - } - err := p.codes.Consume(ctx, code, customerID) - if errors.Is(err, repository.ErrPaymentCodeNotFound) || errors.Is(err, repository.ErrPaymentCodeWrongCustomer) { - return ErrPaymentCodeInvalid - } - return err -} - -// randomDigits draws n decimal digits from a cryptographic source, so codes cannot be -// predicted. -func randomDigits(n int) (string, error) { - var b strings.Builder - for i := 0; i < n; i++ { - d, err := rand.Int(rand.Reader, big.NewInt(10)) - if err != nil { - return "", fmt.Errorf("failed to draw a payment code: %w", err) - } - b.WriteByte(byte('0' + d.Int64())) - } - return b.String(), nil -} diff --git a/internal/processor/payment_code_processor_test.go b/internal/processor/payment_code_processor_test.go deleted file mode 100644 index bb134d7..0000000 --- a/internal/processor/payment_code_processor_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package processor - -import ( - "context" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/google/uuid" - "github.com/redis/go-redis/v9" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -type pinVerifierFake struct{ good string } - -func (f pinVerifierFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error { - if action != PinActionPay { - return &PinError{Code: "UNEXPECTED_ACTION"} - } - if pin != f.good { - return &PinError{Code: PinErrInvalid, RemainingAttempts: 4} - } - return nil -} - -func newPaymentCodeTest(t *testing.T) (*PaymentCodeProcessor, *miniredis.Miniredis) { - t.Helper() - mr := miniredis.RunT(t) - client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - t.Cleanup(func() { client.Close() }) - return NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}), mr -} - -func TestPaymentCode_IssueNeedsThePin(t *testing.T) { - p, mr := newPaymentCodeTest(t) - _, err := p.Issue(context.Background(), uuid.New(), "000000", models.CustomerPinRequestInfo{}) - var pe *PinError - require.ErrorAs(t, err, &pe) - assert.Equal(t, PinErrInvalid, pe.Code) - assert.Empty(t, mr.Keys(), "nothing is issued without the PIN") -} - -func TestPaymentCode_Lifecycle(t *testing.T) { - p, mr := newPaymentCodeTest(t) - ctx := context.Background() - customer, other := uuid.New(), uuid.New() - - code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - assert.Len(t, code.Code, 6) - assert.Equal(t, "enakpoint:"+code.Code, code.QRPayload) - assert.WithinDuration(t, time.Now().Add(2*time.Minute), code.ExpiresAt, 2*time.Second) - assert.InDelta(t, 120, mr.TTL("wallet:paycode:"+code.Code).Seconds(), 1, "Redis expires it by itself") - - // A code of another customer is refused, and stays usable by its owner. - assert.ErrorIs(t, p.Redeem(ctx, code.Code, other), ErrPaymentCodeInvalid) - // Scanned from the QR it works; used once, it is gone. - require.NoError(t, p.Redeem(ctx, code.QRPayload, customer)) - assert.ErrorIs(t, p.Redeem(ctx, code.Code, customer), ErrPaymentCodeInvalid) - - // An expired code is refused. - late, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - mr.FastForward(2*time.Minute + time.Second) - assert.ErrorIs(t, p.Redeem(ctx, late.Code, customer), ErrPaymentCodeInvalid) - - // A new code retires the previous one. - first, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - second, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - if first.Code != second.Code { - assert.ErrorIs(t, p.Redeem(ctx, first.Code, customer), ErrPaymentCodeInvalid) - } - require.NoError(t, p.Redeem(ctx, second.Code, customer)) - - // Garbage is refused without touching Redis. - for _, bad := range []string{"", "12345", "1234567", "enakpoint:"} { - assert.ErrorIs(t, p.Redeem(ctx, bad, customer), ErrPaymentCodeInvalid, bad) - } -} - -// Two cashiers scanning the same code at once: exactly one gets it. -func TestPaymentCode_UsedOnceUnderRace(t *testing.T) { - p, _ := newPaymentCodeTest(t) - ctx := context.Background() - customer := uuid.New() - code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(t, err) - - var wins int32 - var wg sync.WaitGroup - for i := 0; i < 20; i++ { - wg.Add(1) - go func() { - defer wg.Done() - if p.Redeem(ctx, code.Code, customer) == nil { - atomic.AddInt32(&wins, 1) - } - }() - } - wg.Wait() - assert.Equal(t, int32(1), wins) -} - -func TestPaymentCode_SaveRefusesALiveCode(t *testing.T) { - mr := miniredis.RunT(t) - client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - defer client.Close() - repo := repository.NewPaymentCodeRepository(client) - ctx := context.Background() - - require.NoError(t, repo.Save(ctx, "123456", uuid.New(), time.Minute)) - assert.ErrorIs(t, repo.Save(ctx, "123456", uuid.New(), time.Minute), repository.ErrPaymentCodeTaken, - "a live code is never handed to a second customer") -} - -func TestRandomDigits(t *testing.T) { - seen := map[string]bool{} - for i := 0; i < 200; i++ { - d, err := randomDigits(6) - require.NoError(t, err) - require.Len(t, d, 6) - for _, r := range d { - require.True(t, r >= '0' && r <= '9') - } - seen[d] = true - } - assert.Greater(t, len(seen), 190, "codes do not repeat") -} diff --git a/internal/processor/payment_method_processor.go b/internal/processor/payment_method_processor.go index bd0b240..952ab86 100644 --- a/internal/processor/payment_method_processor.go +++ b/internal/processor/payment_method_processor.go @@ -1,10 +1,7 @@ package processor import ( - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" "context" - "errors" "fmt" "apskel-pos-be/internal/mappers" @@ -23,26 +20,17 @@ type PaymentMethodProcessor interface { GetActivePaymentMethodsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]models.PaymentMethodResponse, error) } -// ErrSystemPaymentMethod means an attempt to create, delete or retype the EnakPoint -// method, which the system owns (docs/prd-point-coin.md F9). -var ErrSystemPaymentMethod = errors.New("the EnakPoint payment method is managed by the system: it cannot be created, deleted or change type") - type PaymentMethodProcessorImpl struct { paymentMethodRepo repository.PaymentMethodRepository - outletSettings outletSettingsReader } -func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository, outletSettings outletSettingsReader) *PaymentMethodProcessorImpl { +func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository) *PaymentMethodProcessorImpl { return &PaymentMethodProcessorImpl{ paymentMethodRepo: paymentMethodRepo, - outletSettings: outletSettings, } } func (p *PaymentMethodProcessorImpl) CreatePaymentMethod(ctx context.Context, req *models.CreatePaymentMethodRequest) (*models.PaymentMethodResponse, error) { - if req.Type == constants.PaymentMethodTypePoint { - return nil, ErrSystemPaymentMethod - } exists, err := p.paymentMethodRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil) if err != nil { return nil, fmt.Errorf("failed to check payment method name uniqueness: %w", err) @@ -89,17 +77,6 @@ func (p *PaymentMethodProcessorImpl) ListPaymentMethods(ctx context.Context, req if req.Search != "" { filters["search"] = req.Search } - // At the cashier EnakPoint only shows where the outlet accepts it (F9). Filtered in - // the query so paging stays right. - if req.OutletID != nil && p.outletSettings != nil { - settings, err := p.outletSettings.Outlet(ctx, *req.OutletID) - if err != nil { - return nil, fmt.Errorf("failed to read outlet loyalty settings: %w", err) - } - if !settings.PointPayment.AcceptPayment { - filters["exclude_type"] = string(constants.PaymentMethodTypePoint) - } - } offset := (req.Page - 1) * req.Limit @@ -139,15 +116,6 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id return nil, fmt.Errorf("payment method not found: %w", err) } - // The EnakPoint method keeps its type, and no other method can become one. - if req.Type != nil { - wasPoint := existingPaymentMethod.Type == entities.PaymentMethodTypePoint - isPoint := *req.Type == constants.PaymentMethodTypePoint - if wasPoint != isPoint { - return nil, ErrSystemPaymentMethod - } - } - // Check name uniqueness if name is being updated if req.Name != nil && *req.Name != existingPaymentMethod.Name { exists, err := p.paymentMethodRepo.ExistsByName(ctx, existingPaymentMethod.OrganizationID, *req.Name, &id) @@ -179,13 +147,10 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id func (p *PaymentMethodProcessorImpl) DeletePaymentMethod(ctx context.Context, id uuid.UUID) error { // Check if payment method exists - existing, err := p.paymentMethodRepo.GetByID(ctx, id) + _, err := p.paymentMethodRepo.GetByID(ctx, id) if err != nil { return fmt.Errorf("payment method not found: %w", err) } - if existing.Type == entities.PaymentMethodTypePoint { - return ErrSystemPaymentMethod - } // TODO: Check if payment method is being used in any payments // For now, allow deletion diff --git a/internal/processor/point_payment_db_test.go b/internal/processor/point_payment_db_test.go deleted file mode 100644 index 3ab8c2f..0000000 --- a/internal/processor/point_payment_db_test.go +++ /dev/null @@ -1,401 +0,0 @@ -package processor - -import ( - "context" - "os" - "sync" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/google/uuid" - "github.com/redis/go-redis/v9" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// pointPaymentEnv is an order flow wired as in the app, against Postgres and a -// miniredis for payment codes. -type pointPaymentEnv struct { - t *testing.T - db *gorm.DB - orders *OrderProcessorImpl - payments *PointPaymentProcessor - codes *PaymentCodeProcessor - org uuid.UUID - cashier uuid.UUID - outlet uuid.UUID - point uuid.UUID - cash uuid.UUID - walkIn uuid.UUID - ctx context.Context -} - -func newPointPaymentEnv(t *testing.T) *pointPaymentEnv { - t.Helper() - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - t.Skip("TEST_DATABASE_URL not set") - } - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - require.NoError(t, err) - e := &pointPaymentEnv{t: t, db: db, org: uuid.New(), cashier: uuid.New(), outlet: uuid.New(), cash: uuid.New()} - e.exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'point pay test', 'basic')`, e.org) - e.exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, e.cashier, e.org, e.cashier.String()+"@t") - e.exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, e.outlet, e.org) - e.exec(`INSERT INTO payment_methods (id, organization_id, name, type) VALUES (?, ?, 'Tunai', 'cash')`, e.cash, e.org) - var ids []string - require.NoError(t, db.Raw(`SELECT id::text FROM payment_methods WHERE organization_id = ? AND type = 'point'`, e.org).Scan(&ids).Error) - require.Len(t, ids, 1) - e.point = uuid.MustParse(ids[0]) - require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, e.org).Scan(&ids).Error) - e.walkIn = uuid.MustParse(ids[0]) - t.Cleanup(func() { - db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, e.org) - db.Exec(`DELETE FROM wallet_lots WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM customer_wallets WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM payments WHERE order_id IN (SELECT id FROM orders WHERE organization_id = ?)`, e.org) - db.Exec(`DELETE FROM orders WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, e.outlet) - db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM customers WHERE organization_id = ?`, e.org) - db.Exec(`DELETE FROM outlets WHERE id = ?`, e.outlet) - db.Exec(`DELETE FROM users WHERE id = ?`, e.cashier) - db.Exec(`DELETE FROM organizations WHERE id = ?`, e.org) - }) - - txm := repository.NewTxManager(db) - settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) - wallet := NewWalletProcessor(repository.NewWalletRepository(db)) - e.payments = NewPointPaymentProcessor(repository.NewPointPaymentRepository(db), settings, repository.NewWalletQueryRepository(db), wallet, txm) - mr := miniredis.RunT(t) - client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - t.Cleanup(func() { client.Close() }) - e.codes = NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}) - - e.orders = &OrderProcessorImpl{ - orderRepo: repository.NewOrderRepositoryImpl(db), - orderItemRepo: repository.NewOrderItemRepositoryImpl(db), - paymentRepo: repository.NewPaymentRepositoryImpl(db), - paymentMethodRepo: repository.NewPaymentMethodRepositoryImpl(db), - splitBillProcessor: nil, - txManager: txm, - } - e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm)) - e.orders.SetPointPayments(e.payments, e.codes, pinVerifierFake{good: "482913"}) - - // The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint. - s, err := settings.Outlet(context.Background(), e.outlet) - require.NoError(t, err) - s.Point.Enabled = true - s.PointPayment.AcceptPayment = true - _, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s) - require.NoError(t, err) - - e.ctx = context.WithValue(context.Background(), appcontext.UserIDKey, e.cashier.String()) - return e -} - -func (e *pointPaymentEnv) exec(q string, args ...any) { - e.t.Helper() - require.NoError(e.t, e.db.Exec(q, args...).Error) -} - -// customerWith creates a customer holding the given EnakPoint. -func (e *pointPaymentEnv) customerWith(points int64) uuid.UUID { - e.t.Helper() - id := uuid.New() - e.exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, id, e.org) - if points > 0 { - require.NoError(e.t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error { - _, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{ - CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: points, - ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}}) - return err - })) - } - return id -} - -func (e *pointPaymentEnv) order(customer uuid.UUID, subtotal float64) uuid.UUID { - e.t.Helper() - id := uuid.New() - e.exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type, - subtotal, tax_amount, total_amount, remaining_amount, payment_status) - VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, 0, ?, ?, 'pending')`, - id, e.org, e.outlet, e.cashier, customer, "PP-"+id.String()[:8], subtotal, subtotal, subtotal) - return id -} - -func (e *pointPaymentEnv) code(customer uuid.UUID) string { - e.t.Helper() - c, err := e.codes.Issue(context.Background(), customer, "482913", models.CustomerPinRequestInfo{}) - require.NoError(e.t, err) - return c.Code -} - -func (e *pointPaymentEnv) payPoints(order uuid.UUID, points int64, code string) (*models.PaymentResponse, error) { - return e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &points, PaymentCode: &code}) -} - -func (e *pointPaymentEnv) balance(customer uuid.UUID) int64 { - e.t.Helper() - var b int64 - require.NoError(e.t, e.db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error) - return b -} - -func (e *pointPaymentEnv) orderState(order uuid.UUID) (status string, remaining float64) { - e.t.Helper() - var row struct { - PaymentStatus string - RemainingAmount float64 - } - require.NoError(e.t, e.db.Raw(`SELECT payment_status, remaining_amount FROM orders WHERE id = ?`, order).Scan(&row).Error) - return row.PaymentStatus, row.RemainingAmount -} - -func TestPointPayment_FullPayment(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 50000) - - payment, err := e.payPoints(order, 50000, e.code(customer)) - require.NoError(t, err) - assert.Equal(t, 50000.0, payment.Amount) - require.NotNil(t, payment.PointsUsed) - assert.Equal(t, int64(50000), *payment.PointsUsed) - assert.Equal(t, 1.0, *payment.PointValue, "the value is frozen on the payment") - status, remaining := e.orderState(order) - assert.Equal(t, "completed", status) - assert.Zero(t, remaining) - assert.Equal(t, int64(50000), e.balance(customer)) - - var ledger struct { - Amount int64 - ReferenceType string - ReferenceID string - OutletID string - CreatedByUser string - } - require.NoError(t, e.db.Raw(`SELECT amount, reference_type, reference_id::text AS reference_id, outlet_id::text AS outlet_id, - created_by_user::text AS created_by_user FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT'`, customer).Scan(&ledger).Error) - assert.Equal(t, int64(-50000), ledger.Amount) - assert.Equal(t, "PAYMENT", ledger.ReferenceType) - assert.Equal(t, payment.ID.String(), ledger.ReferenceID) - assert.Equal(t, e.outlet.String(), ledger.OutletID) - assert.Equal(t, e.cashier.String(), ledger.CreatedByUser, "the cashier who took it") - - // Paid entirely with EnakPoint, so nothing earns (Q10). - var earned int64 - require.NoError(t, e.db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error) - assert.Zero(t, earned) -} - -func TestPointPayment_PartialThenCash(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 87500) - - _, err := e.payPoints(order, 20000, e.code(customer)) - require.NoError(t, err) - status, remaining := e.orderState(order) - assert.Equal(t, "partial", status) - assert.Equal(t, 67500.0, remaining) - - // The rest in cash settles it; earning counts only the part not paid with - // EnakPoint: floor(67.500 / 100) = 675. - _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) - require.NoError(t, err) - status, _ = e.orderState(order) - assert.Equal(t, "completed", status) - var earned int64 - require.NoError(t, e.db.Raw(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error) - assert.Equal(t, int64(675), earned) - assert.Equal(t, int64(100000-20000+675), e.balance(customer)) -} - -func TestPointPayment_PercentCap(t *testing.T) { - e := newPointPaymentEnv(t) - settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db)) - s, err := settings.Outlet(context.Background(), e.outlet) - require.NoError(t, err) - s.PointPayment.MaxPaymentPercent = 50 - _, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s) - require.NoError(t, err) - - customer := e.customerWith(100000) - order := e.order(customer, 100000) - - preview, err := e.payments.Preview(context.Background(), e.org, order) - require.NoError(t, err) - assert.True(t, preview.Eligible) - assert.Equal(t, int64(50000), preview.MaxPoints) - assert.Equal(t, int64(100000), preview.PointBalance) - - _, err = e.payPoints(order, 50001, e.code(customer)) - assert.ErrorIs(t, err, ErrPointPaymentRejected) - _, err = e.payPoints(order, 30000, e.code(customer)) - require.NoError(t, err) - _, err = e.payPoints(order, 20001, e.code(customer)) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "earlier EnakPoint counts toward the cap") - _, err = e.payPoints(order, 20000, e.code(customer)) - require.NoError(t, err) - assert.Equal(t, int64(50000), e.balance(customer)) -} - -func TestPointPayment_Refusals(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - other := e.customerWith(100000) - - // A walk-in order cannot be paid with EnakPoint. - walkInOrder := e.order(e.walkIn, 10000) - preview, err := e.payments.Preview(context.Background(), e.org, walkInOrder) - require.NoError(t, err) - assert.False(t, preview.Eligible) - assert.Contains(t, preview.Reason, "walk-in") - _, err = e.payPoints(walkInOrder, 1000, "123456") - assert.ErrorIs(t, err, ErrPointPaymentRejected) - - order := e.order(customer, 10000) - _, err = e.payPoints(order, 1000, "000000") - assert.ErrorIs(t, err, ErrPointPaymentRejected, "a wrong code") - _, err = e.payPoints(order, 1000, e.code(other)) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "another customer's code") - missing := int64(1000) - _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &missing}) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "no code at all") - - code := e.code(customer) - _, err = e.payPoints(order, 1000, code) - require.NoError(t, err) - _, err = e.payPoints(order, 1000, code) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "a code is used once, so a double tap takes once") - _, err = e.payPoints(order, 9001, e.code(customer)) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "no change is given: not more than what is left") - - // Splitting with the EnakPoint method would skip the balance, so it is refused. - e.orders.splitBillProcessor = splitFake{} - _, err = e.orders.SplitBill(e.ctx, &models.SplitBillRequest{OrderID: order, PaymentMethodID: e.point, Type: "AMOUNT", Amount: 1000}) - assert.ErrorIs(t, err, ErrPointPaymentRejected) - - assert.Equal(t, int64(99000), e.balance(customer), "only the one payment took anything") - assert.Equal(t, int64(100000), e.balance(other)) -} - -// Two payments for the same customer at once, on two orders: the balance is taken -// once, never twice. Authorization is taken as given so only the balance decides. -func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(30000) - orders := []uuid.UUID{e.order(customer, 20000), e.order(customer, 20000)} - - var wg sync.WaitGroup - results := make([]error, len(orders)) - for i, order := range orders { - wg.Add(1) - go func(i int, order uuid.UUID) { - defer wg.Done() - _, results[i] = e.payments.Pay(e.ctx, PointPaymentInput{ - OrderID: order, PaymentMethodID: e.point, Points: 20000, - Authorize: func(context.Context, uuid.UUID) error { return nil }, - }) - }(i, order) - } - wg.Wait() - - succeeded := 0 - for _, err := range results { - if err == nil { - succeeded++ - } else { - assert.ErrorIs(t, err, ErrPointPaymentRejected) - } - } - assert.Equal(t, 1, succeeded, "30.000 EnakPoint pays one 20.000 order, not two") - assert.Equal(t, int64(10000), e.balance(customer)) - - // And the wallet still reconciles. - found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000) - require.NoError(t, err) - for _, d := range found { - assert.NotEqual(t, customer, d.CustomerID, d.Check) - } -} - -func TestPointPayment_InApp(t *testing.T) { - e := newPointPaymentEnv(t) - owner := e.customerWith(100000) - stranger := e.customerWith(100000) - order := e.order(owner, 60000) - info := models.CustomerPinRequestInfo{} - - // Another customer cannot pay it, and is not told it exists. - _, err := e.orders.PayWithPointsInApp(e.ctx, stranger, order, 1000, "482913", info) - assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound) - _, err = e.orders.PayWithPointsInApp(e.ctx, owner, uuid.New(), 1000, "482913", info) - assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound) - - // The session alone is not enough: a wrong PIN takes nothing. - _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 1000, "000000", info) - var pe *PinError - require.ErrorAs(t, err, &pe) - assert.Equal(t, PinErrInvalid, pe.Code) - assert.Equal(t, int64(100000), e.balance(owner)) - - // The owner pays part, then the rest, with the same rules as at the cashier. - payment, err := e.orders.PayWithPointsInApp(e.ctx, owner, order, 10000, "482913", info) - require.NoError(t, err) - assert.Equal(t, int64(10000), *payment.PointsUsed) - status, remaining := e.orderState(order) - assert.Equal(t, "partial", status) - assert.Equal(t, 50000.0, remaining) - - _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50001, "482913", info) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "not more than what is left") - _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50000, "482913", info) - require.NoError(t, err) - status, _ = e.orderState(order) - assert.Equal(t, "completed", status) - assert.Equal(t, int64(40000), e.balance(owner)) - assert.Equal(t, int64(100000), e.balance(stranger)) - - var createdBy *string - require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error) - assert.Nil(t, createdBy, "no cashier took an in-app payment") -} - -// The payment method report counts only money actually received as money in; the -// EnakPoint part is listed apart (F9). -func TestPointPayment_ReportKeepsEnakPointOutOfCashIn(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 87500) - _, err := e.payPoints(order, 20000, e.code(customer)) - require.NoError(t, err) - _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) - require.NoError(t, err) - - report, err := NewAnalyticsProcessorImpl(repository.NewAnalyticsRepositoryImpl(e.db), nil).GetPaymentMethodAnalytics(context.Background(), - &models.PaymentMethodAnalyticsRequest{OrganizationID: e.org, DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now().Add(time.Hour)}) - require.NoError(t, err) - assert.Equal(t, 67500.0, report.Summary.TotalAmount, "money in is the cash, not the order total") - assert.Equal(t, 20000.0, report.Summary.PointAmount) - assert.Equal(t, int64(20000), report.Summary.PointsUsed) - assert.Equal(t, 87500.0, report.Summary.TotalWithPoints) - require.Len(t, report.Data, 2) - for _, d := range report.Data { - assert.Equal(t, d.PaymentMethodType != "point", d.CountsAsCashIn, d.PaymentMethodName) - } -} diff --git a/internal/processor/point_payment_method_db_test.go b/internal/processor/point_payment_method_db_test.go deleted file mode 100644 index eb912ed..0000000 --- a/internal/processor/point_payment_method_db_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package processor - -import ( - "context" - "os" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/postgres" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// Needs TEST_DATABASE_URL pointing at a migrated database; see -// internal/repository/wallet_repository_test.go. -func TestPointPaymentMethod_AgainstPostgres(t *testing.T) { - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - t.Skip("TEST_DATABASE_URL not set") - } - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - require.NoError(t, err) - ctx := context.Background() - - org, user, accepting, refusing := uuid.New(), uuid.New(), uuid.New(), uuid.New() - exec := func(q string, args ...any) error { return db.Exec(q, args...).Error } - require.NoError(t, exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pm test', 'basic')`, org)) - require.NoError(t, exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Terima'), (?, ?, 'Tolak')`, accepting, org, refusing, org)) - t.Cleanup(func() { - db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{accepting, refusing}) - db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{accepting, refusing}) - db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, org) - db.Exec(`DELETE FROM organizations WHERE id = ?`, org) - }) - - // A new organization gets exactly one EnakPoint method from the trigger. - var methods []struct { - ID string - Name string - Type string - } - require.NoError(t, db.Raw(`SELECT id::text AS id, name, type FROM payment_methods WHERE organization_id = ?`, org).Scan(&methods).Error) - require.Len(t, methods, 1) - assert.Equal(t, "EnakPoint", methods[0].Name) - assert.Equal(t, "point", methods[0].Type) - pointID := uuid.MustParse(methods[0].ID) - - // The database refuses a second one. - assert.Error(t, exec(`INSERT INTO payment_methods (organization_id, name, type) VALUES (?, 'EnakPoint 2', 'point')`, org)) - - txm := repository.NewTxManager(db) - settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) - s, err := settings.Outlet(ctx, accepting) - require.NoError(t, err) - s.PointPayment.AcceptPayment = true - _, err = settings.UpdateOutlet(ctx, org, accepting, user, *s) - require.NoError(t, err) - - p := NewPaymentMethodProcessorImpl(repository.NewPaymentMethodRepositoryImpl(db), settings) - cash, err := p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Tunai", Type: constants.PaymentMethodTypeCash, IsActive: ptr(true)}) - require.NoError(t, err) - - // The API cannot make, retype or delete an EnakPoint method. - _, err = p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Poin Lain", Type: constants.PaymentMethodTypePoint, IsActive: ptr(true)}) - assert.ErrorIs(t, err, ErrSystemPaymentMethod) - toCash := constants.PaymentMethodTypeCash - _, err = p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Type: &toCash}) - assert.ErrorIs(t, err, ErrSystemPaymentMethod) - toPoint := constants.PaymentMethodTypePoint - _, err = p.UpdatePaymentMethod(ctx, cash.ID, &models.UpdatePaymentMethodRequest{Type: &toPoint}) - assert.ErrorIs(t, err, ErrSystemPaymentMethod) - assert.ErrorIs(t, p.DeletePaymentMethod(ctx, pointID), ErrSystemPaymentMethod) - - // Renaming it is fine; its type stays. - name := "Bayar pakai EnakPoint" - renamed, err := p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Name: &name, Type: &toPoint}) - require.NoError(t, err) - assert.Equal(t, name, renamed.Name) - - // At the cashier it shows only where the outlet accepts EnakPoint. - types := func(outlet *uuid.UUID) []constants.PaymentMethodType { - t.Helper() - list, err := p.ListPaymentMethods(ctx, &models.ListPaymentMethodsRequest{OrganizationID: &org, OutletID: outlet, Page: 1, Limit: 50}) - require.NoError(t, err) - var out []constants.PaymentMethodType - for _, m := range list.PaymentMethods { - out = append(out, m.Type) - } - assert.Equal(t, len(out), list.TotalCount, "the count matches what is listed") - return out - } - assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(&accepting)) - assert.ElementsMatch(t, []constants.PaymentMethodType{"cash"}, types(&refusing)) - assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(nil), "the dashboard, without an outlet, sees it") - - // A payment either records both points_used and point_value, or neither. - var orderID uuid.UUID - require.NoError(t, exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'K', ?, 'x', 'cashier')`, user, org, user.String()+"@t")) - orderID = uuid.New() - require.NoError(t, exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, order_number, order_type, subtotal, tax_amount, total_amount) - VALUES (?, ?, ?, ?, ?, 'dine_in', 1000, 0, 1000)`, orderID, org, accepting, user, "PM-"+orderID.String()[:8])) - t.Cleanup(func() { - db.Exec(`DELETE FROM payments WHERE order_id = ?`, orderID) - db.Exec(`DELETE FROM orders WHERE id = ?`, orderID) - db.Exec(`DELETE FROM users WHERE id = ?`, user) - }) - assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used) VALUES (?, ?, 1000, 1000)`, orderID, pointID)) - assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 0, 1)`, orderID, pointID)) - assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 1000, 1)`, orderID, pointID)) - assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount) VALUES (?, ?, 1000)`, orderID, cash.ID)) -} diff --git a/internal/processor/point_payment_processor.go b/internal/processor/point_payment_processor.go deleted file mode 100644 index 0e989cd..0000000 --- a/internal/processor/point_payment_processor.go +++ /dev/null @@ -1,336 +0,0 @@ -package processor - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// ErrPointPaymentRejected wraps every reason a payment with EnakPoint is refused: the -// order, the customer, the outlet or the amount. The message says which. -var ErrPointPaymentRejected = errors.New("EnakPoint payment refused") - -type pointPaymentSettings interface { - Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) - PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) -} - -type spendableReader interface { - SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) -} - -// PointPaymentInput is one payment of an order with EnakPoint. -type PointPaymentInput struct { - OrderID uuid.UUID - PaymentMethodID uuid.UUID - Points int64 - // The cashier taking the payment at the POS; nil when the customer pays in the app. - CashierID *uuid.UUID - // Authorize proves the customer agreed, before anything is taken: at the POS it - // redeems the payment code, in the app it checks the PIN (K8). - Authorize func(ctx context.Context, customerID uuid.UUID) error -} - -// PointPaymentResult is the payment made and where it left the order. -type PointPaymentResult struct { - Payment *entities.Payment - // True when this payment settled the order. - Completed bool - // Rupiah still to pay with another method. - Remaining float64 -} - -// pointPaymentLimits applies the formula of docs/prd-point-coin.md F9, in cents: -// -// cap = min(remaining, total × max_payment_percent / 100 − already paid with EnakPoint) -// max_points = min(balance, floor(cap / point_value)) -type pointPaymentLimits struct { - RemainingCents int64 - CapCents int64 - MaxPoints int64 -} - -func computePointPaymentLimits(total, totalPaid, paidWithPoints float64, maxPercent, pointValue, balance int64) pointPaymentLimits { - remaining := toCents(total) - toCents(totalPaid) - if remaining < 0 { - remaining = 0 - } - byPercent := toCents(total)*maxPercent/100 - toCents(paidWithPoints) - capCents := min(remaining, byPercent) - if capCents < 0 { - capCents = 0 - } - maxPoints := int64(0) - if pointValue > 0 { - maxPoints = min(balance, capCents/(pointValue*100)) - } - if maxPoints < 0 { - maxPoints = 0 - } - return pointPaymentLimits{RemainingCents: remaining, CapCents: capCents, MaxPoints: maxPoints} -} - -// PointPaymentProcessor pays orders with EnakPoint (docs/prd-point-coin.md F9). -type PointPaymentProcessor struct { - repo repository.PointPaymentRepository - settings pointPaymentSettings - spendable spendableReader - wallet *WalletProcessor - tx TxRunner - now func() time.Time -} - -func NewPointPaymentProcessor(repo repository.PointPaymentRepository, settings pointPaymentSettings, spendable spendableReader, wallet *WalletProcessor, tx TxRunner) *PointPaymentProcessor { - return &PointPaymentProcessor{repo: repo, settings: settings, spendable: spendable, wallet: wallet, tx: tx, now: time.Now} -} - -// Preview is GET /orders/:id/point-payment/preview: whether the order can be paid with -// EnakPoint, and at most how much. -func (p *PointPaymentProcessor) Preview(ctx context.Context, organizationID, orderID uuid.UUID) (*models.PointPaymentPreview, error) { - order, err := p.repo.GetOrder(ctx, orderID, false) - if err != nil { - return nil, err - } - if order.OrganizationID != organizationID { - return nil, repository.ErrPointPaymentOrderNotFound - } - preview := &models.PointPaymentPreview{OrderID: orderID, CustomerID: order.CustomerID} - settings, err := p.settings.Outlet(ctx, order.OutletID) - if err != nil { - return nil, err - } - value, err := p.settings.PointValue(ctx, order.OrganizationID) - if err != nil { - return nil, err - } - preview.PointValue = value - preview.MinPaymentPoints = settings.PointPayment.MinPaymentPoints - preview.MaxPaymentPercent = settings.PointPayment.MaxPaymentPercent - - if reason := pointPaymentOrderProblem(order, settings); reason != "" { - preview.Reason = reason - return preview, nil - } - limits, balance, err := p.limits(ctx, order, settings, value) - if err != nil { - return nil, err - } - preview.PointBalance = balance - preview.RemainingAmount = float64(limits.RemainingCents) / 100 - preview.MaxPoints = limits.MaxPoints - preview.MaxAmount = limits.MaxPoints * value - if limits.MaxPoints < settings.PointPayment.MinPaymentPoints { - preview.Reason = "the customer cannot pay the minimum of EnakPoint on this order" - return preview, nil - } - preview.Eligible = true - return preview, nil -} - -// Pay takes EnakPoint from the order's customer and records the payment. The payment -// row, the ledger PAYMENT row, the balance and the order change in one transaction -// with the order row and the wallet locked (F9 steps 1–6). -func (p *PointPaymentProcessor) Pay(ctx context.Context, in PointPaymentInput) (*PointPaymentResult, error) { - reject := func(format string, args ...any) error { - return fmt.Errorf("%w: %s", ErrPointPaymentRejected, fmt.Sprintf(format, args...)) - } - if in.Points <= 0 { - return nil, reject("the number of EnakPoint must be positive") - } - order, err := p.repo.GetOrder(ctx, in.OrderID, false) - if err != nil { - return nil, err - } - methodOrg, methodType, err := p.repo.GetPaymentMethod(ctx, in.PaymentMethodID) - if err != nil { - return nil, err - } - if methodType != string(constants.PaymentMethodTypePoint) || methodOrg != order.OrganizationID { - return nil, reject("the payment method is not this organization's EnakPoint method") - } - settings, err := p.settings.Outlet(ctx, order.OutletID) - if err != nil { - return nil, err - } - if reason := pointPaymentOrderProblem(order, settings); reason != "" { - return nil, reject("%s", reason) - } - if in.Points < settings.PointPayment.MinPaymentPoints { - return nil, reject("at least %d EnakPoint must be used", settings.PointPayment.MinPaymentPoints) - } - customerID := *order.CustomerID - - // The customer agrees before anything is taken. A code is used up here even if the - // payment then fails, and the customer shows a new one. - if in.Authorize == nil { - return nil, reject("the customer has not approved the payment") - } - if err := in.Authorize(ctx, customerID); err != nil { - return nil, err - } - - result := &PointPaymentResult{} - err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { - // Lock the order, then the wallet, and read everything again: another payment - // may have landed since the checks above. - order, err := p.repo.GetOrder(ctx, in.OrderID, true) - if err != nil { - return err - } - if reason := pointPaymentOrderProblem(order, settings); reason != "" { - return reject("%s", reason) - } - if err := p.wallet.LockWallet(ctx, customerID); err != nil { - return err - } - value, err := p.settings.PointValue(ctx, order.OrganizationID) - if err != nil { - return err - } - limits, _, err := p.limits(ctx, order, settings, value) - if err != nil { - return err - } - if in.Points > limits.MaxPoints { - return reject("at most %d EnakPoint can pay this order now", limits.MaxPoints) - } - amountCents := in.Points * value * 100 - // No change is ever given for EnakPoint (K7); the limits already keep it - // within what is left, this only guards that. - if amountCents > limits.RemainingCents { - return reject("EnakPoint cannot pay more than what is left on the order") - } - - pointsUsed := in.Points - frozenValue := float64(value) - payment := &entities.Payment{ - ID: uuid.New(), - OrderID: order.ID, - PaymentMethodID: in.PaymentMethodID, - Amount: float64(amountCents) / 100, - Status: entities.PaymentTransactionStatusCompleted, - PointsUsed: &pointsUsed, - PointValue: &frozenValue, - Metadata: entities.Metadata{"points_used": pointsUsed, "point_value": value}, - } - if err := p.repo.InsertPayment(ctx, payment); err != nil { - return err - } - - outletID := order.OutletID - if _, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ - CustomerID: customerID, - Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypePayment, - Amount: in.Points, - ReferenceType: constants.WalletRefTypePayment, - ReferenceID: payment.ID, - OutletID: &outletID, - CreatedByUser: in.CashierID, - Description: pointPaymentDescription(order, amountCents), - Metadata: entities.Metadata{"point_value": value, "amount": payment.Amount}, - IdempotencyKey: "payment:" + payment.ID.String(), - }}); err != nil { - if errors.Is(err, repository.ErrWalletInsufficientBalance) { - return reject("the customer does not have enough EnakPoint") - } - return err - } - - remainingCents := limits.RemainingCents - amountCents - completed := remainingCents == 0 - if err := p.repo.UpdateOrderAfterPayment(ctx, order.ID, float64(remainingCents)/100, completed); err != nil { - return err - } - result.Payment = payment - result.Completed = completed - result.Remaining = float64(remainingCents) / 100 - return nil - }) - if err != nil { - return nil, err - } - return result, nil -} - -func (p *PointPaymentProcessor) limits(ctx context.Context, order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings, value int64) (pointPaymentLimits, int64, error) { - totalPaid, err := p.repo.TotalPaid(ctx, order.ID) - if err != nil { - return pointPaymentLimits{}, 0, err - } - paidWithPoints, err := p.repo.PaidWithPoints(ctx, order.ID) - if err != nil { - return pointPaymentLimits{}, 0, err - } - balances, err := p.spendable.SpendableBalances(ctx, *order.CustomerID, p.now()) - if err != nil { - return pointPaymentLimits{}, 0, err - } - balance := balances[constants.WalletCurrencyPoint] - return computePointPaymentLimits(order.TotalAmount, totalPaid, paidWithPoints, settings.PointPayment.MaxPaymentPercent, value, balance), balance, nil -} - -// pointPaymentOrderProblem says why an order cannot be paid with EnakPoint at all, or -// "" when it can. -func pointPaymentOrderProblem(order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings) string { - switch { - case order.IsVoid: - return "the order is void" - case order.PaymentStatus == string(entities.PaymentStatusCompleted): - return "the order is already paid" - case !settings.PointPayment.AcceptPayment: - return "this outlet does not accept EnakPoint" - case order.CustomerID == nil || order.CustomerIsDefault == nil: - return "the order has no customer" - case *order.CustomerIsDefault: - return "a walk-in order cannot be paid with EnakPoint" - case order.CustomerIsActive == nil || !*order.CustomerIsActive: - return "the customer is not active" - } - return "" -} - -func pointPaymentDescription(order *repository.PointPaymentOrder, amountCents int64) string { - description := "Bayar #" + order.OrderNumber - if order.OutletName != "" { - description += " di " + order.OutletName - } - description += " (Rp " + formatRupiah(amountCents/100) + ")" - return truncateRunes(description, walletDescriptionLimit) -} - -// formatRupiah writes 50000 as 50.000. -func formatRupiah(n int64) string { - s := fmt.Sprintf("%d", n) - out := make([]byte, 0, len(s)+len(s)/3) - for i, c := range []byte(s) { - if i > 0 && (len(s)-i)%3 == 0 { - out = append(out, '.') - } - out = append(out, c) - } - return string(out) -} - -// PointMethodID returns the organization's EnakPoint payment method. -func (p *PointPaymentProcessor) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) { - return p.repo.PointMethodID(ctx, organizationID) -} - -// OrderOwner returns the organization and customer of an order, for checking that a -// customer pays only their own order. -func (p *PointPaymentProcessor) OrderOwner(ctx context.Context, orderID uuid.UUID) (organizationID uuid.UUID, customerID *uuid.UUID, err error) { - order, err := p.repo.GetOrder(ctx, orderID, false) - if err != nil { - return uuid.Nil, nil, err - } - return order.OrganizationID, order.CustomerID, nil -} diff --git a/internal/processor/point_payment_processor_test.go b/internal/processor/point_payment_processor_test.go deleted file mode 100644 index 4b879c3..0000000 --- a/internal/processor/point_payment_processor_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package processor - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestComputePointPaymentLimits(t *testing.T) { - for name, c := range map[string]struct { - total, paid, paidWithPoints float64 - percent, value, balance int64 - wantRemaining, wantMax int64 - }{ - // The F9 example: Rp 87.550 left, 50.000 EnakPoint, 100%, Rp 1 a point. - "PRD example": {87550, 0, 0, 100, 1, 50000, 8755000, 50000}, - "balance covers it all": {87550, 0, 0, 100, 1, 100000, 8755000, 87550}, - "part already paid": {100000, 30000, 0, 100, 1, 100000, 7000000, 70000}, - "capped by percent": {100000, 0, 0, 50, 1, 100000, 10000000, 50000}, - "percent counts EnakPoint already used": {100000, 20000, 20000, 50, 1, 100000, 8000000, 30000}, - "percent cap already used": {100000, 50000, 50000, 50, 1, 100000, 5000000, 0}, - "point worth more than Rp 1": {87550, 0, 0, 100, 100, 1000, 8755000, 875}, - "nothing left": {50000, 50000, 0, 100, 1, 100000, 0, 0}, - "overpaid": {50000, 60000, 0, 100, 1, 100000, 0, 0}, - "no balance": {50000, 0, 0, 100, 1, 0, 5000000, 0}, - "cents left over": {10000.50, 0, 0, 100, 1, 100000, 1000050, 10000}, - } { - got := computePointPaymentLimits(c.total, c.paid, c.paidWithPoints, c.percent, c.value, c.balance) - assert.Equal(t, c.wantRemaining, got.RemainingCents, name) - assert.Equal(t, c.wantMax, got.MaxPoints, name) - assert.LessOrEqual(t, got.MaxPoints*c.value*100, got.RemainingCents, "%s: never more than what is left", name) - } -} - -func TestFormatRupiah(t *testing.T) { - for n, want := range map[int64]string{0: "0", 999: "999", 1000: "1.000", 50000: "50.000", 1234567: "1.234.567"} { - assert.Equal(t, want, formatRupiah(n)) - } -} diff --git a/internal/processor/point_payment_refund.go b/internal/processor/point_payment_refund.go deleted file mode 100644 index 9b6397a..0000000 --- a/internal/processor/point_payment_refund.go +++ /dev/null @@ -1,157 +0,0 @@ -package processor - -import ( - "context" - "fmt" - - "github.com/google/uuid" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/repository" -) - -// RefundForOrder gives EnakPoint back for the order's EnakPoint payments, as far as the -// order has been voided or those payments refunded (docs/prd-point-coin.md F9, K7): -// -// - void: every EnakPoint used on the order; -// - a refunded EnakPoint payment: floor(refunded rupiah / the frozen point value), -// so a later change of the point value does not change how many come back, and a -// rupiah remainder below one EnakPoint is lost (Q13). -// -// Never more than the payment used, and only what has not come back yet, so it can be -// called again safely. Returned EnakPoint go back into lots with the expiry of the lots -// they were taken from, but at least seven days from the refund (note N4, decided). It -// returns how many came back in total. -func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) { - order, err := p.repo.GetOrder(ctx, orderID, false) - if err != nil { - return 0, err - } - payments, err := p.repo.ListPointPayments(ctx, orderID) - if err != nil { - return 0, err - } - - var returned int64 - for _, payment := range payments { - if payment.LedgerID == nil || payment.CustomerID == nil { - continue - } - target := pointRefundTarget(order.IsVoid, payment) - if target == 0 { - continue - } - var n int64 - err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { - if err := p.wallet.LockWallet(ctx, *payment.CustomerID); err != nil { - return err - } - allocations, err := p.repo.PaymentAllocations(ctx, *payment.LedgerID) - if err != nil { - return err - } - refunded, err := p.repo.RefundedByOriginLot(ctx, *payment.LedgerID) - if err != nil { - return err - } - var already int64 - for _, amount := range refunded { - already += amount - } - toReturn := target - already - if toReturn <= 0 { - return nil - } - - // Fill the lots the payment took from, each up to what it gave. - var lots []WalletLotInput - left := toReturn - for _, a := range allocations { - if left == 0 { - break - } - room := a.Amount - refunded[a.LotID] - if room <= 0 { - continue - } - take := min(room, left) - left -= take - lotID := a.LotID - lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: RefundExpiry(a.ExpiresAt, p.now()), OriginLotID: &lotID}) - } - toReturn -= left - - ledgerID := *payment.LedgerID - if _, err := p.wallet.Credit(ctx, WalletCreditInput{ - WalletEntry: WalletEntry{ - CustomerID: *payment.CustomerID, - Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypePaymentRefund, - Amount: toReturn, - ReferenceType: constants.WalletRefTypePayment, - ReferenceID: payment.PaymentID, - ReversesTransactionID: &ledgerID, - OutletID: payment.OutletID, - Description: pointRefundDescription(order), - Metadata: entities.Metadata{"point_value": payment.PointValue, "target": target, "void": order.IsVoid}, - IdempotencyKey: fmt.Sprintf("payment-refund:%s:%d", payment.PaymentID, target), - }, - Lots: lots, - }); err != nil { - return err - } - n = toReturn - return nil - }) - if err != nil { - return returned, fmt.Errorf("refunding EnakPoint payment %s: %w", payment.PaymentID, err) - } - returned += n - } - return returned, nil -} - -// EnsureOrderRefundAllowed refuses an order-level refund that would hand back, in cash -// or another method, what was paid with EnakPoint (K7). The EnakPoint part is refunded -// through its own payment, and comes back as EnakPoint. -func (p *PointPaymentProcessor) EnsureOrderRefundAllowed(ctx context.Context, orderID uuid.UUID, amount float64) error { - refundable, err := p.repo.RefundableByOtherMethods(ctx, orderID) - if err != nil { - return err - } - paidWithPoints, err := p.repo.PaidWithPoints(ctx, orderID) - if err != nil { - return err - } - if paidWithPoints == 0 { - return nil - } - if toCents(amount) > toCents(refundable) { - return fmt.Errorf("%w: at most Rp %s can be refunded this way; the part paid with EnakPoint is refunded through its EnakPoint payment and returns as EnakPoint", - ErrPointPaymentRejected, formatRupiah(toCents(refundable)/100)) - } - return nil -} - -func pointRefundTarget(orderVoid bool, payment repository.PointPaymentRow) int64 { - if orderVoid { - return payment.PointsUsed - } - if payment.Status != string(entities.PaymentTransactionStatusRefunded) { - return 0 - } - valueCents := toCents(payment.PointValue) - if valueCents <= 0 { - return 0 - } - return min(payment.PointsUsed, toCents(payment.RefundAmount)/valueCents) -} - -func pointRefundDescription(order *repository.PointPaymentOrder) string { - description := "Pengembalian #" + order.OrderNumber - if order.OutletName != "" { - description += " di " + order.OutletName - } - return truncateRunes(description, walletDescriptionLimit) -} diff --git a/internal/processor/point_refund_db_test.go b/internal/processor/point_refund_db_test.go deleted file mode 100644 index 55a5e59..0000000 --- a/internal/processor/point_refund_db_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package processor - -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -func (e *pointPaymentEnv) sum(query string, args ...any) int64 { - e.t.Helper() - var n int64 - require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error) - return n -} - -func (e *pointPaymentEnv) returned(order uuid.UUID) int64 { - return e.sum(`SELECT COALESCE(SUM(t.amount), 0) FROM wallet_transactions t - JOIN payments p ON p.id = t.reference_id - WHERE p.order_id = ? AND t.type = 'PAYMENT_REFUND'`, order) -} - -func (e *pointPaymentEnv) assertReconciled(customers ...uuid.UUID) { - e.t.Helper() - found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000) - require.NoError(e.t, err) - for _, d := range found { - for _, c := range customers { - assert.NotEqual(e.t, c, d.CustomerID, d.Check) - } - } -} - -func TestPointRefund_VoidReturnsEverythingToItsExpiry(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(0) - expires := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second) - require.NoError(t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error { - _, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{ - WalletEntry: WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, - Amount: 40000, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}, - Lots: []WalletLotInput{{Amount: 40000, ExpiresAt: &expires}}, - }) - return err - })) - - // Part of the order paid with EnakPoint, then the order is voided. - order := e.order(customer, 50000) - _, err := e.payPoints(order, 30000, e.code(customer)) - require.NoError(t, err) - assert.Equal(t, int64(10000), e.balance(customer)) - require.NoError(t, e.orders.VoidOrder(e.ctx, &models.VoidOrderRequest{OrderID: order, Type: "ALL", Reason: "batal"}, e.cashier)) - - assert.Equal(t, int64(30000), e.returned(order)) - assert.Equal(t, int64(40000), e.balance(customer)) - var expiry time.Time - require.NoError(t, e.db.Raw(`SELECT l.expires_at FROM wallet_lots l JOIN wallet_transactions t ON t.id = l.source_transaction_id - WHERE t.customer_id = ? AND t.type = 'PAYMENT_REFUND'`, customer).Scan(&expiry).Error) - assert.WithinDuration(t, expires, expiry, time.Second, "returned EnakPoint keep the expiry they had") - - // Calling it again returns nothing more. - e.orders.onOrderRefunded(e.ctx, order) - assert.Equal(t, int64(30000), e.returned(order)) - e.assertReconciled(customer) -} - -func TestPointRefund_PartialRefundFloors(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 50000) - payment, err := e.payPoints(order, 50000, e.code(customer)) - require.NoError(t, err) - - // Rp 12.345,67 back at Rp 1 a point: 12.345 EnakPoint; the 67 sen are lost (Q13). - require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 12345.67, "sebagian", e.cashier)) - assert.Equal(t, int64(12345), e.returned(order)) - assert.Equal(t, int64(50000+12345), e.balance(customer)) - e.assertReconciled(customer) -} - -func TestPointRefund_UsesTheFrozenValue(t *testing.T) { - e := newPointPaymentEnv(t) - settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db)) - setValue := func(v int64) { - s, err := settings.Organization(context.Background(), e.org) - require.NoError(t, err) - s.PointValue = v - _, _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s) - require.NoError(t, err) - } - setValue(100) - - customer := e.customerWith(1000) - order := e.order(customer, 50000) - payment, err := e.payPoints(order, 500, e.code(customer)) - require.NoError(t, err) - assert.Equal(t, 50000.0, payment.Amount, "500 × Rp 100") - - // The value changes before the refund; the customer still gets back what they used. - setValue(250) - require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 50000, "semua", e.cashier)) - assert.Equal(t, int64(500), e.returned(order), "50.000 / the frozen Rp 100, not the new Rp 250") - assert.Equal(t, int64(1000), e.balance(customer)) -} - -func TestPointRefund_NoCashForTheEnakPointPart(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 50000) - _, err := e.payPoints(order, 20000, e.code(customer)) - require.NoError(t, err) - _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 30000}) - require.NoError(t, err) - - amount := 40000.0 - err = e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier) - assert.ErrorIs(t, err, ErrPointPaymentRejected, "Rp 40.000 in cash would include EnakPoint") - var refunded float64 - require.NoError(t, e.db.Raw(`SELECT refund_amount FROM orders WHERE id = ?`, order).Scan(&refunded).Error) - assert.Zero(t, refunded, "nothing was written") - - amount = 30000 - require.NoError(t, e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier), "the cash part can be refunded") - assert.Zero(t, e.returned(order), "and no EnakPoint came back for it") -} - -// Giving back the EnakPoint part does not take earning back: that part never earned. -func TestPointRefund_DoesNotReverseEarning(t *testing.T) { - e := newPointPaymentEnv(t) - customer := e.customerWith(100000) - order := e.order(customer, 87500) - pointPayment, err := e.payPoints(order, 20000, e.code(customer)) - require.NoError(t, err) - _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) - require.NoError(t, err) - earned := e.sum(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order) - require.Equal(t, int64(675), earned) - - require.NoError(t, e.orders.RefundPayment(e.ctx, pointPayment.ID, 20000, "kembali", e.cashier)) - assert.Equal(t, int64(20000), e.returned(order)) - reversed := e.sum(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL'`, order) - assert.Zero(t, reversed, "the EnakPoint part never earned, so giving it back takes nothing") - e.assertReconciled(customer) -} diff --git a/internal/processor/wallet_exchange_processor.go b/internal/processor/wallet_exchange_processor.go index 71552a1..29a3843 100644 --- a/internal/processor/wallet_exchange_processor.go +++ b/internal/processor/wallet_exchange_processor.go @@ -28,6 +28,10 @@ type organizationSettingsReader interface { Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) } +type spendableReader interface { + SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) +} + // WalletExchangeProcessor exchanges EnakCoin into EnakPoint (docs/prd-point-coin.md // F4, K3). It is one way only; nothing turns EnakPoint back into EnakCoin. type WalletExchangeProcessor struct { diff --git a/internal/processor/wallet_expiry_processor_test.go b/internal/processor/wallet_expiry_processor_test.go index d5bbbe8..0345313 100644 --- a/internal/processor/wallet_expiry_processor_test.go +++ b/internal/processor/wallet_expiry_processor_test.go @@ -63,7 +63,7 @@ func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) { e.credit(t, earn(a, 30, nil)) // never e.earnCoins(t, a, 4, e.at(-time.Minute)) // A payment already used part of the first lot; only the rest expires. - _, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: pay(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}}) + _, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: redeem(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}}) require.NoError(t, err) notifier := ¬ifierFake{} diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go index 0808d2f..524c943 100644 --- a/internal/processor/wallet_move_db_test.go +++ b/internal/processor/wallet_move_db_test.go @@ -180,7 +180,7 @@ func TestWalletTrace_AgainstPostgres(t *testing.T) { require.NoError(t, err) var payment *WalletResult require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - payment, err = wallet.Debit(ctx, pay(b, 30)) + payment, err = wallet.Debit(ctx, redeem(b, 30)) return err })) diff --git a/internal/processor/wallet_processor.go b/internal/processor/wallet_processor.go index 17bb8de..bad8d41 100644 --- a/internal/processor/wallet_processor.go +++ b/internal/processor/wallet_processor.go @@ -483,19 +483,17 @@ type walletTypeRule struct { } var walletTypeRules = map[string]walletTypeRule{ - constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true}, - constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true}, - constants.WalletTxTypePayment: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true}, - constants.WalletTxTypePaymentRefund: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true, needsReverses: true}, - constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true}, - constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true}, - constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true}, - constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true}, - constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}}, - constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}}, - constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true}, - constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}}, - constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}}, + constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true}, + constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true}, + constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true}, + constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true}, + constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true}, + constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true}, + constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}}, + constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}}, + constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true}, + constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}}, + constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}}, } func validateWalletEntry(in *WalletEntry, credit bool) error { diff --git a/internal/processor/wallet_processor_db_test.go b/internal/processor/wallet_processor_db_test.go index d3aa795..d37fcc8 100644 --- a/internal/processor/wallet_processor_db_test.go +++ b/internal/processor/wallet_processor_db_test.go @@ -106,7 +106,7 @@ func TestWalletProcessor_AgainstPostgres(t *testing.T) { // Overdraw fails and rolls back cleanly. err = txm.WithTransaction(context.Background(), func(ctx context.Context) error { - _, err := p.Debit(ctx, pay(b, 121)) + _, err := p.Debit(ctx, redeem(b, 121)) return err }) assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) diff --git a/internal/processor/wallet_processor_test.go b/internal/processor/wallet_processor_test.go index 810dfd7..0b54461 100644 --- a/internal/processor/wallet_processor_test.go +++ b/internal/processor/wallet_processor_test.go @@ -300,16 +300,16 @@ func earn(customerID uuid.UUID, amount int64, expiresAt *time.Time) WalletCredit } } -func pay(customerID uuid.UUID, amount int64) WalletDebitInput { +func redeem(customerID uuid.UUID, amount int64) WalletDebitInput { return WalletDebitInput{WalletEntry: WalletEntry{ CustomerID: customerID, Currency: constants.WalletCurrencyPoint, - Type: constants.WalletTxTypePayment, + Type: constants.WalletTxTypeRewardRedeem, Amount: amount, - ReferenceType: constants.WalletRefTypePayment, + ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: uuid.New(), OutletID: ptr(uuid.New()), - Description: "Bayar", + Description: "Tukar voucher", }} } @@ -385,7 +385,7 @@ func TestWalletProcessor_DebitAcrossSeveralLots(t *testing.T) { second := e.credit(t, earn(c, 50, e.at(2*time.Hour))).Lots[0] third := e.credit(t, earn(c, 40, e.at(3*time.Hour))).Lots[0] - res, err := e.p.Debit(e.ctx, pay(c, 70)) + res, err := e.p.Debit(e.ctx, redeem(c, 70)) require.NoError(t, err) assert.Equal(t, int64(-70), res.Transaction.Amount) @@ -415,7 +415,7 @@ func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) { var order []uuid.UUID for i := 0; i < 4; i++ { - res, err := e.p.Debit(e.ctx, pay(c, 10)) + res, err := e.p.Debit(e.ctx, redeem(c, 10)) require.NoError(t, err) require.Len(t, res.Allocations, 1) order = append(order, res.Allocations[0].LotID) @@ -425,7 +425,7 @@ func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) { // The expired lot still counts in the balance until the expiry job removes it, // but it cannot be spent (§7.3). assert.Equal(t, int64(10), e.balance(t, c)) - _, err := e.p.Debit(e.ctx, pay(c, 10)) + _, err := e.p.Debit(e.ctx, redeem(c, 10)) assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) } @@ -434,14 +434,14 @@ func TestWalletProcessor_DebitOverBalanceChangesNothing(t *testing.T) { c := e.customer() e.credit(t, earn(c, 50, nil)) - _, err := e.p.Debit(e.ctx, pay(c, 51)) + _, err := e.p.Debit(e.ctx, redeem(c, 51)) assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) assert.Equal(t, int64(50), e.balance(t, c)) assert.Len(t, e.repo.transactions, 1) assert.Empty(t, e.repo.allocations) // A customer who never had a wallet has nothing to spend. - _, err = e.p.Debit(e.ctx, pay(e.customer(), 1)) + _, err = e.p.Debit(e.ctx, redeem(e.customer(), 1)) assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) } @@ -467,7 +467,7 @@ func TestWalletProcessor_DebitUpToWithShortfall(t *testing.T) { e := newWalletTestEnv(t) c := e.customer() earned := e.credit(t, earn(c, 100, nil)) - _, err := e.p.Debit(e.ctx, pay(c, 70)) + _, err := e.p.Debit(e.ctx, redeem(c, 70)) require.NoError(t, err) res, err := e.p.DebitUpTo(e.ctx, reversal(c, 100, earned)) @@ -510,7 +510,7 @@ func TestWalletProcessor_DebitRejectsSomeoneElsesLot(t *testing.T) { e.credit(t, earn(a, 10, nil)) other := e.credit(t, earn(b, 10, nil)) - in := pay(a, 5) + in := redeem(a, 5) in.PreferredLotIDs = []uuid.UUID{other.Lots[0].ID} _, err := e.p.Debit(e.ctx, in) assert.ErrorIs(t, err, ErrWalletInvalidEntry) @@ -567,7 +567,7 @@ func TestWalletProcessor_IdempotentDebit(t *testing.T) { c := e.customer() e.credit(t, earn(c, 30, e.at(time.Hour))) e.credit(t, earn(c, 30, nil)) - in := pay(c, 40) + in := redeem(c, 40) in.IdempotencyKey = "pay:1" first, err := e.p.Debit(e.ctx, in) @@ -585,7 +585,7 @@ func TestWalletProcessor_IdempotentDebitUpToKeepsShortfall(t *testing.T) { e := newWalletTestEnv(t) c := e.customer() earned := e.credit(t, earn(c, 100, nil)) - _, err := e.p.Debit(e.ctx, pay(c, 60)) + _, err := e.p.Debit(e.ctx, redeem(c, 60)) require.NoError(t, err) in := reversal(c, 100, earned) in.IdempotencyKey = "reverse:order-1" @@ -614,7 +614,7 @@ func TestWalletProcessor_IdempotencyKeyReusedForAnotherOperation(t *testing.T) { _, err := e.p.Credit(e.ctx, other) assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) - debit := pay(c, 100) + debit := redeem(c, 100) debit.IdempotencyKey = "k" _, err = e.p.Debit(e.ctx, debit) assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) @@ -673,8 +673,8 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) { credits := map[string]func(*WalletCreditInput){ "unknown type": func(in *WalletCreditInput) { in.Type = "BONUS" }, "debit-only type as credit": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypePayment - in.ReferenceType = constants.WalletRefTypePayment + in.Type = constants.WalletTxTypeRewardRedeem + in.ReferenceType = constants.WalletRefTypeRewardRedemption }, "unknown currency": func(in *WalletCreditInput) { in.Currency = "GOLD" }, "zero amount": func(in *WalletCreditInput) { in.Amount = 0; in.Lots = nil }, @@ -701,10 +701,6 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) { in.ReferenceType = constants.WalletRefTypeWalletTx in.CounterpartyCustomerID = ptr(uuid.New()) }, - "PAYMENT_REFUND without source": func(in *WalletCreditInput) { - in.Type = constants.WalletTxTypePaymentRefund - in.ReferenceType = constants.WalletRefTypePayment - }, "ADJUSTMENT without reason": func(in *WalletCreditInput) { in.Type = constants.WalletTxTypeAdjustment in.ReferenceType = constants.WalletRefTypeUser @@ -744,7 +740,7 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) { in.Type = constants.WalletTxTypeMigration in.ReferenceType = constants.WalletRefTypeLegacyPoints }, - "PAYMENT in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin }, + "REWARD_REDEEM in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin }, "GAME_SPEND in POINT": func(in *WalletDebitInput) { in.Type = constants.WalletTxTypeGameSpend in.ReferenceType = constants.WalletRefTypeGamePlay @@ -772,7 +768,7 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) { e := newWalletTestEnv(t) e.repo.customers[c] = e.org e.credit(t, earn(c, 100, nil)) - in := pay(c, 10) + in := redeem(c, 10) mutate(&in) _, err := e.p.Debit(e.ctx, in) assert.ErrorIs(t, err, ErrWalletInvalidEntry) diff --git a/internal/processor/wallet_query_processor_test.go b/internal/processor/wallet_query_processor_test.go index bc24cf2..21b6285 100644 --- a/internal/processor/wallet_query_processor_test.go +++ b/internal/processor/wallet_query_processor_test.go @@ -87,8 +87,8 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing. {Currency: constants.WalletCurrencyPoint, Date: "2026-07-01", Amount: 100}, }, transactions: []entities.WalletTransaction{ - {ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: -50, BalanceAfter: 250, - ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, Description: "Bayar #ORD-1", CreatedAt: created.Add(time.Hour), + {ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem, Amount: -50, BalanceAfter: 250, + ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: payment, Description: "Tukar voucher", CreatedAt: created.Add(time.Hour), CounterpartyCustomerID: ptr(uuid.New()), Metadata: entities.Metadata{"point_value": 100}}, {ID: earnID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, Amount: 300, BalanceAfter: 300, ReferenceType: constants.WalletRefTypeOrder, ReferenceID: order, Description: "Belanja #ORD-1", CreatedAt: created}, @@ -112,7 +112,7 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing. require.Len(t, data.RecentTransactions, 2) pay, earn := data.RecentTransactions[0], data.RecentTransactions[1] - assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypePayment, ID: payment}, pay.Destination) + assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeRewardRedemption, ID: payment}, pay.Destination) assert.Nil(t, pay.Source) assert.Empty(t, pay.Lots) assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeOrder, ID: order}, earn.Source) @@ -124,7 +124,7 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing. assert.Equal(t, int64(250), data.TotalPoints) require.Len(t, data.PointsHistory, 2) assert.Equal(t, int64(-50), data.PointsHistory[0].Points) - assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type) + assert.Equal(t, constants.WalletTxTypeRewardRedeem, data.PointsHistory[0].Type) assert.Equal(t, created.Add(time.Hour), data.LastUpdated) } @@ -181,7 +181,7 @@ func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) { repo := &walletQueryRepoFake{org: uuid.New(), total: 45} page, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{ - Page: 3, Limit: 10, Currency: "point", Type: "earn, PAYMENT", From: "2026-05-01", To: "2026-05-31", + Page: 3, Limit: 10, Currency: "point", Type: "earn, REWARD_REDEEM", From: "2026-05-01", To: "2026-05-31", }) require.NoError(t, err) assert.Equal(t, models.Pagination{Page: 3, Limit: 10, Total: 45, TotalPages: 5}, page.Pagination) @@ -192,7 +192,7 @@ func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) { assert.Equal(t, 20, f.Offset) assert.Equal(t, 10, f.Limit) assert.Equal(t, constants.WalletCurrencyPoint, f.Currency) - assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypePayment}, f.Types) + assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypeRewardRedeem}, f.Types) assert.True(t, f.From.Equal(time.Date(2026, 5, 1, 0, 0, 0, 0, jakarta))) assert.True(t, f.To.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, jakarta)), "to covers the whole last day") diff --git a/internal/processor/wallet_trace_processor_test.go b/internal/processor/wallet_trace_processor_test.go index 2a0aa01..47f174e 100644 --- a/internal/processor/wallet_trace_processor_test.go +++ b/internal/processor/wallet_trace_processor_test.go @@ -82,8 +82,8 @@ func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string } // The example of §8: A has 100 from #ORD-1 and 50 from #ORD-2, sends 120 to B, and B -// pays 30. Tracing B's payment leads to A's order #ORD-1. -func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) { +// redeems 30. Tracing B's redemption leads to A's order #ORD-1. +func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) { e := newWalletMoveEnv(t) a := e.member("Anita", "081200005678") b := e.member("Budi Santoso", "081234561234") @@ -95,14 +95,14 @@ func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) { e.credit(t, ord2) _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) require.NoError(t, err) - payment, err := e.p.Debit(e.ctx, pay(b, 30)) + payment, err := e.p.Debit(e.ctx, redeem(b, 30)) require.NoError(t, err) p := NewWalletTraceProcessor(walletTraceRepoFake{e}) trace, err := p.Trace(e.ctx, e.org, payment.Transaction.ID) require.NoError(t, err) - assert.Equal(t, constants.WalletTxTypePayment, trace.Transaction.Type) + assert.Equal(t, constants.WalletTxTypeRewardRedeem, trace.Transaction.Type) assert.Equal(t, "Budi Santoso", trace.Transaction.Customer.Name) require.Len(t, trace.Lots, 1) assert.Equal(t, int64(30), trace.Lots[0].Amount) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index b788f9e..64fa8ce 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -99,8 +99,7 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, pm.type as payment_method_type, COALESCE(SUM(p.amount), 0) as total_amount, COUNT(DISTINCT p.order_id) as order_count, - COUNT(p.id) as payment_count, - COALESCE(SUM(p.points_used), 0) as points_used + COUNT(p.id) as payment_count `). Joins("JOIN payment_methods pm ON p.payment_method_id = pm.id"). Joins("JOIN orders o ON p.order_id = o.id"). diff --git a/internal/repository/customer_order_repository.go b/internal/repository/customer_order_repository.go index f6bf8d2..52e8612 100644 --- a/internal/repository/customer_order_repository.go +++ b/internal/repository/customer_order_repository.go @@ -62,8 +62,6 @@ type CustomerOrderPaymentRow struct { Amount float64 Status string RefundAmount float64 - PointsUsed *int64 - PointValue *float64 CreatedAt time.Time } @@ -145,7 +143,7 @@ func (r *customerOrderRepository) ListPayments(ctx context.Context, orderID uuid var rows []CustomerOrderPaymentRow err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT pay.id, COALESCE(pm.name, '') AS method_name, COALESCE(pm.type, '') AS method_type, - pay.amount, pay.status, pay.refund_amount, pay.points_used, pay.point_value, pay.created_at + pay.amount, pay.status, pay.refund_amount, pay.created_at FROM payments pay LEFT JOIN payment_methods pm ON pm.id = pay.payment_method_id WHERE pay.order_id = ? diff --git a/internal/repository/earning_repository.go b/internal/repository/earning_repository.go index 81d3711..0ecfca9 100644 --- a/internal/repository/earning_repository.go +++ b/internal/repository/earning_repository.go @@ -44,9 +44,6 @@ type EarningCursor struct { // EarningRepository reads orders for loyalty earning (docs/prd-point-coin.md F3). type EarningRepository interface { GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error) - // PointPaidAmount is the rupiah part of the order paid with EnakPoint, which earns - // nothing (Q10). Zero until EnakPoint payment exists (phase 3). - PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error) // ListPaidOrdersWithoutEarning pages, oldest first, through orders updated since // the given time that are paid, not void, have an eligible customer, belong to an // outlet that earns something, and have no EARN row yet. Pass the previous page's @@ -92,13 +89,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id, o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id, o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status, - COALESCE(o.is_void, false) AS is_void, - -- Refunds of EnakPoint payments are left out: that part never earned (Q10), - -- so giving it back must not take earning back. - COALESCE(o.refund_amount, 0) - COALESCE(( - SELECT SUM(COALESCE(p.refund_amount, 0)) FROM payments p - JOIN payment_methods pm ON pm.id = p.payment_method_id - WHERE p.order_id = o.id AND pm.type = 'point'), 0) AS refund_amount, + COALESCE(o.is_void, false) AS is_void, COALESCE(o.refund_amount, 0) AS refund_amount, c.is_default AS customer_is_default, c.is_active AS customer_is_active FROM orders o LEFT JOIN outlets ou ON ou.id = o.outlet_id @@ -134,21 +125,6 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid return order, nil } -func (r *earningRepository) PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error) { - var total float64 - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COALESCE(SUM(p.amount), 0) - FROM payments p - JOIN payment_methods pm ON pm.id = p.payment_method_id - WHERE p.order_id = ? AND pm.type = ? AND p.status = ?`, - orderID, constants.PaymentMethodTypePoint, entities.PaymentTransactionStatusCompleted). - Scan(&total).Error - if err != nil { - return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err) - } - return total, nil -} - func (r *earningRepository) ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) { cursorAt, cursorID := since, uuid.Nil if after != nil { diff --git a/internal/repository/loyalty_settings_repository_test.go b/internal/repository/loyalty_settings_repository_test.go index 233c66d..8f9c461 100644 --- a/internal/repository/loyalty_settings_repository_test.go +++ b/internal/repository/loyalty_settings_repository_test.go @@ -58,7 +58,6 @@ func TestLoyaltySettings_AgainstPostgres(t *testing.T) { assert.Equal(t, int64(100), settings.Point.EarnPerAmount) assert.Equal(t, int64(25000), settings.Coin.EarnPerAmount) assert.Nil(t, settings.Point.MaxPerOrder) - assert.Equal(t, int64(100), settings.PointPayment.MaxPaymentPercent) // Change three keys. settings.Point.Enabled = true diff --git a/internal/repository/payment_code_repository.go b/internal/repository/payment_code_repository.go deleted file mode 100644 index f76fc5b..0000000 --- a/internal/repository/payment_code_repository.go +++ /dev/null @@ -1,103 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "github.com/redis/go-redis/v9" -) - -var ( - // ErrPaymentCodeTaken means the code is already live for someone; draw another. - ErrPaymentCodeTaken = errors.New("payment code already in use") - // ErrPaymentCodeNotFound means the code does not exist: never issued, expired, or - // already used. - ErrPaymentCodeNotFound = errors.New("payment code not found") - // ErrPaymentCodeWrongCustomer means the code belongs to another customer. - ErrPaymentCodeWrongCustomer = errors.New("payment code belongs to another customer") -) - -// PaymentCodeRepository keeps one-time EnakPoint payment codes in Redis -// (docs/prd-point-coin.md F9). A code expires by TTL and is removed when used. -type PaymentCodeRepository interface { - // Save stores a code for a customer for ttl, and retires the customer's previous - // code so only the newest one works. ErrPaymentCodeTaken if the code is live. - Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error - // Consume uses a code up if it belongs to the customer. A code of another customer - // is left in place, so a cashier scanning it against the wrong order does not burn - // it for its owner. - Consume(ctx context.Context, code string, customerID uuid.UUID) error -} - -type paymentCodeRepository struct { - client *redis.Client -} - -func NewPaymentCodeRepository(client *redis.Client) PaymentCodeRepository { - return &paymentCodeRepository{client: client} -} - -func paymentCodeKey(code string) string { return "wallet:paycode:" + code } - -func paymentCodeCustomerKey(customerID uuid.UUID) string { - return "wallet:paycode:customer:" + customerID.String() -} - -func (r *paymentCodeRepository) Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error { - ok, err := r.client.SetNX(ctx, paymentCodeKey(code), customerID.String(), ttl).Result() - if err != nil { - return fmt.Errorf("failed to store payment code: %w", err) - } - if !ok { - return ErrPaymentCodeTaken - } - previous, err := r.client.GetSet(ctx, paymentCodeCustomerKey(customerID), code).Result() - if err != nil && !errors.Is(err, redis.Nil) { - return fmt.Errorf("failed to track payment code: %w", err) - } - r.client.Expire(ctx, paymentCodeCustomerKey(customerID), ttl) - if previous != "" && previous != code { - // Only if it is still that customer's: the number may have been reissued. - if err := r.compareAndDelete(ctx, previous, customerID); err != nil && !errors.Is(err, ErrPaymentCodeNotFound) && !errors.Is(err, ErrPaymentCodeWrongCustomer) { - return err - } - } - return nil -} - -// consumeScript deletes a code only if it belongs to the given customer, in one step. -// Returns 1 when used up, 0 when missing, -1 when it belongs to someone else. -var consumeScript = redis.NewScript(` -local owner = redis.call('GET', KEYS[1]) -if not owner then return 0 end -if owner ~= ARGV[1] then return -1 end -redis.call('DEL', KEYS[1]) -return 1 -`) - -func (r *paymentCodeRepository) Consume(ctx context.Context, code string, customerID uuid.UUID) error { - code = strings.TrimSpace(code) - if code == "" { - return ErrPaymentCodeNotFound - } - return r.compareAndDelete(ctx, code, customerID) -} - -func (r *paymentCodeRepository) compareAndDelete(ctx context.Context, code string, customerID uuid.UUID) error { - result, err := consumeScript.Run(ctx, r.client, []string{paymentCodeKey(code)}, customerID.String()).Int() - if err != nil { - return fmt.Errorf("failed to use payment code: %w", err) - } - switch result { - case 1: - return nil - case -1: - return ErrPaymentCodeWrongCustomer - default: - return ErrPaymentCodeNotFound - } -} diff --git a/internal/repository/payment_method_repository.go b/internal/repository/payment_method_repository.go index 7a9cd18..41977b7 100644 --- a/internal/repository/payment_method_repository.go +++ b/internal/repository/payment_method_repository.go @@ -75,8 +75,6 @@ func (r *PaymentMethodRepositoryImpl) List(ctx context.Context, filters map[stri case "search": searchValue := "%" + value.(string) + "%" query = query.Where("name ILIKE ? OR processor ILIKE ?", searchValue, searchValue) - case "exclude_type": - query = query.Where("type <> ?", value) default: query = query.Where(key+" = ?", value) } diff --git a/internal/repository/point_payment_repository.go b/internal/repository/point_payment_repository.go deleted file mode 100644 index 43508bf..0000000 --- a/internal/repository/point_payment_repository.go +++ /dev/null @@ -1,339 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" -) - -// ErrPointPaymentOrderNotFound means the order does not exist. -var ErrPointPaymentOrderNotFound = errors.New("point payment: order not found") - -// PointPaymentOrder is what paying with EnakPoint needs to know about an order. -type PointPaymentOrder struct { - ID uuid.UUID - OrganizationID uuid.UUID - OutletID uuid.UUID - OrderNumber string - OutletName string - CustomerID *uuid.UUID - TotalAmount float64 - PaymentStatus string - IsVoid bool - CustomerIsDefault *bool - CustomerIsActive *bool -} - -// PointPaymentRepository reads and writes what paying an order with EnakPoint touches -// (docs/prd-point-coin.md F9). Unlike the order and payment repositories, every method -// joins the caller's transaction, since the payment row, the balance and the order -// must change together. -type PointPaymentRepository interface { - // GetOrder reads the order; with lock it also locks the order row for the rest of - // the transaction, so two payments of the same order queue up. - GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) - // GetPaymentMethod returns a method's organization and type. - GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (organizationID uuid.UUID, methodType string, err error) - // TotalPaid sums the order's completed payments, as the rest of the order flow does. - TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) - // PaidWithPoints sums the rupiah of the order's completed EnakPoint payments. - PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) - // PointMethodID returns the organization's EnakPoint payment method. - PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) - InsertPayment(ctx context.Context, payment *entities.Payment) error - // UpdateOrderAfterPayment stores what is left to pay and marks the order paid when - // nothing is. - UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error - - // ListPointPayments returns the order's EnakPoint payments with their PAYMENT rows. - ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) - // PaymentAllocations returns the lots a PAYMENT row took from, longest-lasting first. - PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) - // RefundedByOriginLot sums, per original lot, what PAYMENT_REFUND rows have already - // returned for a PAYMENT row. - RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) - // RefundableByOtherMethods is what the order's non-EnakPoint payments can still give - // back: paid minus already refunded. - RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) -} - -type pointPaymentRepository struct { - db *gorm.DB -} - -func NewPointPaymentRepository(db *gorm.DB) PointPaymentRepository { - return &pointPaymentRepository{db: db} -} - -func (r *pointPaymentRepository) GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) { - lockClause := "" - if lock { - lockClause = "FOR UPDATE OF o" - } - var rows []struct { - ID string - OrganizationID string - OutletID string - OrderNumber string - OutletName string - CustomerID *string - TotalAmount float64 - PaymentStatus string - IsVoid bool - CustomerIsDefault *bool - CustomerIsActive *bool - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id, - o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id, - o.total_amount, o.payment_status, COALESCE(o.is_void, false) AS is_void, - c.is_default AS customer_is_default, c.is_active AS customer_is_active - FROM orders o - LEFT JOIN outlets ou ON ou.id = o.outlet_id - LEFT JOIN customers c ON c.id = o.customer_id - WHERE o.id = ? - `+lockClause, orderID).Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to read order: %w", err) - } - if len(rows) == 0 { - return nil, ErrPointPaymentOrderNotFound - } - row := rows[0] - order := &PointPaymentOrder{ - OrderNumber: row.OrderNumber, - OutletName: row.OutletName, - TotalAmount: row.TotalAmount, - PaymentStatus: row.PaymentStatus, - IsVoid: row.IsVoid, - CustomerIsDefault: row.CustomerIsDefault, - CustomerIsActive: row.CustomerIsActive, - } - order.ID, _ = uuid.Parse(row.ID) - order.OrganizationID, _ = uuid.Parse(row.OrganizationID) - order.OutletID, _ = uuid.Parse(row.OutletID) - if row.CustomerID != nil { - if id, err := uuid.Parse(*row.CustomerID); err == nil { - order.CustomerID = &id - } - } - return order, nil -} - -func (r *pointPaymentRepository) GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (uuid.UUID, string, error) { - var rows []struct { - OrganizationID string - Type string - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT organization_id::text AS organization_id, type FROM payment_methods WHERE id = ?`, methodID).Scan(&rows).Error - if err != nil { - return uuid.Nil, "", fmt.Errorf("failed to read payment method: %w", err) - } - if len(rows) == 0 { - return uuid.Nil, "", fmt.Errorf("payment method not found") - } - org, _ := uuid.Parse(rows[0].OrganizationID) - return org, rows[0].Type, nil -} - -func (r *pointPaymentRepository) TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) { - var total float64 - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COALESCE(SUM(amount), 0) FROM payments WHERE order_id = ? AND status = ?`, - orderID, entities.PaymentTransactionStatusCompleted).Scan(&total).Error - if err != nil { - return 0, fmt.Errorf("failed to sum payments: %w", err) - } - return total, nil -} - -func (r *pointPaymentRepository) PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) { - var total float64 - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COALESCE(SUM(p.amount), 0) FROM payments p - JOIN payment_methods pm ON pm.id = p.payment_method_id - WHERE p.order_id = ? AND p.status = ? AND pm.type = ?`, - orderID, entities.PaymentTransactionStatusCompleted, constants.PaymentMethodTypePoint).Scan(&total).Error - if err != nil { - return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err) - } - return total, nil -} - -func (r *pointPaymentRepository) InsertPayment(ctx context.Context, payment *entities.Payment) error { - if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(payment).Error; err != nil { - return fmt.Errorf("failed to create payment: %w", err) - } - return nil -} - -func (r *pointPaymentRepository) UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error { - paymentStatus := entities.PaymentStatusPartial - if completed { - paymentStatus = entities.PaymentStatusCompleted - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` - UPDATE orders SET remaining_amount = ?, payment_status = ?, - status = CASE WHEN ? THEN ? ELSE status END, updated_at = NOW() - WHERE id = ?`, - remaining, paymentStatus, completed, entities.OrderStatusCompleted, orderID).Error - if err != nil { - return fmt.Errorf("failed to update order after payment: %w", err) - } - return nil -} - -func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) { - var ids []string - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("payment_methods"). - Where("organization_id = ? AND type = ?", organizationID, constants.PaymentMethodTypePoint). - Limit(1). - Pluck("id::text", &ids).Error - if err != nil { - return uuid.Nil, fmt.Errorf("failed to find the EnakPoint payment method: %w", err) - } - if len(ids) == 0 { - return uuid.Nil, fmt.Errorf("the organization has no EnakPoint payment method") - } - return uuid.Parse(ids[0]) -} - -// PointPaymentRow is one EnakPoint payment of an order, for refunding it. -type PointPaymentRow struct { - PaymentID uuid.UUID - Status string - PointsUsed int64 - PointValue float64 - RefundAmount float64 - // The PAYMENT ledger row that took the balance; nil if none was written. - LedgerID *uuid.UUID - CustomerID *uuid.UUID - OutletID *uuid.UUID -} - -// PointPaymentAllocation is how much a PAYMENT took from one lot, with that lot's -// expiry, so a refund can return it to the same expiry. -type PointPaymentAllocation struct { - LotID uuid.UUID - Amount int64 - ExpiresAt *time.Time -} - -func (r *pointPaymentRepository) ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) { - var rows []struct { - PaymentID string - Status string - PointsUsed int64 - PointValue float64 - RefundAmount float64 - LedgerID *string - CustomerID *string - OutletID *string - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT p.id::text AS payment_id, p.status, p.points_used, p.point_value, - COALESCE(p.refund_amount, 0) AS refund_amount, - t.id::text AS ledger_id, t.customer_id::text AS customer_id, t.outlet_id::text AS outlet_id - FROM payments p - JOIN payment_methods pm ON pm.id = p.payment_method_id - LEFT JOIN wallet_transactions t ON t.reference_type = ? AND t.reference_id = p.id AND t.type = ? - WHERE p.order_id = ? AND pm.type = ? AND p.points_used IS NOT NULL - ORDER BY p.created_at, p.id`, - constants.WalletRefTypePayment, constants.WalletTxTypePayment, orderID, constants.PaymentMethodTypePoint). - Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to list EnakPoint payments: %w", err) - } - out := make([]PointPaymentRow, 0, len(rows)) - for _, row := range rows { - p := PointPaymentRow{Status: row.Status, PointsUsed: row.PointsUsed, PointValue: row.PointValue, RefundAmount: row.RefundAmount} - p.PaymentID, _ = uuid.Parse(row.PaymentID) - p.LedgerID = parseOptionalUUID(row.LedgerID) - p.CustomerID = parseOptionalUUID(row.CustomerID) - p.OutletID = parseOptionalUUID(row.OutletID) - out = append(out, p) - } - return out, nil -} - -func (r *pointPaymentRepository) PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) { - var rows []struct { - LotID string - Amount int64 - ExpiresAt *time.Time - } - // Longest-lasting first: a partial refund gives back the balance that keeps longest. - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT a.lot_id::text AS lot_id, a.amount, l.expires_at - FROM wallet_lot_allocations a JOIN wallet_lots l ON l.id = a.lot_id - WHERE a.transaction_id = ? - ORDER BY l.expires_at DESC NULLS FIRST, l.created_at DESC, l.id`, ledgerID).Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to list payment allocations: %w", err) - } - out := make([]PointPaymentAllocation, 0, len(rows)) - for _, row := range rows { - id, err := uuid.Parse(row.LotID) - if err != nil { - continue - } - out = append(out, PointPaymentAllocation{LotID: id, Amount: row.Amount, ExpiresAt: row.ExpiresAt}) - } - return out, nil -} - -func (r *pointPaymentRepository) RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) { - var rows []struct { - OriginLotID string - Amount int64 - } - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT l.origin_lot_id::text AS origin_lot_id, SUM(l.original_amount) AS amount - FROM wallet_transactions t JOIN wallet_lots l ON l.source_transaction_id = t.id - WHERE t.reverses_transaction_id = ? AND t.type = ? AND l.origin_lot_id IS NOT NULL - GROUP BY l.origin_lot_id`, ledgerID, constants.WalletTxTypePaymentRefund).Scan(&rows).Error - if err != nil { - return nil, fmt.Errorf("failed to sum payment refunds: %w", err) - } - out := make(map[uuid.UUID]int64, len(rows)) - for _, row := range rows { - if id, err := uuid.Parse(row.OriginLotID); err == nil { - out[id] = row.Amount - } - } - return out, nil -} - -func (r *pointPaymentRepository) RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) { - var total float64 - err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` - SELECT COALESCE(SUM(p.amount - COALESCE(p.refund_amount, 0)), 0) - FROM payments p JOIN payment_methods pm ON pm.id = p.payment_method_id - WHERE p.order_id = ? AND pm.type <> ? AND p.status IN (?, ?)`, - orderID, constants.PaymentMethodTypePoint, - entities.PaymentTransactionStatusCompleted, entities.PaymentTransactionStatusRefunded).Scan(&total).Error - if err != nil { - return 0, fmt.Errorf("failed to sum refundable payments: %w", err) - } - return total, nil -} - -func parseOptionalUUID(s *string) *uuid.UUID { - if s == nil { - return nil - } - id, err := uuid.Parse(*s) - if err != nil { - return nil - } - return &id -} diff --git a/internal/repository/wallet_reconciliation_repository_test.go b/internal/repository/wallet_reconciliation_repository_test.go index 4b530f4..9f7563c 100644 --- a/internal/repository/wallet_reconciliation_repository_test.go +++ b/internal/repository/wallet_reconciliation_repository_test.go @@ -71,8 +71,8 @@ func TestWalletReconciliation_AgainstPostgres(t *testing.T) { } outlet := uuid.New() _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{ - CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, - Amount: 70, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet, + CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem, + Amount: 70, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: uuid.New(), OutletID: &outlet, Description: "Bayar"}}) return err })) diff --git a/internal/router/router.go b/internal/router/router.go index 2022cfd..22dbb63 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -57,8 +57,6 @@ type Router struct { walletAdminHandler *handler.WalletAdminHandler loyaltySettingsHandler *handler.LoyaltySettingsHandler customerPinHandler *handler.CustomerPinHandler - pointPaymentHandler *handler.PointPaymentHandler - customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler customerWalletHandler *handler.CustomerWalletHandler customerDeviceHandler *handler.CustomerDeviceHandler customerOutletHandler *handler.CustomerOutletHandler @@ -68,7 +66,7 @@ type Router struct { redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, customerOutletService service.CustomerOutletService, customerOrderService service.CustomerOrderService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, customerOutletService service.CustomerOutletService, customerOrderService service.CustomerOrderService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -117,8 +115,6 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator), loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService), customerPinHandler: handler.NewCustomerPinHandler(customerPinService), - pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService), - customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService), customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService), customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService), customerOutletHandler: handler.NewCustomerOutletHandler(customerOutletService), @@ -176,7 +172,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) customer.GET("/wallet/expiring", r.customerPointsHandler.GetCustomerWalletExpiring) - customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode) customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange) customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange) customer.GET("/wallet/transfer/recipient", r.customerWalletHandler.TransferRecipient) @@ -186,7 +181,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/outlets", r.customerOutletHandler.List) customer.GET("/orders", r.customerOrderHandler.List) customer.GET("/orders/:id", r.customerOrderHandler.Detail) - customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints) // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) customer.GET("/pin/status", r.customerPinHandler.Status) customer.POST("/pin/otp", r.customerPinHandler.RequestOtp) @@ -316,7 +310,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { { orders.GET("", r.orderHandler.ListOrders) orders.GET("/:id", r.orderHandler.GetOrderByID) - orders.GET("/:id/point-payment/preview", r.pointPaymentHandler.Preview) orders.POST("", r.orderHandler.CreateOrder) orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder) orders.PUT("/:id", r.orderHandler.UpdateOrder) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 18a39f5..be34f0c 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -37,7 +37,6 @@ func TestAllRoutesRegister(t *testing.T) { "GET /api/v1/marketing/loyalty-settings", "PUT /api/v1/marketing/loyalty-settings", "GET /api/v1/marketing/loyalty-settings/history", - "POST /api/v1/customer/wallet/payment-code", "GET /api/v1/customer/wallet/exchange/preview", "POST /api/v1/customer/wallet/exchange", "GET /api/v1/customer/wallet/transfer/recipient", @@ -47,8 +46,6 @@ func TestAllRoutesRegister(t *testing.T) { "GET /api/v1/customer/outlets", "GET /api/v1/customer/orders", "GET /api/v1/customer/orders/:id", - "GET /api/v1/orders/:id/point-payment/preview", - "POST /api/v1/customer/orders/:id/pay-with-points", "GET /api/v1/customer/pin/status", "POST /api/v1/customer/pin/otp", "POST /api/v1/customer/pin", @@ -59,4 +56,13 @@ func TestAllRoutesRegister(t *testing.T) { } { assert.True(t, registered[want], want) } + + // EnakPoint cannot pay for orders (docs/enakgame-prd.md §3.2). + for _, gone := range []string{ + "POST /api/v1/customer/wallet/payment-code", + "GET /api/v1/orders/:id/point-payment/preview", + "POST /api/v1/customer/orders/:id/pay-with-points", + } { + assert.False(t, registered[gone], gone) + } } diff --git a/internal/service/customer_order_payment_service.go b/internal/service/customer_order_payment_service.go deleted file mode 100644 index b7e57c4..0000000 --- a/internal/service/customer_order_payment_service.go +++ /dev/null @@ -1,34 +0,0 @@ -package service - -import ( - "context" - - "github.com/google/uuid" - - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/transformer" -) - -// CustomerOrderPaymentService lets customers pay their own orders with EnakPoint in the -// app or a self-order (docs/prd-point-coin.md F9). -type CustomerOrderPaymentService interface { - PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response -} - -type CustomerOrderPaymentServiceImpl struct { - orders processor.OrderProcessor -} - -func NewCustomerOrderPaymentService(orders processor.OrderProcessor) *CustomerOrderPaymentServiceImpl { - return &CustomerOrderPaymentServiceImpl{orders: orders} -} - -func (s *CustomerOrderPaymentServiceImpl) PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response { - payment, err := s.orders.PayWithPointsInApp(ctx, customerID, orderID, req.Points, req.Pin, info) - if err != nil { - return PointPaymentErrorResponse(err) - } - return contract.BuildSuccessResponse(transformer.PaymentModelToContract(payment)) -} diff --git a/internal/service/customer_pin_service.go b/internal/service/customer_pin_service.go index 9fdc5b4..02c9020 100644 --- a/internal/service/customer_pin_service.go +++ b/internal/service/customer_pin_service.go @@ -25,18 +25,14 @@ type CustomerPinService interface { RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response - - // IssuePaymentCode checks the PIN and returns a one-time code for the cashier (F9). - IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response } type CustomerPinServiceImpl struct { - pins *processor.CustomerPinProcessor - codes *processor.PaymentCodeProcessor + pins *processor.CustomerPinProcessor } -func NewCustomerPinService(pins *processor.CustomerPinProcessor, codes *processor.PaymentCodeProcessor) *CustomerPinServiceImpl { - return &CustomerPinServiceImpl{pins: pins, codes: codes} +func NewCustomerPinService(pins *processor.CustomerPinProcessor) *CustomerPinServiceImpl { + return &CustomerPinServiceImpl{pins: pins} } func (s *CustomerPinServiceImpl) Status(ctx context.Context, customerID uuid.UUID) *contract.Response { @@ -130,11 +126,3 @@ func PinErrorResponse(err error) *contract.Response { contract.NewResponseError(code, constants.CustomerPinServiceEntity, err.Error()), }) } - -func (s *CustomerPinServiceImpl) IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response { - code, err := s.codes.Issue(ctx, customerID, req.Pin, info) - if err != nil { - return PinErrorResponse(err) - } - return contract.BuildSuccessResponse(code) -} diff --git a/internal/service/order_service.go b/internal/service/order_service.go index 626fb24..d7e450c 100644 --- a/internal/service/order_service.go +++ b/internal/service/order_service.go @@ -557,8 +557,7 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen return fmt.Errorf("payment method ID is required") } - // A payment with EnakPoint gives points instead; its amount is computed from them. - if req.Points == nil && req.Amount <= 0 { + if req.Amount <= 0 { return fmt.Errorf("payment amount must be greater than zero") } diff --git a/internal/service/order_service_table_test.go b/internal/service/order_service_table_test.go index adbc497..66fd43b 100644 --- a/internal/service/order_service_table_test.go +++ b/internal/service/order_service_table_test.go @@ -42,14 +42,6 @@ func (m *MockOrderProcessor) UpdateOrder(ctx context.Context, id uuid.UUID, req return args.Get(0).(*models.OrderResponse), args.Error(1) } -func (m *MockOrderProcessor) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) { - args := m.Called(ctx, customerID, orderID, points, pin, info) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*models.PaymentResponse), args.Error(1) -} - func (m *MockOrderProcessor) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) { args := m.Called(ctx, id) if args.Get(0) == nil { diff --git a/internal/service/payment_method_service.go b/internal/service/payment_method_service.go index fa1198f..d0ecd7d 100644 --- a/internal/service/payment_method_service.go +++ b/internal/service/payment_method_service.go @@ -2,10 +2,8 @@ package service import ( "context" - "errors" "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/processor" @@ -41,7 +39,7 @@ func (s *PaymentMethodServiceImpl) CreatePaymentMethod(ctx context.Context, cont response, err := s.paymentMethodProcessor.CreatePaymentMethod(ctx, modelReq) if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_CREATE_ERROR"), "payment_method", err.Error()), + contract.NewResponseError("PAYMENT_METHOD_CREATE_ERROR", "payment_method", err.Error()), }) } @@ -86,7 +84,7 @@ func (s *PaymentMethodServiceImpl) UpdatePaymentMethod(ctx context.Context, id u response, err := s.paymentMethodProcessor.UpdatePaymentMethod(ctx, id, modelReq) if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_UPDATE_ERROR"), "payment_method", err.Error()), + contract.NewResponseError("PAYMENT_METHOD_UPDATE_ERROR", "payment_method", err.Error()), }) } @@ -99,7 +97,7 @@ func (s *PaymentMethodServiceImpl) DeletePaymentMethod(ctx context.Context, id u err := s.paymentMethodProcessor.DeletePaymentMethod(ctx, id) if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_DELETE_ERROR"), "payment_method", err.Error()), + contract.NewResponseError("PAYMENT_METHOD_DELETE_ERROR", "payment_method", err.Error()), }) } @@ -125,12 +123,3 @@ func (s *PaymentMethodServiceImpl) GetActivePaymentMethodsByOrganization(ctx con return contract.BuildSuccessResponse(contractResponses) } - -// paymentMethodErrorCode answers a request to create, delete or retype the EnakPoint -// method as a bad request instead of a server error. -func paymentMethodErrorCode(err error, fallback string) string { - if errors.Is(err, processor.ErrSystemPaymentMethod) { - return constants.ValidationErrorCode - } - return fallback -} diff --git a/internal/service/point_payment_service.go b/internal/service/point_payment_service.go deleted file mode 100644 index a0c609e..0000000 --- a/internal/service/point_payment_service.go +++ /dev/null @@ -1,55 +0,0 @@ -package service - -import ( - "context" - "errors" - - "github.com/google/uuid" - - "apskel-pos-be/internal/appcontext" - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" -) - -// PointPaymentService serves what the cashier needs before taking EnakPoint -// (docs/prd-point-coin.md F9). -type PointPaymentService interface { - Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response -} - -type PointPaymentServiceImpl struct { - payments *processor.PointPaymentProcessor -} - -func NewPointPaymentService(payments *processor.PointPaymentProcessor) *PointPaymentServiceImpl { - return &PointPaymentServiceImpl{payments: payments} -} - -func (s *PointPaymentServiceImpl) Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response { - preview, err := s.payments.Preview(ctx, apctx.OrganizationID, orderID) - if err != nil { - return PointPaymentErrorResponse(err) - } - return contract.BuildSuccessResponse(preview) -} - -// PointPaymentErrorResponse answers a refused EnakPoint payment as a bad request, PIN -// problems with their own codes, and anything else as a server error. -func PointPaymentErrorResponse(err error) *contract.Response { - var pinErr *processor.PinError - if errors.As(err, &pinErr) { - return PinErrorResponse(err) - } - code := constants.InternalServerErrorCode - switch { - case errors.Is(err, repository.ErrPointPaymentOrderNotFound): - code = constants.NotFoundErrorCode - case errors.Is(err, processor.ErrPointPaymentRejected): - code = constants.ValidationErrorCode - } - return contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()), - }) -} diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 79327d0..58f37fc 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -60,8 +60,6 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe OrderCount: item.OrderCount, PaymentCount: item.PaymentCount, Percentage: item.Percentage, - PointsUsed: item.PointsUsed, - CountsAsCashIn: item.CountsAsCashIn, }) } @@ -74,9 +72,6 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe GroupBy: resp.GroupBy, Summary: contract.PaymentMethodSummary{ TotalAmount: resp.Summary.TotalAmount, - PointAmount: resp.Summary.PointAmount, - PointsUsed: resp.Summary.PointsUsed, - TotalWithPoints: resp.Summary.TotalWithPoints, TotalOrders: resp.Summary.TotalOrders, TotalPayments: resp.Summary.TotalPayments, AverageOrderValue: resp.Summary.AverageOrderValue, @@ -642,8 +637,6 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse) OrderCount: item.OrderCount, PaymentCount: item.PaymentCount, Percentage: item.Percentage, - PointsUsed: item.PointsUsed, - CountsAsCashIn: item.CountsAsCashIn, }) } diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index 5c7c8c0..5c73ded 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -326,8 +326,6 @@ func CreatePaymentContractToModel(req *contract.CreatePaymentRequest) *models.Cr return &models.CreatePaymentRequest{ OrderID: req.OrderID, PaymentMethodID: req.PaymentMethodID, - Points: req.Points, - PaymentCode: req.PaymentCode, Amount: req.Amount, TransactionID: req.TransactionID, SplitNumber: req.SplitNumber, @@ -369,8 +367,6 @@ func PaymentModelToContract(resp *models.PaymentResponse) *contract.PaymentRespo SplitType: resp.SplitType, SplitDescription: resp.SplitDescription, RefundAmount: resp.RefundAmount, - PointsUsed: resp.PointsUsed, - PointValue: resp.PointValue, RefundReason: resp.RefundReason, RefundedAt: resp.RefundedAt, RefundedBy: resp.RefundedBy, diff --git a/internal/validator/payment_method_validator.go b/internal/validator/payment_method_validator.go index 62d70e3..8173abf 100644 --- a/internal/validator/payment_method_validator.go +++ b/internal/validator/payment_method_validator.go @@ -95,7 +95,6 @@ func (v *PaymentMethodValidatorImpl) isValidPaymentMethodType(paymentMethodType string(constants.PaymentMethodTypeDigitalWallet), string(constants.PaymentMethodTypeQR), string(constants.PaymentMethodTypeEDC), - string(constants.PaymentMethodTypePoint), } for _, validType := range validTypes { diff --git a/migrations/000102_remove_point_payment_method.down.sql b/migrations/000102_remove_point_payment_method.down.sql new file mode 100644 index 0000000..f12f21f --- /dev/null +++ b/migrations/000102_remove_point_payment_method.down.sql @@ -0,0 +1,35 @@ +-- Brings back what 000094 and 000098 set up. The outlet settings removed by the up +-- migration are not restored: every outlet falls back to not accepting EnakPoint. + +ALTER TABLE payments + ADD COLUMN IF NOT EXISTS points_used BIGINT, + ADD COLUMN IF NOT EXISTS point_value DECIMAL(10,2), + ADD CONSTRAINT chk_payments_point_pair CHECK ( + (points_used IS NULL) = (point_value IS NULL) + AND (points_used IS NULL OR (points_used > 0 AND point_value > 0))); + +ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check; +ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check + CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery', 'point')); + +CREATE UNIQUE INDEX uq_payment_methods_point_per_organization ON payment_methods(organization_id) + WHERE type = 'point'; + +INSERT INTO payment_methods (organization_id, name, type, is_active) +SELECT id, 'EnakPoint', 'point', TRUE FROM organizations +ON CONFLICT (organization_id) WHERE type = 'point' DO NOTHING; + +CREATE OR REPLACE FUNCTION create_point_payment_method() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO payment_methods (organization_id, name, type, is_active) + VALUES (NEW.id, 'EnakPoint', 'point', TRUE) + ON CONFLICT (organization_id) WHERE type = 'point' DO NOTHING; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_create_point_payment_method + AFTER INSERT ON organizations + FOR EACH ROW + EXECUTE FUNCTION create_point_payment_method(); diff --git a/migrations/000102_remove_point_payment_method.up.sql b/migrations/000102_remove_point_payment_method.up.sql new file mode 100644 index 0000000..d50ca33 --- /dev/null +++ b/migrations/000102_remove_point_payment_method.up.sql @@ -0,0 +1,25 @@ +-- EnakPoint can no longer pay for orders: it can only be redeemed for vouchers +-- (docs/enakgame-prd.md §3.2). Undoes 000094 and the point type 000098 kept. + +DROP TRIGGER IF EXISTS trigger_create_point_payment_method ON organizations; +DROP FUNCTION IF EXISTS create_point_payment_method(); + +-- payments.payment_method_id is ON DELETE RESTRICT, so this fails if any payment was +-- made with EnakPoint instead of silently losing it. None was (2026-10-07). +DELETE FROM payment_methods WHERE type = 'point'; +DROP INDEX IF EXISTS uq_payment_methods_point_per_organization; + +ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check; +ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check + CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery')); + +ALTER TABLE payments + DROP CONSTRAINT IF EXISTS chk_payments_point_pair, + DROP COLUMN IF EXISTS points_used, + DROP COLUMN IF EXISTS point_value; + +-- The outlet settings that switched it on. Their history in loyalty_setting_changes stays. +DELETE FROM outlet_settings WHERE key IN ( + 'loyalty.point.accept_payment', + 'loyalty.point.min_payment_points', + 'loyalty.point.max_payment_percent'); -- 2.54.0