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');