diff --git a/docs/migrasi-printer-types.md b/docs/migrasi-printer-types.md new file mode 100644 index 0000000..48b2683 --- /dev/null +++ b/docs/migrasi-printer-types.md @@ -0,0 +1,143 @@ +# Migrasi printer_type ke printer_types + +1 Oktober 2026 + +## Ringkasan + +`printer_type` (string) dihapus dan diganti `printer_types` (array string), sehingga satu produk bisa dicetak ke lebih dari satu printer. Contohnya "Paket Makan Minum" dengan `["kitchen", "bar"]` tercetak di kitchen dan bar sekaligus. + +Ini breaking change. Setelah backend baru dirilis, client yang masih membaca `printer_type` tidak menerima printer apa pun. Karena itu app POS dan dashboard harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis). + +Yang perlu bertindak: + +- **App POS**: baca `printer_types` dan cetak tiap item ke semua printer di dalamnya. +- **Dashboard admin**: ganti pilihan printer di form produk jadi multi-select yang mengirim `printer_types`. + +## Perubahan response + +`printer_type` hilang dari semua response dan digantikan `printer_types`, yang nilainya selalu array dan tidak pernah `null`. + +| Endpoint | Letak field | +| --- | --- | +| `POST /api/v1/products`, `PUT /api/v1/products/:id`, `GET /api/v1/products`, `GET /api/v1/products/all`, `GET /api/v1/products/:id` | objek produk | +| `POST /api/v1/orders`, `GET /api/v1/orders`, `GET /api/v1/orders/:id`, `PUT /api/v1/orders/:id` | `order_items[]` | +| `POST /api/v1/orders/:id/add-items` | `added_items[]` dan `updated_order.order_items[]` | +| `POST /api/v1/self-order/orders`, `GET /api/v1/self-order/orders/:session_id` | `order_items[]` | +| `/api/v1/product-recipes` (semua yang mengembalikan recipe) | `product` | +| `/api/v1/inventory` | `product`, selalu `[]` karena produk di sini hanya berisi id dan nama | + +Contoh satu item di `order_items[]`: + +```json +{ + "product_name": "Paket Makan Minum", + "printer_types": ["kitchen", "bar"], + "print_to_checker": true +} +``` + +Aturan nilainya: + +- `printer_types: []` artinya produk tidak dicetak ke printer mana pun. +- Urutan printer sesuai yang disimpan admin, tanpa duplikat. +- Void, refund, payment, split bill, dan set customer tidak membawa data printer, sama seperti sebelumnya. + +## Perubahan request produk + +`POST /api/v1/products` dan `PUT /api/v1/products/:id` menerima `printer_types` sebagai pengganti `printer_type`. Kalau `printer_type` masih dikirim, field itu diabaikan tanpa error. + +```json +{ + "name": "Paket Makan Minum", + "category_id": "", + "price": 25000, + "printer_types": ["kitchen", "bar"] +} +``` + +| Request | `printer_types` yang dikirim | Hasil | +| --- | --- | --- | +| Create | tidak dikirim | `["kitchen"]` | +| Create | `["kitchen", "bar"]` | `["kitchen", "bar"]` | +| Create | `[]` atau hanya string kosong | `["kitchen"]` | +| Update | tidak dikirim | tidak berubah | +| Update | `["bar"]` | `["bar"]`, seluruh daftar diganti | +| Update | `[]` | `[]`, produk tidak dicetak ke mana pun | + +Sebelum disimpan, spasi di awal dan akhir tiap entri dibuang, lalu entri kosong dan duplikat dihapus. Urutan dipertahankan. + +Tiap entri maksimal 50 karakter. Kalau lebih, request ditolak dengan error code `310` dan pesan `each printer_types entry cannot exceed 50 characters`. + +## Migrasi app POS + +App POS harus mengirim tiap item ke semua printer di `printer_types`, sehingga satu item bisa muncul di lebih dari satu tiket. + +1. Ganti `printer_type` dengan `printer_types` (list string) di model order item dan produk. +2. Selama backend lama masih jalan, `printer_types` belum ada di response. Pakai `[printer_type]` kalau `printer_types` tidak ada, supaya app baru bisa dirilis sebelum backend. +3. Saat mencetak, kelompokkan item per printer dengan mengulang setiap entri `printer_types` milik item. +4. Item dengan `printer_types: []` tidak dicetak ke printer station mana pun. +5. `print_to_checker` tidak berubah dan tetap diperlakukan terpisah. +6. Untuk tambahan pesanan dari `POST /api/v1/orders/:id/add-items`, cetak dari `added_items[]`. + +```text +tiket = {} +untuk setiap item di order_items: + printers = item.printer_types ?? [item.printer_type] // fallback hanya untuk backend lama + untuk setiap printer di printers: + tiket[printer].tambah(item) +untuk setiap (printer, items) di tiket: + cetak items ke printer +``` + +Perbaikan di `added_items[]`: sebelumnya field ini selalu berisi `product_name` dan printer kosong, serta `print_to_checker: true`. Sekarang isinya lengkap seperti item di `updated_order.order_items[]`, dengan urutan sesuai request. Kalau app selama ini mengakali dengan mencari item baru di `updated_order`, cara itu bisa diganti dengan `added_items[]` langsung. + +## Migrasi dashboard + +Form produk di dashboard harus memakai multi-select printer dan selalu mengirim daftar lengkapnya lewat `printer_types`. + +1. Ganti dropdown printer tunggal dengan multi-select, misalnya checkbox, berisi pilihan printer yang sama. +2. Saat membuka form edit, isi pilihan dari `printer_types`. Selama backend lama masih jalan, pakai `[printer_type]` kalau `printer_types` tidak ada. +3. Saat menyimpan, kirim `printer_types` berisi semua printer yang dipilih. +4. Selama backend lama masih jalan, kirim juga `printer_type` berisi printer pertama. Backend lama hanya membaca `printer_type`, dan backend baru mengabaikannya. +5. Di daftar produk, tampilkan semua printer dari `printer_types`. + +Backend tidak membatasi nilai printer. Nilainya harus sama persis dengan nama printer yang dikenal app POS, termasuk huruf besar dan kecilnya. + +Create dengan `printer_types: []` tetap menghasilkan `["kitchen"]`. Produk tanpa printer dibuat dulu, lalu di-update dengan `printer_types: []`. + +## Database dan urutan rilis + +Migration `000099_add_printer_types_to_products` menambah kolom `products.printer_types` (JSONB, `NOT NULL`, default `["kitchen"]`), mengisinya dari `printer_type`, lalu menghapus kolom `printer_type` beserta index-nya. + +- Produk dengan `printer_type` berisi nilai menjadi `[printer_type]`. +- Produk dengan `printer_type` `NULL` atau kosong menjadi `[]`. + +Urutan rilis: + +1. Rilis app POS baru, yang membaca `printer_types` dengan fallback ke `printer_type`, ke semua outlet. +2. Rilis dashboard baru, yang mengirim `printer_types` dan `printer_type`. +3. Jalankan migration `000099` tepat sebelum deploy backend baru. Di antara keduanya, backend lama gagal menyimpan produk karena kolom `printer_type` sudah tidak ada. +4. Setelah backend baru jalan, dashboard boleh berhenti mengirim `printer_type`, dan fallback di app POS boleh dihapus. +5. Atur produk multi-printer, misalnya "Paket Makan Minum" ke `["kitchen", "bar"]`. + +Outlet yang masih memakai app POS lama setelah langkah 3 tidak menerima printer untuk semua item. Pastikan langkah 1 sudah selesai di semua outlet. + +Rollback: jalankan down migration dan deploy backend lama bersamaan. Down migration membuat ulang kolom `printer_type` berisi printer pertama, lalu menghapus `printer_types`, jadi yang hilang hanya printer tambahan. + +## Checklist + +- [ ] App POS baru (dengan fallback) terpasang di semua outlet +- [ ] Dashboard baru mengirim `printer_types` dan `printer_type` +- [ ] Migration `000099` dan backend baru dirilis di staging +- [ ] Uji "Paket Makan Minum" dengan `["kitchen", "bar"]` tercetak di dua printer, saat order baru dan saat tambah pesanan +- [ ] Migration `000099` dan backend baru dirilis di production +- [ ] Dashboard berhenti mengirim `printer_type` +- [ ] Fallback `printer_type` di app POS dihapus + +## FAQ + +**Kenapa `printer_type` dihapus, bukan dipertahankan?** Supaya hanya ada satu sumber data printer. Dua field yang menyimpan hal yang sama bisa saling berbeda. + +**Apakah kitchen dan bar menerima tiket yang sama?** Ya. Keduanya mencetak "Paket Makan Minum" lengkap dengan varian dan modifier-nya. Memecah isi paket per station butuh fitur bundle, yang di luar rilis ini. + +**Apakah nilai printer dibatasi?** Tidak. Nilainya string bebas sampai 50 karakter, dan harus sama dengan nama printer di app POS. diff --git a/docs/prd-point-coin.md b/docs/prd-point-coin.md new file mode 100644 index 0000000..5af5fbd --- /dev/null +++ b/docs/prd-point-coin.md @@ -0,0 +1,1038 @@ +# PRD: EnakPoint & EnakCoin + +**Status:** Draft +**Tanggal:** 2026-09-29 +**Scope:** Wallet customer (EnakPoint & EnakCoin), earning dari order, pembayaran order +dengan EnakPoint, exchange EnakCoin → EnakPoint, transfer antar customer, kedaluwarsa +saldo, PIN customer, pengaturan per outlet dan per organisasi, migrasi dari Token +**Out of scope:** Penukaran reward, tier otomatis, eksekusi campaign rules (lihat §11) + +--- + +## 1. Latar Belakang + +Sistem saat ini punya dua saldo customer: **Point** (`customer_points`) dan **Token** +(`customer_tokens`, per jenis `SPIN` / `RAFFLE` / `MINIGAME`). Keduanya baru sebatas +tabel dan endpoint baca: + +- Tidak ada jalur yang menambah saldo. Order selesai tidak menghasilkan apa pun. +- `AddPoints` / `DeductPoints` masih `not implemented`. +- Tidak ada riwayat transaksi. "History" di `/customer/points` sebenarnya adalah baris + saldo itu sendiri. +- Token hanya dipakai untuk spin game, dan pemotongannya tidak atomik (repository tidak + memakai `DBFromContext`, jumlah baris ter-update tidak dicek). +- Point belum bisa dipakai untuk apa pun, termasuk membayar. + +PRD ini mendefinisikan ulang saldo customer menjadi **EnakPoint** dan **EnakCoin**, +lengkap dengan cara mendapatkannya, memakainya, memindahkannya, masa berlakunya, dan +jejak auditnya. + +--- + +## 2. Tujuan + +1. Customer mendapat EnakPoint dan EnakCoin otomatis dari order yang lunas, dengan + besaran yang diatur **per outlet**. +2. Customer bisa **membayar order dengan EnakPoint**, penuh atau sebagian. EnakCoin + tidak bisa dipakai membayar. +3. Customer bisa menukar EnakCoin ke EnakPoint, satu arah, dengan kurs yang bisa diatur + (default **1 EnakCoin = 1 EnakPoint**). +4. Customer bisa mentransfer EnakPoint dan EnakCoin ke customer lain. +5. EnakPoint dan EnakCoin bisa **kedaluwarsa**, dengan masa berlaku yang diatur sendiri + oleh owner. +6. Setiap perubahan saldo tercatat di ledger, lengkap dengan asal dan tujuannya + **sampai ke tiap butir**, dan bisa diaudit serta direkonsiliasi dengan saldo. + +### Bukan tujuan + +- Menukar EnakPoint ke EnakCoin. Exchange hanya satu arah. +- Membayar dengan EnakCoin. +- Menunaikan EnakPoint/EnakCoin dalam bentuk apa pun (lihat K7). Ini larangan, bukan + fitur yang ditunda. + +--- + +## 3. Istilah + +| Istilah | Kode | Arti | +|---|---|---| +| **EnakPoint** | `POINT` | Saldo yang bernilai rupiah. Satu-satunya saldo yang bisa dipakai membayar order. | +| **EnakCoin** | `COIN` | Pengganti Token. **Mata uang untuk bermain game** (spin, ferris wheel, raffle, minigame, dan game berikutnya). Bisa ditukar ke EnakPoint. **Tidak bisa** dipakai membayar. | +| **Wallet** | – | Saldo EnakPoint dan EnakCoin milik satu customer. | +| **Ledger** | – | Catatan setiap mutasi saldo. Saldo wallet = jumlah seluruh mutasi di ledger. | +| **Lot** | `wallet_lots` | Satu "paket" saldo yang masuk bersamaan, dengan asal dan tanggal kedaluwarsa sendiri. Saldo wallet = jumlah sisa semua lot. | +| **Nilai EnakPoint** | `point_value` | Nilai rupiah dari 1 EnakPoint saat dipakai membayar. Default Rp 1. | +| **Kurs exchange** | – | Berapa EnakCoin ditukar menjadi berapa EnakPoint. Default 1 : 1. | +| **Basis earning** | – | Nominal order yang dipakai untuk menghitung EnakPoint/EnakCoin yang didapat. | + +Di dokumen ini, "Point" dan "Coin" adalah singkatan dari EnakPoint dan EnakCoin. + +--- + +## 4. Keputusan Inti + +**K1 — Token diganti EnakCoin, dan EnakCoin hanya satu jenis.** +Jenis `SPIN` / `RAFFLE` / `MINIGAME` dihapus. **Semua game memakai EnakCoin yang +sama**. Tidak ada lagi saldo terpisah per jenis game, dan game baru tidak menambah jenis +saldo baru. Biaya main diatur per game (F8). + +**K2 — Hanya EnakPoint yang bisa dipakai membayar.** +EnakPoint didaftarkan sebagai payment method, sejajar dengan cash, kartu, dan e-wallet. +EnakCoin tidak punya payment method, dan ledger menolak mutasi pembayaran bercurrency +`COIN` di level database (§8). Customer yang ingin memakai EnakCoin untuk belanja harus +menukarnya dulu ke EnakPoint (F4). + +**K3 — Exchange hanya EnakCoin → EnakPoint. Kurs bisa diatur, default 1 : 1.** +Kurs diatur per organisasi (F2) dalam bentuk bilangan bulat "X EnakCoin = Y EnakPoint", +dengan default 1 : 1. Mengubah kurs langsung mengubah nilai seluruh EnakCoin yang +beredar, jadi perubahan kurs berlaku ke depan saja, kurs yang dipakai dibekukan di +setiap exchange, dan setiap perubahan tercatat (F2). + +> **Implikasi.** Karena EnakCoin bisa menjadi EnakPoint, dan EnakPoint bernilai +> rupiah, maka **setiap EnakCoin yang diberikan secara efektif juga bernilai rupiah**: +> `nilai 1 EnakCoin = (Y / X) × nilai EnakPoint`. Besaran earning EnakCoin (F1) dan +> hadiah EnakCoin di game perlu dihitung sebagai biaya, sama seperti EnakPoint. + +**K4 — Wallet, nilai EnakPoint, kurs, dan kedaluwarsa berada di level organisasi. +Earning di level outlet.** +`customers` sudah terikat ke `organization_id`, dan `customers.phone_number` unik secara +global, sehingga satu nomor telepon adalah satu customer di satu organisasi (Q7, +diputuskan). Saldo berlaku di semua outlet dalam organisasi tersebut. Karena itu nilai +EnakPoint, kurs exchange, dan aturan kedaluwarsa **harus sama di semua outlet** dan +diatur per organisasi. Outlet hanya menentukan berapa yang didapat dari order di +outlet itu, dan apakah outlet menerima pembayaran EnakPoint. Transfer hanya boleh antar +customer dalam organisasi yang sama. + +**K5 — Setiap EnakPoint dan EnakCoin punya jejak: dapat dari mana, hilang ke mana.** +Satu mutasi = satu baris ledger, dan saldo tidak pernah diubah tanpa ledger. Setiap +baris **wajib** menunjuk sumbernya (untuk penambahan) atau tujuannya (untuk +pengurangan). Contohnya order mana, pembayaran mana, customer mana, game play mana, +lot mana yang kedaluwarsa, atau admin siapa. Mutasi tanpa asal/tujuan ditolak oleh +database, bukan cuma oleh aplikasi. Perubahan saldo dan penulisan ledger terjadi dalam +satu transaksi database. Rinciannya di §8.1. + +**K6 — Semua nilai EnakPoint dan EnakCoin berupa bilangan bulat.** +Pecahan hasil perhitungan earning dibulatkan ke bawah. Pembayaran memakai EnakPoint +utuh (tidak ada "setengah Point"). + +**K7 — EnakPoint dan EnakCoin tidak bisa ditunaikan.** +Nilai rupiah EnakPoint hanya berlaku sebagai potongan tagihan order. EnakPoint dan +EnakCoin tidak pernah keluar dari sistem dalam bentuk uang: tidak ada pencairan, tidak +ada kembalian, dan tidak ada refund tunai atas bagian yang dibayar EnakPoint. Semua +jalur yang bisa menjadi jalan untuk menunaikan ditutup: + +| Celah | Aturan | +|---|---| +| Pencairan langsung | Tidak ada endpoint, menu, atau tipe ledger untuk menarik saldo menjadi uang | +| Kembalian | Pembayaran EnakPoint tidak boleh melebihi sisa tagihan. Tidak ada kembalian tunai dari EnakPoint (F9) | +| Refund / void order | Bagian yang dibayar EnakPoint **selalu** kembali sebagai EnakPoint, tidak pernah tunai, transfer bank, atau method lain (F9) | +| Refund sebagian | Sisa rupiah di bawah 1 EnakPoint hangus, tidak dibayar tunai (F9) | +| Order fiktif untuk dibatalkan | Order dibayar EnakPoint lalu di-void hanya mengembalikan EnakPoint | +| Kedaluwarsa | Saldo yang kedaluwarsa hangus tanpa kompensasi dalam bentuk apa pun (F12) | +| Adjustment admin | Mengurangi saldo lewat adjustment tidak disertai pembayaran uang ke customer. Alasan adjustment tidak boleh "pencairan" | +| Transfer | Transfer hanya memindahkan saldo antar customer. Jual-beli saldo di luar sistem tidak difasilitasi dan dilarang di Syarat & Ketentuan | +| Nilai di aplikasi | Nilai rupiah ditampilkan sebagai "setara potongan Rp …", bukan "saldo Rp …", supaya tidak terbaca seperti uang elektronik | + +Aturan ini juga menjaga agar EnakPoint tetap berupa program loyalitas dan tidak +diperlakukan sebagai uang elektronik (lihat catatan N3). + +**K8 — Saldo yang dipindahkan atas permintaan customer wajib disetujui dengan PIN.** +Customer punya PIN 6 digit yang terpisah dari password login (F11). PIN wajib untuk +transfer, pembayaran EnakPoint, dan exchange. Password login tidak dipakai untuk +menyetujui transaksi, karena sesi yang sudah login (HP dipinjam, HP tidak dikunci) +tidak boleh cukup untuk memindahkan saldo. + +| Aksi | Butuh PIN | Alasan | +|---|---|---| +| Transfer EnakPoint / EnakCoin (F5) | **Ya** | Saldo keluar ke orang lain dan tidak bisa dibatalkan | +| Bayar EnakPoint di app / self-order (F9) | **Ya** | Saldo dipakai | +| Buat kode bayar untuk kasir (F9) | **Ya** | Kode bayar sama dengan izin memakai EnakPoint | +| Exchange EnakCoin → EnakPoint (F4) | **Ya** | Tidak bisa dibatalkan | +| Main game (F8) | Tidak | Nilainya kecil per aksi, dan PIN di setiap permainan merusak pengalaman bermain | +| Lihat saldo & riwayat (F6) | Tidak | Tidak memindahkan saldo | +| Reversal, refund, kedaluwarsa, adjustment admin | Tidak berlaku | Dijalankan sistem atau admin, bukan customer | + +**K9 — Saldo disimpan per lot, dan saldo yang paling cepat kedaluwarsa dipakai lebih +dulu.** +Setiap penambahan saldo membuat lot baru dengan tanggal kedaluwarsanya sendiri. Setiap +pengurangan mengambil dari lot yang **paling cepat kedaluwarsa**, dan setiap +pengambilan dicatat (lot mana, berapa banyak). Dengan cara ini: +- Customer tidak dirugikan: saldo yang hampir hangus terpakai lebih dulu. +- Kedaluwarsa tidak bisa diakali. Transfer dan exchange **membawa tanggal kedaluwarsa + asal**, sehingga saldo tidak bisa "diperpanjang" dengan mengirimnya bolak-balik atau + menukarnya. +- Asal setiap butir bisa ditelusuri, tidak hanya asal setiap mutasi (Q9, diputuskan). + +--- + +## 5. User Story + +| # | Sebagai | Saya ingin | Supaya | +|---|---|---|---| +| U1 | Customer | mendapat EnakPoint dan EnakCoin setelah membayar order | belanja saya dihargai | +| U2 | Customer | membayar order dengan EnakPoint, penuh atau sebagian | saldo saya bisa dipakai belanja | +| U3 | Customer | melihat saldo dan riwayat, termasuk asal dan tujuan tiap mutasi | tahu dari mana saldo saya berasal dan dipakai untuk apa | +| U4 | Customer | menukar EnakCoin menjadi EnakPoint | EnakCoin saya bisa ikut dipakai belanja | +| U5 | Customer | mengirim EnakPoint atau EnakCoin ke teman | bisa berbagi atau menggabungkan saldo | +| U6 | Customer | melihat berapa saldo yang akan kedaluwarsa dan kapan, serta diingatkan sebelumnya | bisa memakainya sebelum hangus | +| U7 | Kasir | menerima pembayaran EnakPoint dengan persetujuan customer | EnakPoint customer tidak bisa dipakai tanpa izinnya | +| U8 | Kasir | melihat EnakPoint & EnakCoin yang didapat dan dipakai di struk | bisa memberi tahu customer | +| U9 | Owner/Manager | mengatur earning dan penerimaan EnakPoint per outlet | bisa membedakan promo antar outlet | +| U10 | Owner/Manager | mengatur nilai rupiah EnakPoint, kurs exchange, dan masa berlaku saldo | bisa mengendalikan biaya program loyalitas | +| U11 | Owner/Manager | melihat mutasi wallet seorang customer | bisa menangani komplain | +| U12 | Owner/Manager | menyesuaikan saldo secara manual dengan alasan | bisa mengoreksi kesalahan | +| U13 | Customer | menyetujui transfer, pembayaran, dan exchange dengan PIN | saldo saya aman walaupun HP saya dipinjam orang | +| U14 | Customer | mereset PIN lewat OTP kalau lupa | tidak kehilangan akses ke saldo saya | + +--- + +## 6. Kebutuhan Fungsional + +### F1 — Pengaturan per Outlet + +Disimpan di `outlet_settings` (key–value, sudah ada). + +**Earning.** Pengaturan EnakPoint dan EnakCoin berdiri sendiri. + +| Key | Tipe | Default | Arti | +|---|---|---|---| +| `loyalty.point.enabled` | bool | `false` | Outlet memberi EnakPoint | +| `loyalty.point.earn_per_amount` | int (Rp) | `100` | Setiap kelipatan nominal ini… | +| `loyalty.point.earn_value` | int | `1` | …mendapat sekian EnakPoint | +| `loyalty.point.min_order_amount` | int (Rp) | `0` | Basis minimal agar dapat EnakPoint | +| `loyalty.point.max_per_order` | int, nullable | kosong | Batas atas EnakPoint per order | +| `loyalty.coin.enabled` | bool | `false` | Outlet memberi EnakCoin | +| `loyalty.coin.earn_per_amount` | int (Rp) | `25000` | | +| `loyalty.coin.earn_value` | int | `1` | | +| `loyalty.coin.min_order_amount` | int (Rp) | `0` | | +| `loyalty.coin.max_per_order` | int, nullable | kosong | | + +Dengan nilai EnakPoint default Rp 1, default earning 1 EnakPoint per Rp 100 setara +**cashback 1%**. Dashboard selalu menampilkan persentase cashback efektif di samping +setting ini: `earn_value × nilai EnakPoint / earn_per_amount`. Tujuannya supaya owner +tidak salah mengira skala. + +**Pembayaran EnakPoint.** Hanya ada untuk EnakPoint, tidak ada padanannya untuk +EnakCoin. + +| Key | Tipe | Default | Arti | +|---|---|---|---| +| `loyalty.point.accept_payment` | bool | `false` | Outlet menerima pembayaran EnakPoint | +| `loyalty.point.min_payment_points` | int | `1` | EnakPoint minimal per pembayaran | +| `loyalty.point.max_payment_percent` | int (0–100) | `100` | Porsi maksimal total order yang boleh dibayar EnakPoint | + +**Rumus earning:** + +``` +basis = subtotal − discount_amount − dibayar_dengan_enakpoint +jumlah = 0 jika basis < min_order_amount +jumlah = floor(basis / earn_per_amount) × earn_value +jumlah = min(jumlah, max_per_order) jika max_per_order diisi +``` + +- Basis dihitung **sebelum pajak** (Q1, diputuskan). `tax_amount` tidak ikut dihitung, + begitu juga service charge atau biaya lain yang ditambahkan di atas subtotal. +- Bagian order yang dibayar EnakPoint **tidak** menghasilkan earning (Q10, diputuskan), + supaya tidak ada "Point dari Point". + +**Contoh.** Subtotal setelah diskon Rp 87.500, dibayar tunai penuh. Outlet memberi +1 EnakPoint per Rp 100 dan 1 EnakCoin per Rp 25.000. Customer mendapat **875 +EnakPoint** dan **3 EnakCoin**. Jika Rp 20.000 dari order itu dibayar dengan EnakPoint, +basisnya menjadi Rp 67.500, sehingga customer mendapat 675 EnakPoint dan 2 EnakCoin. + +Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `min_order_amount ≥ 0`, +`max_per_order ≥ 0`, `0 ≤ max_payment_percent ≤ 100`. Hanya role Admin/Manager yang +bisa mengubah. + +### F2 — Pengaturan per Organisasi + +Disimpan di pengaturan organisasi. Berlaku untuk semua outlet (K4). + +| Key | Tipe | Default | Arti | +|---|---|---|---| +| `loyalty.point.value` | int (Rp), ≥ 1 | `1` | Nilai rupiah 1 EnakPoint saat membayar (Q11, diputuskan) | +| `loyalty.exchange.coin_amount` | int, ≥ 1 | `1` | Kurs: sekian EnakCoin… | +| `loyalty.exchange.point_amount` | int, ≥ 1 | `1` | …ditukar menjadi sekian EnakPoint (Q11, diputuskan) | +| `loyalty.transfer.enabled` | bool | `true` | Transfer diizinkan | +| `loyalty.transfer.min_amount` | int | `1` | | +| `loyalty.transfer.max_per_transaction` | int, nullable | kosong | | +| `loyalty.transfer.daily_limit` | int, nullable | kosong | | +| `loyalty.{point,coin}.expiry_*` | – | nonaktif | Kedaluwarsa, lihat F12 | + +**Mengubah nilai EnakPoint atau kurs exchange** langsung mengubah daya beli saldo yang +beredar. Karena itu: +- Dashboard menampilkan peringatan beserta total saldo beredar dan nilai rupiahnya + sebelum dan sesudah perubahan. +- Perubahan berlaku ke depan saja. Pembayaran, refund, dan exchange yang sudah terjadi + memakai nilai yang dibekukan saat transaksi tersebut. +- Setiap perubahan setting loyalitas dicatat: key, nilai lama, nilai baru, siapa, dan + kapan. + +### F3 — Earning dari Order + +- **Pemicu:** order berpindah ke `payment_status = completed`, yaitu lunas penuh + (termasuk lunas lewat split bill). +- **Syarat:** order punya `customer_id`, customer tersebut **bukan** customer default + (walk-in, `is_default = true`), dan customer aktif. +- **Semua kanal diperlakukan sama** (Q2, diputuskan): order dari kasir, self-order (QR + meja), dan customer app memakai aturan dan setting outlet yang sama. +- **Sekali per order.** Ledger memakai idempotency key `earn:{order_id}:{currency}`, + sehingga pemicu ganda (retry, webhook ganda) tidak menggandakan saldo. +- **Snapshot setting.** Nilai setting yang dipakai disimpan di metadata ledger, supaya + perubahan setting berikutnya tidak mengubah arti earning yang sudah terjadi dan + reversal bisa dihitung dengan setting yang sama. +- Earning membuat lot baru dengan tanggal kedaluwarsa sesuai F12. +- Kegagalan earning **tidak boleh** menggagalkan pembayaran order. Kegagalan dicatat + di log dan bisa di-retry, dan idempotency key menjamin retry aman. +- Response order dan data struk menyertakan `points_earned` dan `coins_earned`. + +### F4 — Exchange EnakCoin → EnakPoint + +- Kurs sesuai F2: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). +- Customer memasukkan jumlah EnakCoin. Jumlahnya harus **kelipatan `coin_amount`**, + supaya tidak ada EnakCoin yang hilang karena pembulatan. Aplikasi menampilkan + EnakPoint yang akan didapat sebelum konfirmasi. + ``` + point_didapat = (coin_ditukar / coin_amount) × point_amount + ``` +- EnakCoin berkurang dan EnakPoint bertambah dalam satu transaksi. +- Menghasilkan dua baris ledger: `EXCHANGE_OUT` (COIN, −) dan `EXCHANGE_IN` (POINT, +), + keduanya dengan `group_id` yang sama. Kurs yang dipakai dibekukan di metadata keduanya. +- **Kedaluwarsa ikut terbawa** (K9). Lot EnakPoint hasil exchange kedaluwarsa pada + `min(kedaluwarsa lot EnakCoin asal, sekarang + masa berlaku EnakPoint)`. Exchange + tidak bisa dipakai untuk memperpanjang umur saldo. +- Tidak bisa dibatalkan, jadi aplikasi menampilkan konfirmasi dan meminta PIN (K8). +- Request wajib membawa `Idempotency-Key`. + +### F5 — Transfer ke Customer Lain + +- Mata uang: EnakPoint **atau** EnakCoin, satu jenis per transfer. +- **Penerima** diidentifikasi dengan nomor telepon (identitas login customer app). + Sebelum konfirmasi, aplikasi menampilkan nama penerima yang sudah disamarkan (mis. + "Bu*** Sa***") supaya pengirim bisa memastikan. +- **Syarat penerima:** customer aktif, organisasi sama, bukan customer default, dan + bukan diri sendiri. +- **Konfirmasi:** pengirim memasukkan **PIN** (K8, F11; Q5 diputuskan). +- **Batas:** diatur per organisasi (F2). Defaultnya tanpa batas, dan owner bisa + memasang batas per transaksi atau harian (Q4, diputuskan). +- Menghasilkan dua baris ledger: `TRANSFER_OUT` (pengirim, −N) dan `TRANSFER_IN` + (penerima, +N), dengan `group_id` sama dan referensi silang ke customer lawan. +- **Kedaluwarsa ikut terbawa** (K9). Saldo diambil dari lot pengirim yang paling cepat + kedaluwarsa, dan penerima mendapat lot dengan tanggal kedaluwarsa yang sama persis. + Aplikasi pengirim menampilkan bahwa saldo yang dikirim akan kedaluwarsa pada tanggal + tersebut. +- Final dan tidak bisa dibatalkan oleh customer. Koreksi hanya lewat adjustment admin + (F7). +- Request wajib membawa `Idempotency-Key`. +- Penerima mendapat notifikasi push (memakai `NotificationService` yang sudah ada). + +### F6 — Saldo & Riwayat (Customer App) + +- `GET /customer/wallet` mengembalikan saldo EnakPoint (beserta nilai rupiahnya saat + ini), saldo EnakCoin, **saldo yang akan kedaluwarsa terdekat** (jumlah dan tanggal), + dan beberapa mutasi terakhir. +- `GET /customer/wallet/expiring` mengembalikan rincian saldo yang akan kedaluwarsa, + dikelompokkan per tanggal. +- `GET /customer/wallet/transactions` mengembalikan daftar mutasi dengan pagination, + bisa difilter per currency, tipe, dan rentang tanggal. +- Setiap mutasi menampilkan: tipe, jumlah bertanda (+/−), saldo setelahnya, + **asal (untuk penambahan) atau tujuan (untuk pengurangan)** sesuai §8.1, dan waktu. + Mutasi masuk juga menampilkan tanggal kedaluwarsanya. +- Mutasi bisa dibuka ke detail: order (nomor, outlet, total), pembayaran (nominal + rupiah yang ditutup), lawan transfer (nama tersamar), game play (hadiah yang + didapat), atau pasangan exchange-nya. +- Riwayat tidak pernah hilang. Mutasi yang dikoreksi tetap tampil, bersama baris + koreksinya. + +### F7 — Admin (Dashboard) + +- Melihat wallet dan mutasi seorang customer, dengan asal/tujuan tampil penuh (nama + asli lawan transfer, admin pelaku adjustment, kasir penerima pembayaran). +- **Telusuri mutasi:** dari satu mutasi, lompat ke referensinya, yaitu order, + pembayaran, baris pasangan transfer/exchange, baris asal dari sebuah reversal, game + play, atau lot yang kedaluwarsa. +- **Telusuri per butir:** dari satu pengurangan (misalnya pembayaran), lihat lot mana + yang terpakai, lalu dari lot itu telusuri asalnya sampai ke earning awal, termasuk + jika saldo itu sudah melewati beberapa transfer atau exchange. +- Melihat semua mutasi yang berasal dari satu order (earning, pembayaran, reversal, + refund) dari halaman detail order. +- **Adjustment manual:** tambah atau kurangi EnakPoint/EnakCoin dengan alasan wajib. + Tercatat sebagai `ADJUSTMENT` beserta `user_id` admin. Saldo tidak boleh menjadi + negatif. Adjustment tambah membuat lot dengan kedaluwarsa sesuai F12. +- Mengatur F1 per outlet serta F2 dan F12 per organisasi. + +### F8 — Game Memakai EnakCoin + +- **Semua jenis game** (`SPIN`, ferris wheel, `RAFFLE`, `MINIGAME`) memotong EnakCoin + yang sama. `POST /customer/spin` memotong EnakCoin, bukan Token `SPIN`. +- Biaya per main diatur per game di `games.metadata.coin_cost` (default 1), sehingga + game yang hadiahnya lebih besar bisa lebih mahal. +- Pemotongan EnakCoin, pencatatan `game_plays`, pengurangan stok hadiah, dan ledger + `GAME_SPEND` terjadi dalam satu transaksi. Jika stok hadiah gagal dikurangi, seluruh + permainan dibatalkan (saat ini hanya di-`Printf`). +- `game_plays.token_used` berganti arti menjadi jumlah EnakCoin yang dipakai (diganti + nama menjadi `coins_used`). + +### F9 — Bayar Order dengan EnakPoint + +**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 +`loyalty.point.accept_payment`. Tidak ada payment method untuk EnakCoin. + +**Siapa yang dipotong.** Yang dipotong selalu saldo **customer yang tercatat di order** +(`orders.customer_id`). Order walk-in (customer default) tidak bisa dibayar EnakPoint. +Kalau customer ingin memakai EnakPoint milik orang lain, pemiliknya harus mentransfer +dulu (F5). + +**Perhitungan.** + +``` +nilai = loyalty.point.value (dibaca saat pembayaran, lalu dibekukan) +batas_rupiah = min(remaining_amount, + total_amount × max_payment_percent / 100 + − yang_sudah_dibayar_enakpoint_di_order_ini) +maks_point = min(saldo_point, floor(batas_rupiah / nilai)) +point_dipakai = pilihan customer, min_payment_points ≤ point_dipakai ≤ maks_point +nominal_rupiah = point_dipakai × nilai +``` + +- EnakPoint tidak pernah menghasilkan kembalian (K7). `nominal_rupiah` tidak boleh + melebihi `remaining_amount`. Jika nilai EnakPoint diatur lebih dari Rp 1, sisa tagihan + yang bukan kelipatan `nilai` dibayar dengan method lain lewat split payment yang sudah + ada. +- Aplikasi dan kasir menyediakan tombol "Pakai maksimal" yang mengisi `maks_point`. +- EnakPoint diambil dari lot yang paling cepat kedaluwarsa (K9). + +**Contoh.** Nilai EnakPoint Rp 1 (default), sisa tagihan Rp 87.550, saldo 50.000 +EnakPoint, batas 100%. `maks_point = min(50000, floor(87550 / 1)) = 50000`. Customer +memakai 50.000 EnakPoint (Rp 50.000), lalu sisa Rp 37.550 dibayar tunai. + +**Persetujuan customer.** Kasir tidak boleh bisa memakai EnakPoint customer tanpa +izinnya. +- **Di kasir (POS):** customer membuka aplikasi, memasukkan **PIN**, lalu aplikasi + menampilkan **kode bayar**, yaitu kode 6 digit/QR sekali pakai yang berlaku 2 menit. + Kasir memindai atau mengetik kode tersebut. Kode terikat ke customer, sehingga kode + milik customer lain ditolak. PIN **tidak pernah** diketik di perangkat kasir, supaya + kasir tidak bisa melihat atau merekamnya. Customer tanpa aplikasi belum didukung + (catatan N1). +- **Di customer app / self-order:** customer memasukkan PIN sebelum pembayaran + diproses. Sesi login saja tidak cukup (K8). + +**Pencatatan.** Dalam satu transaksi database: +1. Kunci wallet customer. +2. Ambil saldo dari lot yang paling cepat kedaluwarsa dan catat alokasinya (§8). +3. Potong saldo EnakPoint (update bersyarat, §7). +4. Buat baris `payments` dengan method EnakPoint, `status = completed`, + `amount = nominal_rupiah`, `points_used`, dan `point_value` (nilai yang dibekukan). +5. Tulis ledger `PAYMENT` (POINT, −N) yang menunjuk `payments.id` dan `outlet_id`. +6. Perbarui `remaining_amount` / `payment_status` order seperti pembayaran lain. + +Idempotency key: `payment:{payment_id}`. Kasir yang menekan tombol dua kali tidak +memotong dua kali. + +**Void / refund pembayaran EnakPoint.** +- Pengembalian **hanya dalam bentuk EnakPoint** (K7). Endpoint refund menolak permintaan + yang mengembalikan bagian EnakPoint lewat method lain (tunai, kartu, transfer). Kasir + tidak diberi pilihan method refund untuk bagian ini. +- EnakPoint dikembalikan ke customer yang sama sebagai `PAYMENT_REFUND` (POINT, +N), + dengan `reverses_transaction_id` menunjuk baris `PAYMENT` asal. +- Jumlah yang dikembalikan dihitung dengan **`point_value` yang dibekukan di + pembayaran**, bukan nilai saat ini. Customer mendapat kembali EnakPoint sebanyak yang + dipakai, tidak lebih dan tidak kurang, walaupun nilai EnakPoint sudah diubah. +- **Kedaluwarsa dipulihkan.** EnakPoint yang dikembalikan kembali ke lot dengan tanggal + kedaluwarsa asalnya. Jika tanggal itu sudah lewat atau tinggal kurang dari 7 hari, + masa berlakunya diperpanjang menjadi 7 hari sejak refund, supaya customer sempat + memakainya (catatan N4). +- **Void order:** semua pembayaran EnakPoint di order itu dikembalikan penuh. +- **Refund sebagian:** mengikuti alur refund per-`payments` yang sudah ada. Refund atas + pembayaran EnakPoint dilakukan dalam EnakPoint utuh: + `point_kembali = floor(refund_amount / point_value)`. Sisa rupiah di bawah 1 + EnakPoint hangus, tidak dikembalikan sebagai EnakPoint maupun tunai (Q13, + diputuskan). +- Akumulasi EnakPoint yang dikembalikan tidak boleh melebihi `points_used`. + +**Tampilan.** Struk dan detail order menampilkan baris "EnakPoint: 50.000 (Rp 50.000)". + +**Laporan.** Laporan per payment method menampilkan EnakPoint terpisah. EnakPoint yang +dipakai membayar **bukan kas masuk**. Perlakuan akuntansinya ditunda (catatan N2). + +### F10 — Reversal Earning saat Void / Refund + +- **Void** order yang sudah memberi earning: EnakPoint dan EnakCoin dari order itu + ditarik kembali sepenuhnya. +- **Refund sebagian:** penarikan proporsional, + `floor(earned × refund_amount / basis)`, dengan akumulasi penarikan tidak melebihi + yang pernah diberikan. +- **Lot yang ditarik:** pertama dari lot yang dibuat oleh `EARN` order tersebut + (kalau masih ada sisanya), lalu dari lot lain dengan urutan K9. +- **Saldo tidak cukup** (misalnya sudah dipakai atau ditransfer): tarik sebanyak saldo + yang ada sampai 0, lalu catat kekurangannya di metadata ledger (`shortfall`). Saldo + tidak boleh negatif, dan refund **tidak pernah diblokir** karena saldo tidak cukup + (Q3, diputuskan). +- Tipe ledger: `EARN_REVERSAL`, dengan `reverses_transaction_id` menunjuk `EARN` asal. +- Jika order dibayar sebagian dengan EnakPoint, maka pada void yang sama earning ditarik + (F10) **dan** EnakPoint pembayaran dikembalikan (F9). Keduanya tercatat sebagai + baris terpisah. + +### F11 — PIN Customer + +**Format.** 6 digit angka. Terpisah dari password login. + +**Membuat PIN.** +- Diminta saat customer pertama kali melakukan aksi yang butuh PIN (K8), bukan saat + registrasi. Customer yang hanya mengumpulkan saldo tidak dipaksa membuat PIN. +- Customer yang belum punya PIN tetap bisa **menerima** transfer dan earning, tapi tidak + bisa mengirim, membayar, atau exchange sampai PIN dibuat. +- Membuat PIN pertama kali memerlukan OTP ke nomor telepon customer (memakai + `OtpProcessor` yang sudah ada, dengan purpose baru `pin_setup`). Ini memastikan PIN + dibuat oleh pemilik nomor, bukan oleh orang yang kebetulan memegang HP yang sedang + login. +- PIN ditolak jika terlalu mudah ditebak: semua digit sama (`111111`), berurutan + (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`, dari + `customers.birth_date`). +- PIN dimasukkan dua kali untuk konfirmasi. + +**Penyimpanan.** Hanya hash (bcrypt, sama seperti `password_hash`). PIN tidak pernah +disimpan, dicatat di log, atau dikembalikan di response dalam bentuk asli. Admin tidak +bisa melihat PIN. + +**Salah PIN** (Q17, diputuskan). +- Setiap salah PIN menambah penghitung. Setelah **5 kali salah berturut-turut**, PIN + dikunci selama **30 menit**. Selama terkunci, semua aksi yang butuh PIN ditolak, + termasuk PIN yang benar. +- PIN yang benar mereset penghitung ke 0. +- Penghitung disimpan di database, bukan hanya di cache, supaya tidak bisa dilewati + dengan menunggu cache hilang atau menembak server yang berbeda. +- Response saat salah PIN menyebutkan sisa percobaan. Saat terkunci, response + menyebutkan kapan kunci dibuka. +- Setiap kali PIN terkunci, customer mendapat notifikasi push. + +**Mengganti PIN.** Customer memasukkan PIN lama, lalu PIN baru dua kali. + +**Lupa PIN.** Customer meminta reset, memverifikasi OTP ke nomor telepon (purpose +`pin_reset`), lalu membuat PIN baru. Reset lewat OTP juga membuka kunci PIN. Setelah +reset, **transfer keluar ditahan 24 jam**, sedangkan pembayaran dan exchange tetap bisa +(Q16, diputuskan). Ini membatasi kerugian jika nomor telepon customer diambil alih. + +**Admin.** Admin tidak bisa membuat atau mengganti PIN customer. Admin hanya bisa +**menghapus PIN** (misalnya atas permintaan customer yang kehilangan akses), sehingga +customer harus membuat PIN baru lewat OTP. Aksi ini tercatat beserta admin pelaku dan +alasannya. + +**Jejak.** Semua peristiwa PIN dicatat di log keamanan: dibuat, diganti, di-reset, +salah, terkunci, dan dihapus admin. Setiap peristiwa menyimpan waktu, customer, dan +perangkat/IP. Peristiwa ini bukan mutasi saldo, jadi tidak masuk `wallet_transactions`. + +### F12 — Kedaluwarsa Saldo + +> **Ditunda: model kedaluwarsa belum diputuskan (catatan N4).** Isi bagian ini +> menggambarkan model **per saldo masuk** sebagai draft. Alternatifnya adalah model +> **tanggal tetap** (gaya Telkomsel POIN / XL Poin, semua hangus di tanggal yang sama). +> Yang sudah pasti dan tidak bergantung pada N4: saldo bisa kedaluwarsa, owner bisa +> mengatur sendiri, saldo disimpan per lot (K9), transfer dan exchange membawa tanggal +> kedaluwarsa asal, dan saldo yang hangus tercatat sebagai `EXPIRE`. + +**Pengaturan** (per organisasi, terpisah untuk EnakPoint dan EnakCoin; Q9, diputuskan): + +| Key | Tipe | Default | Arti | +|---|---|---|---| +| `loyalty.point.expiry_enabled` | bool | `false` | EnakPoint bisa kedaluwarsa | +| `loyalty.point.expiry_period` | int, ≥ 1 | `12` | Lama masa berlaku… | +| `loyalty.point.expiry_unit` | `DAY` / `MONTH` | `MONTH` | …dalam satuan ini | +| `loyalty.point.expiry_end_of_month` | bool | `false` | Dibulatkan ke akhir bulan (mis. semua saldo Maret 2026 hangus 31 Maret 2027) | +| `loyalty.point.expiry_reminder_days` | int, ≥ 0 | `7` | Pengingat dikirim sekian hari sebelum kedaluwarsa (0 = tanpa pengingat) | +| `loyalty.coin.expiry_*` | | sama | Pengaturan yang sama untuk EnakCoin | + +Dashboard menampilkan contoh hasil setting, misalnya "EnakPoint yang didapat hari ini +kedaluwarsa pada 30 Sep 2027". + +**Tanggal kedaluwarsa per lot:** + +| Saldo masuk lewat | Kedaluwarsa | +|---|---| +| `EARN`, `ADJUSTMENT` (+) | Sejak saat masuk + masa berlaku currency tersebut. Kosong (tidak kedaluwarsa) jika expiry nonaktif | +| `TRANSFER_IN` | **Sama persis** dengan lot pengirim yang terpakai | +| `EXCHANGE_IN` | `min(kedaluwarsa lot EnakCoin asal, sekarang + masa berlaku EnakPoint)` | +| `PAYMENT_REFUND` | Kedaluwarsa lot asal. Jika sudah lewat atau kurang dari 7 hari lagi, menjadi 7 hari sejak refund (catatan N4) | +| `MIGRATION` | Kosong, sampai expiry diaktifkan (lihat aturan aktivasi di bawah) | + +**Proses kedaluwarsa.** +- Job terjadwal berjalan setiap jam. Job mencari lot dengan `expires_at ≤ sekarang` dan + sisa > 0. +- Untuk setiap lot, job menulis satu baris ledger `EXPIRE` (−sisa) yang menunjuk lot + tersebut, lalu menjadikan sisa lot 0. Semua ini dalam satu transaksi dengan lock + wallet. Idempotency key: `expire:{lot_id}`. +- Saldo yang kedaluwarsa hangus tanpa kompensasi (K7). +- Customer mendapat notifikasi saat saldo kedaluwarsa, dengan jumlahnya. + +**Pengingat.** Sekian hari sebelum kedaluwarsa (`expiry_reminder_days`), customer +mendapat notifikasi push: "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026". Pengingat +dikelompokkan per tanggal, sehingga satu notifikasi per tanggal kedaluwarsa, bukan satu +per lot. + +**Mengubah pengaturan.** +- Mengubah masa berlaku hanya berlaku untuk lot yang masuk **setelah** perubahan. Lot + yang sudah ada tetap memakai tanggalnya. +- **Mengaktifkan** expiry untuk pertama kali: lot yang sudah ada tanpa tanggal + kedaluwarsa diberi tanggal `waktu aktivasi + masa berlaku`, sehingga customer mendapat + masa berlaku penuh sejak aturan diumumkan (catatan N4). +- **Menonaktifkan** expiry: lot baru tidak kedaluwarsa. Lot yang sudah punya tanggal + tetap kedaluwarsa sesuai jadwalnya (catatan N4). +- Setiap perubahan tercatat (F2), dan dashboard menampilkan berapa saldo customer yang + terdampak sebelum owner menyimpan. + +--- + +## 7. Aturan Konsistensi + +1. **Saldo tidak pernah negatif.** Dijaga oleh `CHECK` di database dan update bersyarat + (`WHERE balance >= ?`) yang **mengecek jumlah baris ter-update**. Update yang + mengenai 0 baris dianggap saldo tidak cukup. +2. **Satu transaksi database per operasi.** Semua repository wallet memakai + `DBFromContext` agar ikut transaksi dari `TxManager`. Pembayaran EnakPoint berada di + transaksi yang sama dengan pembuatan baris `payments`. +3. **Urutan lock.** Setiap operasi, termasuk job kedaluwarsa, mengunci wallet + (`SELECT … FOR UPDATE`) sebelum mengubah wallet atau lot-nya. Transfer mengunci dua + wallet berurutan berdasarkan `customer_id` untuk mencegah deadlock. Operasi yang + bersamaan untuk customer yang sama akan antre di lock yang sama, sehingga saldo tidak + terpakai dua kali dan tidak terpakai setelah kedaluwarsa. +4. **Idempotensi.** `wallet_transactions.idempotency_key` unik. Request ulang dengan key + yang sama mengembalikan hasil pertama, bukan error dan bukan mutasi baru. +5. **Rekonsiliasi.** Job pemeriksaan memastikan, per customer per currency: + - `SUM(amount)` ledger = saldo wallet = `SUM(remaining_amount)` semua lot. + - Untuk setiap lot: `original_amount − SUM(alokasi) = remaining_amount`. + - Untuk setiap mutasi keluar: `SUM(alokasi)` = nilai absolut `amount`-nya. + - Untuk setiap `payments` bermethod EnakPoint: `points_used` = nilai absolut baris + `PAYMENT`-nya. + +--- + +## 8. Model Data (Usulan) + +### `customer_wallets`: menggantikan `customer_points` dan `customer_tokens` + +Satu baris per customer. Baris ini juga menjadi titik lock untuk semua operasi wallet +customer tersebut. + +```sql +CREATE TABLE customer_wallets ( + customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE RESTRICT, + organization_id UUID NOT NULL REFERENCES organizations(id), + point_balance BIGINT NOT NULL DEFAULT 0 CHECK (point_balance >= 0), + coin_balance BIGINT NOT NULL DEFAULT 0 CHECK (coin_balance >= 0), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### `wallet_transactions`: ledger + +```sql +CREATE TABLE wallet_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')), + type VARCHAR(30) NOT NULL, + amount BIGINT NOT NULL CHECK (amount <> 0), -- bertanda + balance_after BIGINT NOT NULL, + group_id UUID, -- menyatukan pasangan exchange / transfer + + -- Asal (amount > 0) atau tujuan (amount < 0). Wajib untuk semua tipe. + reference_type VARCHAR(30) NOT NULL, -- ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER, ... + reference_id UUID NOT NULL, + + counterparty_customer_id UUID REFERENCES customers(id), -- TRANSFER_IN / _OUT + reverses_transaction_id UUID REFERENCES wallet_transactions(id), -- EARN_REVERSAL / PAYMENT_REFUND + outlet_id UUID, -- EARN, EARN_REVERSAL, PAYMENT, PAYMENT_REFUND + created_by_user UUID, -- ADJUSTMENT: admin; PAYMENT / PAYMENT_REFUND: kasir + reason VARCHAR(255), -- ADJUSTMENT: alasan + + description VARCHAR(255) NOT NULL, -- teks siap tampil, dibekukan saat dibuat + metadata JSONB DEFAULT '{}', -- snapshot setting, point_value, kurs, shortfall + idempotency_key VARCHAR(100) UNIQUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- Hanya EnakPoint yang bisa membayar (K2) + CONSTRAINT chk_point_only_types CHECK ( + type NOT IN ('PAYMENT','PAYMENT_REFUND','EXCHANGE_IN','REWARD_REDEEM') + OR currency = 'POINT'), + CONSTRAINT chk_coin_only_types CHECK ( + type NOT IN ('EXCHANGE_OUT','GAME_SPEND') OR currency = 'COIN'), + + CONSTRAINT chk_transfer_counterparty CHECK ( + type NOT IN ('TRANSFER_IN','TRANSFER_OUT') OR counterparty_customer_id IS NOT NULL), + CONSTRAINT chk_reversal_source CHECK ( + type NOT IN ('EARN_REVERSAL','PAYMENT_REFUND') OR reverses_transaction_id IS NOT NULL), + CONSTRAINT chk_adjustment_actor CHECK ( + type <> 'ADJUSTMENT' OR (created_by_user IS NOT NULL AND reason IS NOT NULL)), + CONSTRAINT chk_expire_lot CHECK ( + type <> 'EXPIRE' OR reference_type = 'LOT') +); +-- index: (customer_id, created_at DESC), (reference_type, reference_id), (group_id), +-- (counterparty_customer_id), (reverses_transaction_id) +``` + +Ledger bersifat **append-only**. Baris tidak pernah di-`UPDATE` atau di-`DELETE`. +Koreksi dilakukan dengan baris baru (`EARN_REVERSAL`, `PAYMENT_REFUND`, atau +`ADJUSTMENT`) yang menunjuk baris yang dikoreksi. Customer yang punya riwayat tidak bisa +dihapus permanen, cukup dinonaktifkan. + +### `wallet_lots` dan `wallet_lot_allocations`: saldo per butir (K9) + +```sql +CREATE TABLE wallet_lots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')), + source_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), -- mutasi masuk pembuatnya + origin_lot_id UUID REFERENCES wallet_lots(id), -- lot asal: transfer / exchange / refund + original_amount BIGINT NOT NULL CHECK (original_amount > 0), + remaining_amount BIGINT NOT NULL CHECK (remaining_amount >= 0 + AND remaining_amount <= original_amount), + expires_at TIMESTAMPTZ, -- NULL = tidak kedaluwarsa + created_at TIMESTAMPTZ DEFAULT NOW() +); +-- urutan pemakaian (K9): paling cepat kedaluwarsa dulu, yang tanpa tanggal paling akhir +CREATE INDEX idx_wallet_lots_consume ON wallet_lots + (customer_id, currency, expires_at NULLS LAST, created_at) WHERE remaining_amount > 0; +CREATE INDEX idx_wallet_lots_expiry ON wallet_lots (expires_at) WHERE remaining_amount > 0; + +-- Setiap mutasi keluar mencatat lot mana yang dipakai dan berapa banyak +CREATE TABLE wallet_lot_allocations ( + transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), -- mutasi keluar + lot_id UUID NOT NULL REFERENCES wallet_lots(id), + amount BIGINT NOT NULL CHECK (amount > 0), + PRIMARY KEY (transaction_id, lot_id) +); +``` + +`wallet_lots.remaining_amount` adalah satu-satunya kolom yang di-`UPDATE`, sebagai +ringkasan untuk mempercepat pemakaian. Nilainya selalu bisa dihitung ulang dari +`original_amount − SUM(wallet_lot_allocations.amount)` (§7.5). Ledger dan alokasi tetap +append-only. + +**Contoh.** Customer A punya lot 100 EnakPoint (dari order #ORD-1, kedaluwarsa 31 Des) +dan lot 50 EnakPoint (dari order #ORD-2, kedaluwarsa 31 Jan). A mentransfer 120 ke B. +- `TRANSFER_OUT` A dialokasikan 100 dari lot #ORD-1 dan 20 dari lot #ORD-2. +- B mendapat dua lot: 100 (kedaluwarsa 31 Des, `origin_lot_id` = lot #ORD-1) dan 20 + (kedaluwarsa 31 Jan, `origin_lot_id` = lot #ORD-2). +- Kalau B lalu membayar dengan 30 EnakPoint, alokasinya menunjukkan bahwa 30 EnakPoint + itu berasal dari order #ORD-1 milik A. + +### Perubahan tabel yang sudah ada + +```sql +-- payment_methods.type: tambah nilai 'point' +-- (validator saat ini: oneof=cash card digital_wallet) + +-- PIN customer (F11) +ALTER TABLE customers + ADD COLUMN pin_hash VARCHAR(255), + ADD COLUMN pin_set_at TIMESTAMPTZ, + ADD COLUMN pin_failed_attempts INT NOT NULL DEFAULT 0, + ADD COLUMN pin_locked_until TIMESTAMPTZ, + ADD COLUMN transfer_blocked_until TIMESTAMPTZ; -- 24 jam setelah reset PIN + +CREATE TABLE customer_security_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + event VARCHAR(30) NOT NULL, -- PIN_SET, PIN_CHANGED, PIN_RESET, PIN_FAILED, + -- PIN_LOCKED, PIN_REMOVED_BY_ADMIN + actor_user UUID, -- diisi untuk PIN_REMOVED_BY_ADMIN + reason VARCHAR(255), + ip_address VARCHAR(45), + user_agent VARCHAR(255), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE payments + ADD COLUMN points_used BIGINT, -- diisi hanya untuk method EnakPoint + ADD COLUMN point_value DECIMAL(10,2), -- nilai 1 EnakPoint saat dibayar (beku) + ADD CONSTRAINT chk_payments_point_pair CHECK ( + (points_used IS NULL AND point_value IS NULL) + OR (points_used > 0 AND point_value > 0)); + +-- Riwayat perubahan setting loyalitas (F2, F12) +CREATE TABLE loyalty_setting_changes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + outlet_id UUID, -- NULL untuk setting organisasi + key VARCHAR(100) NOT NULL, + old_value TEXT, + new_value TEXT, + changed_by UUID NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 8.1 Jejak Asal & Tujuan per Tipe + +| Tipe | Currency | Arah | Dari mana / ke mana | `reference_type` → `reference_id` | Kolom wajib tambahan | Contoh `description` | +|---|---|---|---|---|---|---| +| `EARN` | POINT / COIN | masuk | Order yang lunas | `ORDER` → `orders.id` | `outlet_id` | "Belanja #ORD-0123 di Outlet Kemang" | +| `EARN_REVERSAL` | POINT / COIN | keluar | Ditarik karena order di-void/refund | `ORDER` → `orders.id` | `reverses_transaction_id` (baris `EARN` asal), `outlet_id` | "Batal #ORD-0123 di Outlet Kemang" | +| `PAYMENT` | POINT | keluar | Dipakai membayar order | `PAYMENT` → `payments.id` | `outlet_id`, `created_by_user` (kasir, jika via POS) | "Bayar #ORD-0123 di Outlet Kemang (Rp 50.000)" | +| `PAYMENT_REFUND` | POINT | masuk | Dikembalikan karena pembayaran di-void/refund | `PAYMENT` → `payments.id` | `reverses_transaction_id` (baris `PAYMENT` asal), `outlet_id` | "Pengembalian #ORD-0123 di Outlet Kemang" | +| `EXCHANGE_OUT` | COIN | keluar | Ditukar menjadi EnakPoint | `WALLET_TX` → baris `EXCHANGE_IN` pasangannya | `group_id` | "Tukar 50 EnakCoin ke EnakPoint" | +| `EXCHANGE_IN` | POINT | masuk | Hasil tukar EnakCoin | `WALLET_TX` → baris `EXCHANGE_OUT` pasangannya | `group_id` | "Dari tukar 50 EnakCoin" | +| `TRANSFER_OUT` | POINT / COIN | keluar | Dikirim ke customer lain | `WALLET_TX` → baris `TRANSFER_IN` penerima | `counterparty_customer_id`, `group_id` | "Transfer ke Bu*** Sa*** (08**-****-1234)" | +| `TRANSFER_IN` | POINT / COIN | masuk | Diterima dari customer lain | `WALLET_TX` → baris `TRANSFER_OUT` pengirim | `counterparty_customer_id`, `group_id` | "Transfer dari An*** (08**-****-5678)" | +| `GAME_SPEND` | COIN | keluar | Dipakai bermain game | `GAME_PLAY` → `game_plays.id` | – | "Main Spin Wheel: dapat Voucher 10rb" | +| `EXPIRE` | POINT / COIN | keluar | Hangus karena masa berlaku habis | `LOT` → `wallet_lots.id` | – | "Kedaluwarsa: 150 EnakPoint dari Belanja #ORD-0098" | +| `ADJUSTMENT` | POINT / COIN | masuk/keluar | Koreksi manual oleh admin | `USER` → `users.id` admin | `created_by_user`, `reason` | "Koreksi oleh admin: komplain #45" | +| `MIGRATION` | POINT / COIN | masuk | Saldo lama sebelum sistem ini | `LEGACY_POINTS` / `LEGACY_TOKENS` → id baris lama | – | "Saldo awal dari sistem lama" | +| `REWARD_REDEEM` | POINT | keluar | Ditukar reward (fase berikut) | `REWARD_REDEMPTION` → id penukaran | – | "Tukar reward: Tumbler" | + +Setiap mutasi **masuk** membuat satu atau lebih lot. Setiap mutasi **keluar** mencatat +alokasi ke lot yang dipakai. Dengan begitu jejak bisa ditelusuri di dua tingkat: + +**Per mutasi** (lewat `reference_*`): +- **EnakPoint yang dipakai bayar:** `PAYMENT` → `payments` → order, outlet, kasir, dan + nominal rupiah yang ditutup. +- **EnakPoint yang kembali:** `PAYMENT_REFUND` → `PAYMENT` asal → order. +- **Saldo yang masuk lewat transfer:** `TRANSFER_IN` → baris `TRANSFER_OUT` pengirim. +- **EnakPoint hasil tukar:** `EXCHANGE_IN` → `EXCHANGE_OUT` (EnakCoin). +- **Earning yang ditarik:** `EARN_REVERSAL` → `EARN` asal → order. +- **Saldo yang hangus:** `EXPIRE` → lot → mutasi masuk yang membuat lot tersebut. + +**Per butir** (lewat `wallet_lot_allocations` dan `origin_lot_id`): dari pengurangan +mana pun, lihat lot yang terpakai, lalu ikuti `origin_lot_id` ke belakang melewati +transfer, exchange, atau refund, sampai ke lot pertama yang dibuat oleh `EARN`, +`ADJUSTMENT`, atau `MIGRATION`. + +**`description` dibekukan saat dibuat.** Nama outlet, nomor order, atau nama penerima +yang berubah belakangan tidak mengubah riwayat. Prinsipnya sama seperti snapshot harga +di `order_items`. Nama penerima/pengirim disamarkan di `description`. Nama lengkap hanya +terlihat oleh admin lewat `counterparty_customer_id`. + +--- + +## 9. API (Usulan) + +### Customer app (`/customer`, `CustomerAuthMiddleware`) + +| Method | Path | Keterangan | +|---|---|---| +| GET | `/wallet` | Saldo, nilai rupiah EnakPoint, saldo yang akan kedaluwarsa terdekat, mutasi terakhir (menggantikan `/points`, `/tokens`) | +| GET | `/wallet/transactions` | Riwayat, pagination & filter | +| GET | `/wallet/expiring` | Rincian saldo yang akan kedaluwarsa per tanggal | +| POST | `/wallet/payment-code` | `{ "pin" }` → kode bayar EnakPoint sekali pakai `{ "code", "qr", "expires_at" }` | +| GET | `/wallet/exchange/preview?coins=` | Kurs saat ini dan EnakPoint yang akan didapat | +| POST | `/wallet/exchange` | `{ "coins": 50, "pin": "..." }` | +| GET | `/wallet/transfer/recipient?phone=` | Cek penerima, mengembalikan nama tersamar | +| POST | `/wallet/transfer` | `{ "currency": "POINT", "amount": 100, "recipient_phone": "...", "pin": "..." }` | +| POST | `/orders/:id/pay-with-points` | Bayar order milik customer sendiri (self-order / app) → `{ "points": 50000, "pin": "..." }` | +| POST | `/spin` | Tetap, kini memotong EnakCoin. Tanpa PIN | +| GET | `/pin/status` | `{ "has_pin", "locked_until", "transfer_blocked_until" }` | +| POST | `/pin/otp` | Kirim OTP untuk `pin_setup` / `pin_reset` | +| POST | `/pin` | Buat PIN pertama: `{ "otp_code", "pin", "confirm_pin" }` | +| PUT | `/pin` | Ganti PIN: `{ "old_pin", "pin", "confirm_pin" }` | +| POST | `/pin/reset` | Lupa PIN: `{ "otp_code", "pin", "confirm_pin" }` | + +Semua endpoint yang menerima `pin` mengembalikan error yang bisa dibedakan oleh +aplikasi: `PIN_NOT_SET`, `PIN_INVALID` (beserta sisa percobaan), `PIN_LOCKED` (beserta +`locked_until`), dan `TRANSFER_BLOCKED` (beserta `transfer_blocked_until`). + +Endpoint `/points` dan `/tokens` dipertahankan sementara sebagai alias yang membaca +dari `customer_wallets`, lalu dihapus setelah aplikasi diperbarui. + +### POS / Dashboard (`/api/v1`) + +| Method | Path | Role | Keterangan | +|---|---|---|---| +| GET | `/orders/:id/point-payment/preview` | Kasir | `maks_point`, nilai EnakPoint, nominal rupiah untuk customer order | +| POST | `/orders/:id/payments` | Kasir | Endpoint pembayaran yang sudah ada. Untuk method EnakPoint, body membawa `{ "points": 50000, "payment_code": "482913" }` | +| GET/PUT | `/outlets/:id/loyalty-settings` | Admin/Manager | F1 | +| GET/PUT | `/marketing/loyalty-settings` | Admin/Manager | F2 dan F12 (nilai EnakPoint, kurs, transfer, kedaluwarsa) | +| GET | `/marketing/loyalty-settings/history` | Admin/Manager | Riwayat perubahan setting | +| GET | `/marketing/customers/:id/wallet` | Admin/Manager | Saldo, lot aktif, mutasi | +| GET | `/marketing/wallet-transactions/:id/trace` | Admin/Manager | Telusuri per butir: alokasi lot dan rantai `origin_lot_id` | +| POST | `/marketing/customers/:id/wallet/adjust` | Admin/Manager | `{ "currency", "amount", "reason" }` | +| DELETE | `/marketing/customers/:id/pin` | Admin/Manager | Hapus PIN customer: `{ "reason" }` | +| GET | `/marketing/customers/:id/security-events` | Admin/Manager | Log keamanan PIN | + +Payment method bertipe `point` ditolak di endpoint pembayaran jika: outlet tidak +menerima EnakPoint, order tanpa customer atau walk-in, kode bayar salah/kedaluwarsa/ +milik customer lain, atau `points` di luar batas F9. + +--- + +## 10. Migrasi dari Token + +1. Buat `customer_wallets`, `wallet_transactions`, `wallet_lots`, + `wallet_lot_allocations`, dan `loyalty_setting_changes`. +2. Salin saldo: + - `point_balance` diisi dari `customer_points.balance`. + - `coin_balance` diisi dari **jumlah seluruh jenis** `customer_tokens.balance` milik + customer tersebut (Q6, diputuskan). Contoh: SPIN 5 + RAFFLE 2 + MINIGAME 1 = + 8 EnakCoin. Rincian saldo per jenis disimpan di `metadata` baris ledger + `MIGRATION`, supaya asal saldo awal tetap bisa ditelusuri. +3. Tulis satu baris ledger `MIGRATION` dan satu lot (tanpa tanggal kedaluwarsa) per + customer per currency yang saldonya > 0, supaya rekonsiliasi (§7.5) langsung + berlaku. +4. `campaigns.type` / `campaign_rules.reward_type`: nilai `TOKENS` diganti `COINS`. +5. Tambah tipe `point` ke `payment_methods`, lalu buat payment method sistem + "EnakPoint" untuk setiap organisasi. Tambah kolom `points_used` / `point_value` ke + `payments`. +6. Tambah kolom PIN ke `customers` dan tabel `customer_security_events`. Semua customer + yang sudah ada mulai tanpa PIN, dan akan diminta membuatnya lewat OTP saat pertama + kali transfer, membayar, atau exchange. +7. `customer_points` dan `customer_tokens` dibiarkan read-only selama satu rilis, lalu + di-drop di migrasi berikutnya. + +--- + +## 11. Di Luar Scope + +- **Penukaran reward dengan EnakPoint.** Katalog reward sudah ada. Alurnya akan dibahas + di PRD terpisah dan memakai tipe ledger `REWARD_REDEEM`. +- **Tier otomatis** berdasarkan EnakPoint. Dibahas di PRD terpisah (lihat Q8 untuk + arahannya). +- **Eksekusi campaign rules** (bonus/multiplier) di atas earning dasar outlet. +- **Earning untuk order tanpa customer terdaftar** (klaim belakangan lewat struk/QR). + +--- + +## 12. Pertanyaan & Catatan + +### 12.1 Sudah Diputuskan + +| # | Pertanyaan | Keputusan | Tercermin di | +|---|---|---|---| +| Q1 | Basis earning: sebelum atau sesudah pajak/service charge? | **Sebelum pajak** (`subtotal − discount`) | F1 | +| Q2 | Apakah earning dari self-order (QR meja) diperlakukan sama? | **Ya**, semua kanal sama selama order punya customer | F3 | +| Q3 | Saat reversal earning dan saldo tidak cukup: tarik sampai 0, izinkan saldo negatif, atau blokir refund? | **Tarik sampai 0 dan catat shortfall.** Refund tidak diblokir | F10 | +| Q4 | Batas transfer diatur per organisasi atau global? Perlu limit harian? | **Per organisasi**, default tanpa batas | F2, F5 | +| Q5 | Konfirmasi transfer pakai password, PIN khusus, atau OTP? | **PIN customer 6 digit**, juga untuk pembayaran dan exchange | K8, F11 | +| Q6 | Saldo Token `RAFFLE`/`MINIGAME` yang ada ikut dikonversi ke EnakCoin? | **Ya**, semua jenis dijumlahkan menjadi EnakCoin. EnakCoin adalah mata uang untuk semua game | K1, F8, §10 | +| Q7 | Customer app melayani satu organisasi atau banyak? | **Satu nomor telepon = satu customer di satu organisasi**, sesuai `customers.phone_number` yang unik secara global | K4 | +| Q8 | Bagaimana tier (level keanggotaan, mis. Silver/Gold; tabel `tiers` sudah ada tapi belum terhubung ke customer) berhubungan dengan EnakPoint? | **Dibahas di PRD terpisah.** Arahan untuk PRD itu: tier dihitung dari **total `EARN` dalam 12 bulan terakhir**, bukan dari saldo, supaya customer tidak turun tier karena memakai, mentransfer, atau kehilangan saldo karena kedaluwarsa, dan tier tidak bisa "dibeli" lewat transfer. Ledger di PRD ini sudah mencatat `EARN`, jadi tidak ada yang perlu diubah di sini | §11 | +| Q9 | Jejak cukup per mutasi, atau harus per butir? | **Per butir**, lewat lot. EnakPoint dan EnakCoin **bisa kedaluwarsa**, dengan masa berlaku yang diatur sendiri | K9, F12, §8 | +| Q10 | Apakah bagian order yang dibayar EnakPoint tetap menghasilkan earning? | **Tidak.** Basis earning dikurangi nominal EnakPoint | F1 | +| Q11 | Berapa nilai rupiah 1 EnakPoint, dan kurs EnakCoin → EnakPoint? | **1 EnakPoint = Rp 1** dan **1 EnakCoin = 1 EnakPoint** sebagai default. Keduanya bisa diubah di setting | K3, F2, F4 | +| Q13 | Refund sebagian yang tidak habis dibagi nilai EnakPoint: sisa rupiahnya ke mana? | **Dibulatkan ke bawah**, sisanya hangus | F9 | +| Q16 | Setelah reset PIN, berapa lama transfer keluar ditahan? | **24 jam, hanya transfer.** Pembayaran dan exchange tetap bisa | F11 | +| Q17 | Parameter kunci PIN? | **5 kali salah, terkunci 30 menit** | F11 | + +### 12.2 Masih Terbuka + +Tidak ada. Semua hal yang belum diputuskan sudah dipindahkan ke catatan N1–N4 di +bawah, masing-masing dengan batas waktu. + +### 12.3 Ditunda (Catatan agar Tidak Lupa) + +Hal-hal berikut sengaja belum diputuskan. Masing-masing punya batas waktu, yaitu fase +yang tidak boleh dirilis sebelum catatan ini ditutup. + +**N1 — Pembayaran EnakPoint di kasir untuk customer tanpa aplikasi** (sebelumnya Q12) +- **Situasi:** persetujuan pembayaran di kasir saat ini hanya lewat kode bayar dari + aplikasi (F9). Customer yang tidak punya aplikasi, HP-nya mati, atau tidak ada + internet belum bisa membayar dengan EnakPoint di kasir. +- **Batasan yang harus tetap dijaga:** PIN tidak boleh diketik di layar kasir (K8). +- **Opsi yang sudah terpikir:** PIN pad atau layar yang menghadap customer; OTP ke + nomor telepon (butuh HP tapi tidak butuh aplikasi); atau memang tidak didukung. +- **Batas waktu:** tidak memblokir fase 3. Fase 3 bisa rilis tanpa jalur ini, tetapi + kasir perlu tahu apa yang harus dikatakan ke customer tanpa aplikasi. +- **Pemilik keputusan:** product owner. + +**N2 — Perlakuan akuntansi EnakPoint dan EnakCoin** (sebelumnya Q14) +- **Situasi:** EnakPoint yang dipakai membayar bukan kas masuk. Saldo yang beredar + berpotensi menjadi kewajiban. Saldo yang kedaluwarsa (F12) menjadi "breakage" yang + juga perlu dicatat. +- **Yang perlu diputuskan:** apakah EnakPoint yang dipakai dicatat sebagai beban + promosi atau pengurang liabilitas loyalitas; apakah saldo beredar dicatat sebagai + liabilitas; bagaimana breakage dari kedaluwarsa dicatat; apakah EnakCoin (yang bisa + ditukar ke EnakPoint, K3) ikut dihitung; dan apakah perlu jurnal otomatis ke modul + chart of account yang sudah ada. +- **Dampak ke sistem:** laporan payment method, laporan penjualan (penjualan kotor vs + kas masuk), dan kemungkinan jurnal otomatis. +- **Batas waktu:** sebelum fase 3 (pembayaran EnakPoint) dirilis ke outlet pertama. +- **Pemilik keputusan:** tim keuangan. + +**N3 — Tinjauan regulasi uang elektronik** (sebelumnya Q15) +- **Situasi:** EnakPoint bernilai rupiah, bisa dipakai membayar, dan bisa ditransfer + antar customer. Kombinasi ini mirip dengan uang elektronik yang diatur Bank + Indonesia. +- **Mitigasi yang sudah ada di desain:** tidak bisa ditunaikan (K7), hanya berlaku di + outlet dalam organisasi yang sama (K4), tampilan "setara potongan", bukan "saldo + rupiah", dan bisa kedaluwarsa (F12). +- **Yang perlu dicek ke legal:** apakah fitur transfer (F5) masih aman; apakah perlu + batas transfer wajib (F2); dan apa yang harus ada di Syarat & Ketentuan. +- **Batas waktu:** sebelum fase 3 (pembayaran) dan fase 4 (transfer) dirilis. +- **Pemilik keputusan:** legal. + +**N4 — Model kedaluwarsa saldo** +- **Situasi:** sudah diputuskan bahwa EnakPoint dan EnakCoin bisa kedaluwarsa dan + owner bisa mengatur sendiri (Q9). Yang belum diputuskan adalah **modelnya**. +- **Pilihan:** + + | | A. Tanggal tetap (gaya Telkomsel POIN / XL Poin) | B. Per saldo masuk (draft F12 saat ini) | + |---|---|---| + | Cara kerja | Semua saldo hangus di tanggal yang sama, mis. tiap 31 Des (1× setahun) atau tiap 30 Jun & 31 Des (2× setahun) | Tiap saldo punya tanggal sendiri, mis. 12 bulan sejak didapat | + | Mudah dipahami customer | Sangat mudah: "semua hangus 31 Desember" | Lebih rumit: "150 hangus 3 Okt, 200 hangus 18 Nov" | + | Pengingat | Satu kampanye besar menjelang tanggal hangus | Banyak pengingat kecil | + | Adil | Kurang. Saldo yang didapat sehari sebelum tanggal hangus langsung hilang. Bisa ditutup dengan **periode tanggung**, mis. saldo yang didapat < 3 bulan sebelum tanggal hangus ikut ke tanggal hangus berikutnya | Adil. Semua saldo punya umur yang sama | + | Efek bisnis | Lonjakan belanja menjelang tanggal hangus | Lebih rata | + +- **Pilihan ketiga:** keduanya didukung sebagai mode di setting, dan owner memilih. + Ini tidak mengubah struktur data. Lot, alokasi, dan `EXPIRE` tetap sama. Yang berbeda + hanya rumus `expires_at` saat lot dibuat: + - A: tanggal hangus berikutnya setelah (tanggal didapat + periode tanggung) + - B: tanggal didapat + masa berlaku +- **Usulan sementara (belum disetujui):** dukung keduanya, dengan default model A + setahun sekali tiap 31 Desember dan periode tanggung 3 bulan, karena model ini sudah + familiar bagi customer di Indonesia. +- **Pertanyaan turunan yang ikut diputuskan bersama N4:** + - Saat kedaluwarsa pertama kali diaktifkan, bagaimana dengan saldo lama yang belum + punya tanggal kedaluwarsa? Usulan: diberi masa berlaku penuh sejak tanggal aktivasi + (model B), atau ikut tanggal hangus kedua berikutnya (model A). + - EnakPoint yang dikembalikan karena refund, padahal lot asalnya sudah atau hampir + kedaluwarsa? Usulan: diberi masa berlaku minimal 7 hari sejak refund. + - Saat kedaluwarsa dinonaktifkan, apakah saldo yang sudah terjadwal kedaluwarsa ikut + dibatalkan? Usulan: tidak, hanya saldo baru yang tidak kedaluwarsa. + - Pengingat dikirim berapa hari sebelum tanggal hangus, dan berapa kali? +- **Dampak ke sistem:** tabel pengaturan di F12, rumus kedaluwarsa per lot, isi + pengingat, dan tampilan "saldo yang akan kedaluwarsa" di aplikasi. +- **Batas waktu:** sebelum fase 5 (kedaluwarsa) dikerjakan. Fase 1–4 tidak terblokir, + karena lot sudah dibuat sejak fase 1 dan dipakai oleh kedua model. +- **Pemilik keputusan:** product owner. + +--- + +## 13. Tahapan Rilis + +| Fase | Isi | Syarat rilis | +|---|---|---| +| **1. Fondasi** | Tabel wallet, ledger, dan lot; migrasi Token → EnakCoin; repository transaksional; endpoint saldo & riwayat; adjustment admin; riwayat perubahan setting | – | +| **2. Earning** | Setting outlet (F1), earning saat order lunas (F3), reversal (F10), `points_earned`/`coins_earned` di response order | – | +| **3. Pembayaran EnakPoint** | PIN customer (F11), setting organisasi (F2), payment method EnakPoint, kode bayar, bayar di kasir & app (F9), refund EnakPoint, laporan payment method | N2 dan N3 ditutup | +| **4. Pergerakan saldo** | Exchange dengan kurs (F4), transfer (F5), semua game memakai EnakCoin (F8), telusuri per butir di dashboard | N3 ditutup | +| **5. Kedaluwarsa** | Setting kedaluwarsa (F12), job kedaluwarsa, pengingat, tampilan saldo yang akan kedaluwarsa | N4 ditutup | +| **6. Lanjutan** | Penukaran reward, tier (Q8), campaign rules | – | + +Lot sudah dibuat sejak fase 1, meskipun kedaluwarsa baru aktif di fase 5. Kalau lot +baru ditambahkan belakangan, seluruh riwayat alokasi harus direkonstruksi ulang dari +ledger. + +--- + +## 14. Metrik Keberhasilan + +- 0 selisih pada job rekonsiliasi: ledger vs saldo vs lot, alokasi vs mutasi, dan + pembayaran EnakPoint vs ledger. +- 0 mutasi tanpa asal/tujuan (dijamin oleh constraint, tetapi diverifikasi di job + rekonsiliasi). +- 0 earning ganda per order, 0 pemotongan ganda per pembayaran, dan 0 kedaluwarsa ganda + per lot (dicek dari idempotency key). +- 0 refund non-EnakPoint atas pembayaran EnakPoint (dicek dari job rekonsiliasi). +- 0 lot yang lewat tanggal kedaluwarsa lebih dari 1 jam tanpa diproses. +- Persentase order lunas dengan customer terdaftar yang mendapat earning (target: 100% + untuk outlet dengan setting aktif). +- Persentase transaksi yang dibayar (sebagian) dengan EnakPoint, dan total nilai + rupiahnya per bulan. +- Jumlah EnakPoint/EnakCoin yang kedaluwarsa per bulan, dan persentase customer yang + memakai saldonya setelah menerima pengingat. +- Volume exchange dan transfer per minggu sebagai indikator adopsi. diff --git a/docs/tasks-point-coin.md b/docs/tasks-point-coin.md new file mode 100644 index 0000000..1129ad6 --- /dev/null +++ b/docs/tasks-point-coin.md @@ -0,0 +1,438 @@ +# Task Breakdown: EnakPoint & EnakCoin + +**Sumber:** [PRD EnakPoint & EnakCoin](prd-point-coin.md) +**Tanggal:** 2026-09-29 + +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. + +Konvensi kode mengikuti yang sudah ada: `migrations/` (lanjut dari `000089`), +`entities` → `repository` → `processor` → `service` → `handler` / `validator` → +`router`, dan wiring di `internal/app/app.go`. + +--- + +## Ringkasan + +| Fase | Task | Terblokir oleh catatan PRD | +|---|---|---| +| 1. Fondasi | PC-101 – PC-109 | – | +| 2. Earning | PC-201 – PC-205 | – | +| 3. Pembayaran EnakPoint | PC-301 – PC-308 | N2 (keuangan), N3 (legal) sebelum **rilis** | +| 4. Pergerakan saldo | PC-401 – PC-404 | N3 (legal) sebelum **rilis** | +| 5. Kedaluwarsa | PC-501 – PC-504 | N4 (model kedaluwarsa) sebelum **dikerjakan** | +| 6. Bersih-bersih | PC-601 – PC-602 | – | + +Catatan N2 dan N3 hanya memblokir **rilis** fase 3–4, bukan pengerjaannya. N4 +memblokir pengerjaan fase 5, karena rumus kedaluwarsanya belum ditentukan. + +``` +PC-101 ─┬─ PC-103 ── PC-104 ─┬─ PC-105 ── PC-106 + │ ├─ PC-107 + │ ├─ PC-108 + │ ├─ PC-203 ── PC-204 ── PC-205 + │ ├─ PC-305 ── PC-306 / PC-307 + │ ├─ PC-401 / PC-402 / PC-403 / PC-404 + │ └─ PC-502 ── PC-503 +PC-102 ── PC-109 ─┬─ PC-201 ── PC-202 ── PC-203 + └─ PC-302 +PC-301 ── PC-304 ── PC-305 +PC-303 ── PC-305 +``` + +--- + +## Fase 1 — Fondasi + +Semua fase lain bergantung pada fase ini. **PC-104 (wallet engine) adalah inti.** Tidak +ada kode lain yang boleh mengubah saldo tanpa lewat engine ini. + +### PC-101 · Migrasi tabel wallet, ledger, dan lot · M +- **PRD:** §8 (`customer_wallets`, `wallet_transactions`, `wallet_lots`, + `wallet_lot_allocations`), K5, K9 +- **Kerjakan:** migrasi `000090_create_wallet_tables` (up & down) dengan semua + `CHECK` constraint dan index persis seperti di §8. +- **Selesai jika:** + - Up dan down berjalan bersih di database kosong dan di salinan staging. + - Uji constraint langsung di SQL, masing-masing harus ditolak: + `PAYMENT` bercurrency `COIN`; `TRANSFER_IN` tanpa `counterparty_customer_id`; + `EARN_REVERSAL` tanpa `reverses_transaction_id`; `ADJUSTMENT` tanpa `reason`; + `EXPIRE` dengan `reference_type` selain `LOT`; saldo negatif; lot dengan + `remaining_amount > original_amount`. +- **Bergantung pada:** – + +### PC-102 · Migrasi pengaturan organisasi dan riwayat perubahan setting · S +- **PRD:** F2, `loyalty_setting_changes` di §8 +- **Kerjakan:** tabel `organization_settings` (key–value, pola sama dengan + `outlet_settings`, `UNIQUE(organization_id, key)`) dan `loyalty_setting_changes`. + Saat ini belum ada tempat menyimpan setting per organisasi. +- **Selesai jika:** up/down bersih. +- **Bergantung pada:** – + +### PC-103 · Entities dan repository wallet · M +- **PRD:** §7.1–§7.4 +- **Kerjakan:** + - Entities untuk empat tabel PC-101. + - Repository yang **selalu** memakai `DBFromContext` (berbeda dari repository + gamification yang sekarang memakai `r.db` langsung). + - `LockWallet(ctx, customerID)` dengan `SELECT … FOR UPDATE`. Membuat baris wallet + jika belum ada (`INSERT … ON CONFLICT DO NOTHING`, lalu lock). + - `LockWallets(ctx, a, b)` yang selalu mengunci berurutan berdasarkan `customer_id`. + - Update saldo bersyarat yang **mengembalikan error jika 0 baris ter-update**. + - Query lot aktif sesuai urutan K9: `expires_at NULLS LAST, created_at`. +- **Selesai jika:** test repository menunjukkan update bersyarat gagal saat saldo + kurang, dan dua goroutine yang mengunci wallet yang sama berjalan bergantian. +- **Bergantung pada:** PC-101 + +### PC-104 · Wallet engine (credit / debit / lot / idempotensi) · L +- **PRD:** K5, K6, K9, §7, §8.1 +- **Kerjakan:** processor `WalletProcessor` sebagai **satu-satunya pintu** perubahan + saldo: + - `Credit(ctx, CreditInput)`: menulis ledger, membuat lot (dengan `expires_at` dan + `origin_lot_id` dari input), dan menambah saldo. + - `Debit(ctx, DebitInput)`: mengambil dari lot sesuai urutan K9 (atau dari lot + tertentu lebih dulu, untuk reversal di F10), menulis alokasi dan ledger, lalu + mengurangi saldo. Mengembalikan daftar alokasi, supaya transfer/exchange bisa + membuat lot penerima dengan `expires_at` yang sama. + - `DebitUpTo`: mengambil sebanyak yang tersedia, untuk reversal dengan shortfall + (F10, Q3). + - Idempotensi: jika `idempotency_key` sudah ada, kembalikan hasil pertama tanpa + mutasi baru. + - Validasi di level kode untuk aturan §8.1 (tipe ↔ currency ↔ referensi wajib), + sebagai lapisan kedua di atas constraint database. + - Semua method mewajibkan caller sudah berada di dalam transaksi dan wallet sudah + dikunci. +- **Selesai jika:** unit test mencakup debit yang melewati beberapa lot, urutan lot + dengan dan tanpa `expires_at`, debit melebihi saldo (ditolak), `DebitUpTo` dengan + shortfall, idempotency key ganda, dan saldo = `SUM(ledger)` = `SUM(lot.remaining)` + setelah setiap skenario. +- **Bergantung pada:** PC-103 + +### PC-105 · Migrasi data Point & Token lama · M +- **PRD:** §10, Q6 +- **Kerjakan:** migrasi data (atau command satu kali) yang: + - Membuat wallet dari `customer_points` dan penjumlahan semua jenis + `customer_tokens`. + - Menulis ledger `MIGRATION` dan lot tanpa `expires_at` per customer per currency, + dengan rincian per jenis token di `metadata`. + - Mengganti `TOKENS` menjadi `COINS` di `campaigns.type` dan + `campaign_rules.reward_type`. +- **Selesai jika:** jumlah total Point dan Token sebelum = jumlah total saldo wallet + sesudah (diuji pada salinan data staging), dan migrasi aman dijalankan dua kali + (tidak menggandakan). +- **Bergantung pada:** PC-104 + +### PC-106 · Endpoint saldo & riwayat customer · M +- **PRD:** F6, §9 (customer app) +- **Kerjakan:** `GET /customer/wallet` dan `GET /customer/wallet/transactions` + (pagination, filter currency/tipe/tanggal). Ganti isi `/customer/points` dan + `/customer/tokens` menjadi alias yang membaca `customer_wallets`. Hapus pemakaian + `customer_points_repository` untuk saldo. +- **Selesai jika:** response menampilkan asal/tujuan setiap mutasi sesuai §8.1, dan + aplikasi lama yang memanggil `/points` / `/tokens` tetap mendapat angka yang benar. +- **Bergantung pada:** PC-105 + +### PC-107 · Wallet customer dan adjustment di dashboard · M +- **PRD:** F7 +- **Kerjakan:** `GET /marketing/customers/:id/wallet` (saldo, lot aktif, mutasi) dan + `POST /marketing/customers/:id/wallet/adjust` (alasan wajib, tidak boleh membuat saldo + negatif, tercatat `created_by_user`). +- **Selesai jika:** adjustment muncul di riwayat customer dengan nama admin dan alasan. + Adjustment yang melebihi saldo ditolak. +- **Bergantung pada:** PC-104 + +### PC-108 · Job rekonsiliasi · S +- **PRD:** §7.5 +- **Kerjakan:** query dan job terjadwal yang memeriksa semua invarian §7.5 dan + melaporkan selisih ke log dan notifikasi admin. +- **Selesai jika:** job menemukan selisih yang sengaja dibuat di data test, dan diam + saat data konsisten. +- **Bergantung pada:** PC-104 + +### PC-109 · Pembaca setting loyalitas dan riwayat perubahan · M +- **PRD:** F1, F2 (bagian mekanisme, bukan UI), §8 `loyalty_setting_changes` +- **Kerjakan:** service yang membaca setting outlet dan organisasi dengan nilai default + PRD bila key belum diisi, mengembalikan struct bertipe (bukan string mentah), dan + menulis `loyalty_setting_changes` setiap kali setting loyalitas diubah. +- **Selesai jika:** outlet tanpa setting mendapat semua default PRD, dan setiap + perubahan tercatat nilai lama, nilai baru, dan pelakunya. +- **Bergantung pada:** PC-102 + +--- + +## Fase 2 — Earning + +### PC-201 · API pengaturan loyalitas outlet · M +- **PRD:** F1 +- **Kerjakan:** `GET/PUT /outlets/:id/loyalty-settings` dengan validasi F1. Response + menyertakan **persentase cashback efektif** (`earn_value × nilai EnakPoint / + earn_per_amount`). +- **Selesai jika:** validasi menolak nilai di luar batas, dan hanya Admin/Manager yang + bisa mengubah. +- **Bergantung pada:** PC-109 + +### PC-202 · Kalkulator earning · S +- **PRD:** F1 (rumus), Q1, Q10 +- **Kerjakan:** fungsi murni `CalculateEarning(order, pointPaidAmount, settings)` yang + mengembalikan Point, Coin, basis, dan snapshot setting. +- **Selesai jika:** unit test mencakup contoh di PRD (Rp 87.500 → 875 Point, 3 Coin; + dengan Rp 20.000 dibayar EnakPoint → 675 Point, 2 Coin), basis di bawah minimum, + `max_per_order`, setting nonaktif, dan pajak tidak ikut dihitung. +- **Bergantung pada:** PC-201 + +### PC-203 · Earning saat order lunas · L +- **PRD:** F3 +- **Kerjakan:** + - **Satukan titik "order menjadi lunas".** Saat ini `payment_status = completed` + di-set di empat tempat: `OrderProcessorImpl.UpdateOrder`, + `OrderProcessorImpl.updateOrderStatus`, dan dua tempat di `split_bill_processor.go`. + Buat satu hook `onOrderPaid(orderID)` yang dipanggil dari keempatnya. + - Hook menjalankan earning **setelah** transaksi pembayaran commit, sehingga + kegagalan earning tidak menggagalkan pembayaran. + - Idempotency key `earn:{order_id}:{currency}`. + - Lewati order tanpa customer, customer default, atau customer nonaktif. + - **Jaring pengaman:** job yang mencari order lunas beberapa hari terakhir yang belum + punya `EARN` (dan seharusnya punya), lalu menjalankan ulang earning. +- **Selesai jika:** test untuk pembayaran penuh, split bill (lunas di pembayaran + terakhir), self-order, dan pemanggilan ganda (hasilnya tetap satu earning). Earning + yang sengaja digagalkan tidak menggagalkan pembayaran dan terambil oleh job. +- **Bergantung pada:** PC-104, PC-202 + +### PC-204 · Reversal earning saat void / refund · M +- **PRD:** F10, Q3 +- **Kerjakan:** panggil reversal dari `VoidOrder`, `RefundOrder`, dan `RefundPayment`. + Ambil pertama dari lot `EARN` order tersebut, lalu lot lain. Gunakan `DebitUpTo` dan + catat `shortfall`. Refund tidak pernah diblokir. +- **Selesai jika:** test untuk void penuh, refund sebagian (proporsional, akumulasi tidak + melebihi earning), dan saldo yang sudah terpakai (shortfall tercatat, saldo 0). +- **Bergantung pada:** PC-203 + +### PC-205 · Tampilkan earning di order dan struk · S +- **PRD:** F3 +- **Kerjakan:** tambah `points_earned` dan `coins_earned` ke response order dan data + struk. +- **Selesai jika:** nilai sama dengan baris `EARN` di ledger, dan bernilai 0 untuk order + tanpa earning. +- **Bergantung pada:** PC-203 + +--- + +## Fase 3 — Pembayaran EnakPoint + +> Boleh dikerjakan sekarang. **Tidak boleh dirilis** sebelum catatan N2 (keuangan) dan +> N3 (legal) ditutup. + +### PC-301 · PIN customer · L +- **PRD:** K8, F11, Q16, Q17 +- **Kerjakan:** + - Migrasi kolom PIN di `customers` dan tabel `customer_security_events`. + - Purpose OTP baru `pin_setup` dan `pin_reset` di `OtpProcessor`. + - Endpoint `/customer/pin/*` (status, OTP, buat, ganti, reset). + - Hash bcrypt, tolak PIN lemah (digit sama, berurutan, tanggal lahir). + - Kunci 30 menit setelah 5 kali salah, dengan penghitung di database. + - Tahan transfer keluar 24 jam setelah reset. + - `VerifyPin(ctx, customerID, pin)` untuk dipakai task lain, dengan error + `PIN_NOT_SET`, `PIN_INVALID`, `PIN_LOCKED`, `TRANSFER_BLOCKED`. + - Hapus PIN oleh admin: `DELETE /marketing/customers/:id/pin`, dan + `GET /marketing/customers/:id/security-events`. + - PIN tidak pernah muncul di log (termasuk log request body). +- **Selesai jika:** test untuk semua aturan di atas, termasuk 5 kali salah lalu PIN + benar tetap ditolak selama terkunci, dan reset OTP membuka kunci. +- **Bergantung pada:** – + +### PC-302 · API pengaturan loyalitas organisasi · S +- **PRD:** F2 +- **Kerjakan:** `GET/PUT /marketing/loyalty-settings` untuk nilai EnakPoint, kurs + exchange, dan batas transfer, serta `GET /marketing/loyalty-settings/history`. + Sebelum menyimpan perubahan nilai EnakPoint atau kurs, response preview menampilkan + total saldo beredar dan nilai rupiahnya sebelum/sesudah. +- **Selesai jika:** perubahan tercatat di riwayat, dan transaksi lama tetap memakai nilai + yang dibekukan. +- **Bergantung pada:** PC-109 + +### PC-303 · Payment method EnakPoint · M +- **PRD:** F9 (payment method), §8 (perubahan `payments`), §10.5 +- **Kerjakan:** + - Tambah tipe `point` ke `PaymentMethodType` dan validator (`oneof=cash card + digital_wallet point`). + - Migrasi kolom `points_used` dan `point_value` di `payments`. + - Buat payment method sistem "EnakPoint" untuk setiap organisasi yang sudah ada, dan + otomatis untuk organisasi baru. + - Tolak hapus / ubah tipe method sistem. + - Sembunyikan method ini di kasir jika outlet tidak mengaktifkan `accept_payment`. +- **Selesai jika:** setiap organisasi punya tepat satu method EnakPoint yang tidak bisa + dihapus. +- **Bergantung pada:** – + +### PC-304 · Kode bayar sekali pakai · M +- **PRD:** F9 (persetujuan customer), K8 +- **Kerjakan:** `POST /customer/wallet/payment-code` (butuh PIN) menghasilkan kode 6 + digit dan QR yang berlaku 2 menit, disimpan di Redis dengan TTL dan diambil + sekali-pakai (`GETDEL`). Kode terikat ke `customer_id`. +- **Selesai jika:** kode kedaluwarsa, kode yang sudah dipakai, dan kode milik customer + lain semuanya ditolak. +- **Bergantung pada:** PC-301 + +### PC-305 · Bayar EnakPoint di kasir · L +- **PRD:** F9 (perhitungan, pencatatan), K7 +- **Kerjakan:** + - `GET /orders/:id/point-payment/preview`. + - Cabang method `point` di `CreatePayment`: validasi kode bayar, cocokkan customer + order, hitung `maks_point`, lalu lakukan langkah 1–6 F9 dalam **satu transaksi** + bersama pembuatan baris `payments`. + - Idempotency `payment:{payment_id}`. + - Tolak jika `nominal_rupiah > remaining_amount` (tidak ada kembalian). +- **Selesai jika:** test untuk pembayaran penuh, sebagian + tunai (split), batas + `max_payment_percent`, order walk-in (ditolak), kode salah (ditolak), dan dua + pembayaran bersamaan untuk customer yang sama (saldo tidak terpakai dua kali). + Earning (PC-203) menghitung basis tanpa bagian EnakPoint. +- **Bergantung pada:** PC-104, PC-303, PC-304 + +### PC-306 · Bayar EnakPoint dari app / self-order · M +- **PRD:** F9 +- **Kerjakan:** `POST /customer/orders/:id/pay-with-points` (butuh PIN, hanya untuk + order milik customer itu sendiri), memakai logika yang sama dengan PC-305. +- **Selesai jika:** customer tidak bisa membayar order milik customer lain. +- **Bergantung pada:** PC-305 + +### PC-307 · Refund pembayaran EnakPoint · M +- **PRD:** F9 (void/refund), K7, Q13 +- **Kerjakan:** `PAYMENT_REFUND` di jalur void dan refund memakai `point_value` yang + dibekukan, dengan pembulatan ke bawah. Kembalikan ke lot dengan `expires_at` asal + (aturan perpanjangan 7 hari mengikuti N4, jadi untuk sekarang cukup pakai tanggal + asal). **Tolak** permintaan refund bagian EnakPoint lewat method lain. +- **Selesai jika:** test untuk void penuh, refund sebagian, perubahan nilai EnakPoint di + antara bayar dan refund (jumlah EnakPoint yang kembali tetap sama), dan percobaan + refund tunai atas pembayaran EnakPoint (ditolak). +- **Bergantung pada:** PC-305 + +### PC-308 · EnakPoint di laporan · S +- **PRD:** F9 (laporan) +- **Kerjakan:** laporan per payment method menampilkan EnakPoint terpisah dan tidak + menghitungnya sebagai kas masuk. Cek laporan analytics yang menjumlahkan pembayaran. +- **Selesai jika:** total kas masuk di laporan tidak berubah saat sebagian order dibayar + EnakPoint. Perlakuan akuntansi lanjutan menunggu N2. +- **Bergantung pada:** PC-305 + +--- + +## Fase 4 — Pergerakan Saldo + +> Boleh dikerjakan sekarang. Transfer (PC-402) **tidak boleh dirilis** sebelum N3 +> (legal) ditutup. + +### PC-401 · Exchange EnakCoin → EnakPoint · M +- **PRD:** F4, K3 +- **Kerjakan:** `GET /customer/wallet/exchange/preview` dan + `POST /customer/wallet/exchange` (butuh PIN, `Idempotency-Key`). Jumlah harus + kelipatan `coin_amount`. Kurs dibekukan di metadata. Lot EnakPoint kedaluwarsa pada + `min(lot EnakCoin asal, sekarang + masa berlaku EnakPoint)`. +- **Selesai jika:** test untuk kurs default 1:1, kurs 10:3, jumlah bukan kelipatan + (ditolak), dan `expires_at` lot hasil exchange tidak pernah lebih lama dari lot asal. +- **Bergantung pada:** PC-104, PC-301, PC-302 + +### PC-402 · Transfer antar customer · L +- **PRD:** F5, Q4, Q16 +- **Kerjakan:** `GET /customer/wallet/transfer/recipient` (nama disamarkan) dan + `POST /customer/wallet/transfer` (butuh PIN, `Idempotency-Key`). Kunci dua wallet + berurutan, cek batas organisasi (per transaksi dan harian), cek + `transfer_blocked_until`. Lot penerima mewarisi `expires_at` dan `origin_lot_id` dari + lot pengirim. Kirim notifikasi push ke penerima. +- **Selesai jika:** test untuk transfer ke diri sendiri / customer lain organisasi / + customer default (semua ditolak), batas harian, transfer dua arah bersamaan (tidak + deadlock), dan `expires_at` di penerima sama persis dengan pengirim. +- **Bergantung pada:** PC-104, PC-301, PC-302 + +### PC-403 · Semua game memakai EnakCoin · M +- **PRD:** F8, K1 +- **Kerjakan:** + - `GamePlayProcessor.PlayGame` memotong EnakCoin lewat wallet engine sesuai + `games.metadata.coin_cost`. + - Seluruh permainan dalam satu transaksi: ubah repository game, game play, dan game + prize ke `DBFromContext`. Gagal mengurangi stok hadiah membatalkan permainan (saat + ini hanya di-`Printf`), dan rollback manual `AddTokens` dihapus. + - Rename `game_plays.token_used` menjadi `coins_used`. +- **Selesai jika:** test untuk saldo kurang (ditolak, tidak ada `game_plays` yang + tercatat), stok hadiah gagal (semua dibatalkan), dan `GAME_SPEND` menunjuk + `game_plays.id`. +- **Bergantung pada:** PC-104 + +### PC-404 · Telusuri per butir di dashboard · S +- **PRD:** F7, §8.1 (per butir) +- **Kerjakan:** `GET /marketing/wallet-transactions/:id/trace` yang mengembalikan + alokasi lot dan rantai `origin_lot_id` sampai ke lot pertama. +- **Selesai jika:** contoh di §8 (A transfer 120 ke B, B bayar 30) menelusuri ke order + #ORD-1 milik A. +- **Bergantung pada:** PC-402 + +--- + +## Fase 5 — Kedaluwarsa + +> **Jangan dikerjakan sebelum catatan N4 (model kedaluwarsa) diputuskan.** Struktur lot +> sudah ada sejak PC-101, jadi yang tersisa hanya rumus, job, dan tampilan. + +### PC-501 · Setting kedaluwarsa · M +- **PRD:** F12 (pengaturan), N4 +- **Kerjakan:** key setting sesuai model yang dipilih di N4, dengan validasi dan preview + "saldo yang didapat hari ini kedaluwarsa pada …". +- **Bergantung pada:** PC-302, **N4** + +### PC-502 · Hitung `expires_at` saat lot dibuat · M +- **PRD:** F12 (tabel kedaluwarsa per lot), N4 +- **Kerjakan:** satu fungsi `ComputeExpiry(currency, receivedAt, settings)` yang dipakai + oleh `EARN` dan `ADJUSTMENT`, serta aturan aktivasi pertama untuk lot lama (termasuk + lot `MIGRATION`). +- **Bergantung pada:** PC-104, PC-501 + +### PC-503 · Job kedaluwarsa · M +- **PRD:** F12 (proses kedaluwarsa) +- **Kerjakan:** job per jam yang memproses lot lewat tanggal dengan `EXPIRE` + (idempotency `expire:{lot_id}`) di bawah lock wallet. + - **Jangan menyalin pola `OmsetMilestoneScheduler`.** Scheduler itu menyimpan state di + memori, dan menurut komentarnya sendiri bisa mengirim ulang notifikasi setelah + restart. Job ini harus aman dijalankan di banyak instance sekaligus: pilih lot + dengan `FOR UPDATE SKIP LOCKED`, dan andalkan idempotency key. +- **Selesai jika:** dua instance yang berjalan bersamaan tidak menghanguskan lot yang + sama dua kali, dan tidak ada lot yang lewat tanggal lebih dari 1 jam tanpa diproses. +- **Bergantung pada:** PC-502 + +### PC-504 · Pengingat dan tampilan saldo yang akan kedaluwarsa · M +- **PRD:** F6 (`/wallet/expiring`), F12 (pengingat) +- **Kerjakan:** endpoint `GET /customer/wallet/expiring`, field saldo kedaluwarsa + terdekat di `/wallet`, dan notifikasi pengingat yang dikelompokkan per tanggal. + Jadwal pengingat mengikuti N4. +- **Bergantung pada:** PC-503 + +--- + +## Fase 6 — Bersih-bersih + +### PC-601 · Hapus tabel dan kode lama · S +- **PRD:** §10.7 +- **Kerjakan:** satu rilis setelah PC-105 berjalan di production, drop + `customer_points` dan `customer_tokens`. Hapus `CustomerPointsProcessor` dan + `CustomerTokensProcessor` beserta stub `not implemented`, rute yang di-comment di + `router.go`, dan alias `/customer/points` / `/customer/tokens` setelah aplikasi + diperbarui. +- **Bergantung pada:** PC-106, PC-403, dan konfirmasi bahwa aplikasi sudah tidak + memanggil endpoint lama. + +### PC-602 · Dokumentasi integrasi · S +- **Kerjakan:** panduan integrasi untuk tim aplikasi dan POS (seperti + `integration-weight-based-products.md`): endpoint, kode error PIN, alur kode bayar, dan + contoh request/response. +- **Bergantung pada:** PC-305, PC-402 + +--- + +## Yang Bisa Dimulai Sekarang + +Bisa dikerjakan paralel tanpa menunggu apa pun: +- **PC-101** (migrasi wallet) → langsung lanjut **PC-103**, lalu **PC-104** +- **PC-102** (setting organisasi) → **PC-109** +- **PC-301** (PIN customer) +- **PC-303** (payment method EnakPoint) + +PC-104 adalah jalur kritis: hampir semua task lain menunggunya. diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index 4ce5f20..b95b492 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -121,7 +121,7 @@ type OrderItemResponse struct { Status string `json:"status"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - PrinterType string `json:"printer_type"` + PrinterTypes []string `json:"printer_types"` PrintToChecker bool `json:"print_to_checker"` PaidQuantity int `json:"paid_quantity"` } diff --git a/internal/contract/product_contract.go b/internal/contract/product_contract.go index 90a90ea..f606dfd 100644 --- a/internal/contract/product_contract.go +++ b/internal/contract/product_contract.go @@ -16,7 +16,7 @@ type CreateProductRequest struct { Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"` BusinessType *string `json:"business_type,omitempty"` ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` - PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` + PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"` PrintToChecker *bool `json:"print_to_checker,omitempty"` UnitID *uuid.UUID `json:"unit_id,omitempty"` SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"` @@ -38,7 +38,7 @@ type UpdateProductRequest struct { Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"` BusinessType *string `json:"business_type,omitempty"` ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"` - PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"` + PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"` // Replaces the whole list when sent PrintToChecker *bool `json:"print_to_checker,omitempty"` UnitID *uuid.UUID `json:"unit_id,omitempty"` SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"` @@ -76,7 +76,7 @@ type ProductResponse struct { Cost float64 `json:"cost"` BusinessType string `json:"business_type"` ImageURL *string `json:"image_url"` - PrinterType string `json:"printer_type"` + PrinterTypes []string `json:"printer_types"` UnitID *uuid.UUID `json:"unit_id,omitempty"` SellBy string `json:"sell_by"` PrintToChecker bool `json:"print_to_checker"` diff --git a/internal/entities/product.go b/internal/entities/product.go index 22d3f6d..ef7901a 100644 --- a/internal/entities/product.go +++ b/internal/entities/product.go @@ -1,6 +1,7 @@ package entities import ( + "strings" "time" "github.com/google/uuid" @@ -8,24 +9,26 @@ import ( ) type Product 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"` - CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"` - SKU *string `gorm:"size:100;index" json:"sku"` - Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` - Description *string `gorm:"type:text" json:"description"` - Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"` - Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"` - BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"` - ImageURL *string `gorm:"size:500" json:"image_url"` - PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"` - UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"` - SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"` - HasIngredients bool `gorm:"default:false" json:"has_ingredients"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - IsActive bool `gorm:"default:true" json:"is_active"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + 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"` + CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"` + SKU *string `gorm:"size:100;index" json:"sku"` + Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` + Description *string `gorm:"type:text" json:"description"` + Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"` + Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"` + BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"` + ImageURL *string `gorm:"size:500" json:"image_url"` + // PrinterTypes are the stations the product is printed at. Set them through + // SetPrinterTypes. + PrinterTypes StringSlice `gorm:"type:jsonb;not null;default:'[\"kitchen\"]'" json:"printer_types"` + UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"` + SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"` + HasIngredients bool `gorm:"default:false" json:"has_ingredients"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` @@ -48,6 +51,32 @@ func (Product) TableName() string { return "products" } +// SetPrinterTypes records the stations the product is printed at, in order, dropping +// blanks and repeats. +func (p *Product) SetPrinterTypes(printerTypes []string) { + cleaned := StringSlice{} + seen := make(map[string]bool, len(printerTypes)) + for _, printerType := range printerTypes { + printerType = strings.TrimSpace(printerType) + if printerType == "" || seen[printerType] { + continue + } + seen[printerType] = true + cleaned = append(cleaned, printerType) + } + + p.PrinterTypes = cleaned +} + +// GetPrinterTypes returns the stations the product is printed at, never nil, so a +// response always carries a list. A product that was not loaded has none. +func (p *Product) GetPrinterTypes() []string { + if p.PrinterTypes == nil { + return []string{} + } + return []string(p.PrinterTypes) +} + type ProductVariant struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"` diff --git a/internal/mappers/order_mapper.go b/internal/mappers/order_mapper.go index 8a12404..e4b5803 100644 --- a/internal/mappers/order_mapper.go +++ b/internal/mappers/order_mapper.go @@ -149,7 +149,7 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo Status: constants.OrderItemStatus(item.Status), CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, - PrinterType: item.Product.PrinterType, + PrinterTypes: item.Product.GetPrinterTypes(), PrintToChecker: printToChecker, } diff --git a/internal/mappers/product_mapper.go b/internal/mappers/product_mapper.go index c9d1aa4..572ed96 100644 --- a/internal/mappers/product_mapper.go +++ b/internal/mappers/product_mapper.go @@ -24,7 +24,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product { Cost: entity.Cost, BusinessType: constants.BusinessType(entity.BusinessType), ImageURL: entity.ImageURL, - PrinterType: entity.PrinterType, + PrinterTypes: entity.GetPrinterTypes(), UnitID: entity.UnitID, SellBy: entity.SellBy, HasIngredients: entity.HasIngredients, @@ -51,7 +51,7 @@ func ProductModelToEntity(model *models.Product) *entities.Product { Cost: model.Cost, BusinessType: string(model.BusinessType), ImageURL: model.ImageURL, - PrinterType: model.PrinterType, + PrinterTypes: entities.StringSlice(model.PrinterTypes), UnitID: model.UnitID, SellBy: model.SellBy, HasIngredients: model.HasIngredients, @@ -77,11 +77,6 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr businessType = string(req.BusinessType) } - printerType := "kitchen" - if req.PrinterType != nil && *req.PrinterType != "" { - printerType = *req.PrinterType - } - sellBy := constants.SellByUnit if constants.IsValidSellBy(req.SellBy) { sellBy = req.SellBy @@ -92,7 +87,7 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr metadata = entities.Metadata(req.Metadata) } - return &entities.Product{ + product := &entities.Product{ OrganizationID: req.OrganizationID, CategoryID: req.CategoryID, SKU: req.SKU, @@ -102,12 +97,18 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr Cost: cost, BusinessType: businessType, ImageURL: req.ImageURL, - PrinterType: printerType, UnitID: req.UnitID, SellBy: sellBy, Metadata: metadata, IsActive: true, // Default to active } + + product.SetPrinterTypes(req.PrinterTypes) + if len(product.PrinterTypes) == 0 { + product.SetPrinterTypes([]string{"kitchen"}) + } + + return product } func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse { @@ -152,7 +153,7 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse { Cost: entity.Cost, BusinessType: constants.BusinessType(entity.BusinessType), ImageURL: entity.ImageURL, - PrinterType: entity.PrinterType, + PrinterTypes: entity.GetPrinterTypes(), UnitID: entity.UnitID, SellBy: entity.SellBy, Metadata: map[string]interface{}(entity.Metadata), @@ -196,8 +197,8 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update entity.ImageURL = req.ImageURL } - if req.PrinterType != nil { - entity.PrinterType = *req.PrinterType + if req.PrinterTypes != nil { + entity.SetPrinterTypes(req.PrinterTypes) } if req.UnitID != nil { diff --git a/internal/mappers/product_mapper_printer_types_test.go b/internal/mappers/product_mapper_printer_types_test.go new file mode 100644 index 0000000..cd18156 --- /dev/null +++ b/internal/mappers/product_mapper_printer_types_test.go @@ -0,0 +1,102 @@ +package mappers + +import ( + "testing" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestCreateProductRequestToEntityPrinterTypes(t *testing.T) { + tests := []struct { + name string + printerTypes []string + want []string + }{ + { + name: "nothing sent prints in the kitchen", + want: []string{"kitchen"}, + }, + { + name: "a meal-and-drink package prints in the kitchen and the bar", + printerTypes: []string{"kitchen", "bar"}, + want: []string{"kitchen", "bar"}, + }, + { + name: "blanks and repeats are dropped", + printerTypes: []string{" kitchen ", "", "kitchen", "bar"}, + want: []string{"kitchen", "bar"}, + }, + { + name: "a list of only blanks falls back to the kitchen", + printerTypes: []string{" ", ""}, + want: []string{"kitchen"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + product := CreateProductRequestToEntity(&models.CreateProductRequest{ + OrganizationID: uuid.New(), + CategoryID: uuid.New(), + Name: "Paket Makan Minum", + PrinterTypes: tt.printerTypes, + }) + + assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes) + }) + } +} + +func TestUpdateProductEntityFromRequestPrinterTypes(t *testing.T) { + tests := []struct { + name string + printerTypes []string + want []string + }{ + { + name: "a save that does not send printers keeps them", + want: []string{"kitchen", "bar"}, + }, + { + name: "printer_types replaces the whole list", + printerTypes: []string{"bar"}, + want: []string{"bar"}, + }, + { + name: "an empty printer_types prints nowhere", + printerTypes: []string{}, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + product := &entities.Product{} + product.SetPrinterTypes([]string{"kitchen", "bar"}) + + UpdateProductEntityFromRequest(product, &models.UpdateProductRequest{PrinterTypes: tt.printerTypes}) + + assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes) + }) + } +} + +func TestOrderItemEntityToResponsePrinterTypes(t *testing.T) { + product := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"} + product.SetPrinterTypes([]string{"kitchen", "bar"}) + + response := OrderItemEntityToResponse(&entities.OrderItem{ProductID: product.ID, Product: product}, uuid.New()) + + assert.Equal(t, []string{"kitchen", "bar"}, response.PrinterTypes) +} + +func TestProductEntityToResponseAlwaysHasAList(t *testing.T) { + // The POS reads printer_types as a list, so a product without one sends [] not null. + response := ProductEntityToResponse(&entities.Product{}) + + assert.Equal(t, []string{}, response.PrinterTypes) +} diff --git a/internal/models/order.go b/internal/models/order.go index d15278f..dcb584e 100644 --- a/internal/models/order.go +++ b/internal/models/order.go @@ -218,7 +218,7 @@ type OrderItemResponse struct { Status constants.OrderItemStatus CreatedAt time.Time UpdatedAt time.Time - PrinterType string + PrinterTypes []string PrintToChecker bool PaidQuantity int } diff --git a/internal/models/product.go b/internal/models/product.go index 1d2142b..1580715 100644 --- a/internal/models/product.go +++ b/internal/models/product.go @@ -18,7 +18,7 @@ type Product struct { Cost float64 BusinessType constants.BusinessType ImageURL *string - PrinterType string + PrinterTypes []string SellBy string UnitID *uuid.UUID HasIngredients bool @@ -50,7 +50,7 @@ type CreateProductRequest struct { Cost float64 `validate:"min=0"` BusinessType constants.BusinessType `validate:"required"` ImageURL *string `validate:"omitempty,max=500"` - PrinterType *string `validate:"omitempty,max=50"` + PrinterTypes []string `validate:"omitempty,dive,max=50"` PrintToChecker *bool `validate:"omitempty"` UnitID *uuid.UUID `validate:"omitempty"` SellBy string `validate:"omitempty,oneof=unit weight"` @@ -72,7 +72,7 @@ type UpdateProductRequest struct { Price *float64 `validate:"omitempty,min=0"` Cost *float64 `validate:"omitempty,min=0"` ImageURL *string `validate:"omitempty,max=500"` - PrinterType *string `validate:"omitempty,max=50"` + PrinterTypes []string `validate:"omitempty,dive,max=50"` // Replaces the whole list when not nil PrintToChecker *bool `validate:"omitempty"` UnitID *uuid.UUID `validate:"omitempty"` SellBy *string `validate:"omitempty,oneof=unit weight"` @@ -112,7 +112,7 @@ type ProductResponse struct { Cost float64 BusinessType constants.BusinessType ImageURL *string - PrinterType string + PrinterTypes []string SellBy string PrintToChecker bool UnitID *uuid.UUID diff --git a/internal/processor/order_add_items_test.go b/internal/processor/order_add_items_test.go new file mode 100644 index 0000000..550f1a5 --- /dev/null +++ b/internal/processor/order_add_items_test.go @@ -0,0 +1,46 @@ +package processor + +import ( + "testing" + + "apskel-pos-be/internal/entities" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The items AddToOrder creates have no product loaded, so their responses used to go out +// without a name or a printer, and the POS could not print them. +func TestAddedItemsFromOrderUsesTheReloadedProduct(t *testing.T) { + outletID := uuid.New() + + paket := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"} + paket.SetPrinterTypes([]string{"kitchen", "bar"}) + paket.ProductOutletPrices = []entities.ProductOutletPrice{{OutletID: outletID, PrintToChecker: false}} + esTeh := entities.Product{ID: uuid.New(), Name: "Es Teh"} + esTeh.SetPrinterTypes([]string{"bar"}) + + existing := entities.OrderItem{ID: uuid.New(), ProductID: esTeh.ID, Product: esTeh} + addedPaket := &entities.OrderItem{ID: uuid.New(), ProductID: paket.ID} + addedEsTeh := &entities.OrderItem{ID: uuid.New(), ProductID: esTeh.ID} + + order := &entities.Order{ + OutletID: outletID, + OrderItems: []entities.OrderItem{ + existing, + {ID: addedEsTeh.ID, ProductID: esTeh.ID, Product: esTeh}, + {ID: addedPaket.ID, ProductID: paket.ID, Product: paket}, + }, + } + + responses := addedItemsFromOrder([]*entities.OrderItem{addedPaket, addedEsTeh}, order) + + require.Len(t, responses, 2, "only the added items, not the ones already on the order") + assert.Equal(t, addedPaket.ID, responses[0].ID, "in the order they were requested") + assert.Equal(t, "Paket Makan Minum", responses[0].ProductName) + assert.Equal(t, []string{"kitchen", "bar"}, responses[0].PrinterTypes) + assert.False(t, responses[0].PrintToChecker, "the outlet's print_to_checker is loaded too") + assert.Equal(t, addedEsTeh.ID, responses[1].ID) + assert.Equal(t, []string{"bar"}, responses[1].PrinterTypes) +} diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 60ad0f4..0afc945 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -568,16 +568,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, return nil, fmt.Errorf("failed to update order: %w", err) } - var addedItemResponses []models.OrderItemResponse for _, orderItem := range addedOrderItems { if err := p.orderItemRepo.Create(ctx, orderItem); err != nil { return nil, fmt.Errorf("failed to create order item: %w", err) } - - itemResponse := mappers.OrderItemEntityToResponse(orderItem, order.OutletID) - if itemResponse != nil { - addedItemResponses = append(addedItemResponses, *itemResponse) - } } orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID) @@ -585,6 +579,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, return nil, fmt.Errorf("failed to retrieve updated order: %w", err) } + // The items just created carry no product, so take them from the reloaded order: the + // POS prints added items from this response and needs their name and printers. + addedItemResponses := addedItemsFromOrder(addedOrderItems, orderWithRelations) + updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations) p.attachEarnings(ctx, updatedOrderResponse) @@ -596,6 +594,26 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, }, nil } +// addedItemsFromOrder maps the added items in the order they were requested, using their +// copies in the reloaded order, which have the product and its outlet prices loaded. +func addedItemsFromOrder(added []*entities.OrderItem, order *entities.Order) []models.OrderItemResponse { + reloaded := make(map[uuid.UUID]*entities.OrderItem, len(order.OrderItems)) + for i := range order.OrderItems { + reloaded[order.OrderItems[i].ID] = &order.OrderItems[i] + } + + var responses []models.OrderItemResponse + for _, item := range added { + if loaded, ok := reloaded[item.ID]; ok { + item = loaded + } + if response := mappers.OrderItemEntityToResponse(item, order.OutletID); response != nil { + responses = append(responses, *response) + } + } + return responses +} + func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) { // Get existing order order, err := p.orderRepo.GetByID(ctx, id) diff --git a/internal/processor/product_recipe_processor.go b/internal/processor/product_recipe_processor.go index 5f63fa1..924b4e2 100644 --- a/internal/processor/product_recipe_processor.go +++ b/internal/processor/product_recipe_processor.go @@ -199,7 +199,7 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe Cost: entity.Product.Cost, BusinessType: string(entity.Product.BusinessType), ImageURL: entity.Product.ImageURL, - PrinterType: entity.Product.PrinterType, + PrinterTypes: entity.Product.GetPrinterTypes(), Metadata: entity.Product.Metadata, IsActive: entity.Product.IsActive, CreatedAt: entity.Product.CreatedAt, diff --git a/internal/repository/product_ingredient_repository.go b/internal/repository/product_ingredient_repository.go index b81579d..e55481a 100644 --- a/internal/repository/product_ingredient_repository.go +++ b/internal/repository/product_ingredient_repository.go @@ -39,7 +39,7 @@ func (r *ProductIngredientRepository) Create(ctx context.Context, productIngredi func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.ProductIngredient, error) { query := ` SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at, - p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at, + p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at, i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at FROM product_ingredients pi LEFT JOIN products p ON pi.product_id = p.id @@ -70,7 +70,7 @@ func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizat &product.Cost, &product.BusinessType, &product.ImageURL, - &product.PrinterType, + &product.PrinterTypes, &product.UnitID, &product.HasIngredients, &product.Metadata, @@ -103,7 +103,7 @@ func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizat func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, productID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) { query := ` SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at, - p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at, + p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at, i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at FROM product_ingredients pi LEFT JOIN products p ON pi.product_id = p.id @@ -143,7 +143,7 @@ func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, produc &product.Cost, &product.BusinessType, &product.ImageURL, - &product.PrinterType, + &product.PrinterTypes, &product.UnitID, &product.HasIngredients, &product.Metadata, @@ -178,7 +178,7 @@ func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, produc func (r *ProductIngredientRepository) GetByIngredientID(ctx context.Context, ingredientID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) { query := ` SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at, - p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at, + p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at, i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at FROM product_ingredients pi LEFT JOIN products p ON pi.product_id = p.id @@ -218,7 +218,7 @@ func (r *ProductIngredientRepository) GetByIngredientID(ctx context.Context, ing &product.Cost, &product.BusinessType, &product.ImageURL, - &product.PrinterType, + &product.PrinterTypes, &product.UnitID, &product.HasIngredients, &product.Metadata, diff --git a/internal/repository/product_repository_printer_types_test.go b/internal/repository/product_repository_printer_types_test.go new file mode 100644 index 0000000..63b5c3b --- /dev/null +++ b/internal/repository/product_repository_printer_types_test.go @@ -0,0 +1,48 @@ +package repository + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/entities" +) + +// Needs TEST_DATABASE_URL pointing at a migrated database; see walletTestDB. +func TestProductPrinterTypes_AgainstPostgres(t *testing.T) { + db := walletTestDB(t) + ctx := context.Background() + + org, category := uuid.New(), uuid.New() + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'printer test', 'basic')`, org).Error) + require.NoError(t, db.Exec(`INSERT INTO categories (id, organization_id, name) VALUES (?, ?, 'Paket')`, category, org).Error) + t.Cleanup(func() { + db.Exec(`DELETE FROM products WHERE organization_id = ?`, org) + db.Exec(`DELETE FROM categories WHERE id = ?`, category) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + + repo := NewProductRepositoryImpl(db) + product := &entities.Product{OrganizationID: org, CategoryID: category, Name: "Paket Makan Minum", Price: 25000} + product.SetPrinterTypes([]string{"kitchen", "bar"}) + require.NoError(t, repo.Create(ctx, product)) + + stored, err := repo.GetByID(ctx, product.ID) + require.NoError(t, err) + assert.Equal(t, entities.StringSlice{"kitchen", "bar"}, stored.PrinterTypes) + + stored.SetPrinterTypes([]string{"bar"}) + require.NoError(t, repo.Update(ctx, stored)) + + var printerTypes string + require.NoError(t, db.Raw(`SELECT printer_types::text FROM products WHERE id = ?`, product.ID).Scan(&printerTypes).Error) + assert.Equal(t, `["bar"]`, printerTypes) + + // The old single column is gone. + var columns int64 + require.NoError(t, db.Raw(`SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'printer_type'`).Scan(&columns).Error) + assert.Zero(t, columns) +} diff --git a/internal/transformer/inventory_transformer.go b/internal/transformer/inventory_transformer.go index 4825b2c..6d39c63 100644 --- a/internal/transformer/inventory_transformer.go +++ b/internal/transformer/inventory_transformer.go @@ -44,8 +44,9 @@ func InventoryModelResponseToResponse(inv *models.InventoryResponse) *contract.I IsLowStock: inv.IsLowStock, UpdatedAt: inv.UpdatedAt, Product: &contract.ProductResponse{ - ID: inv.ProductID, - Name: inv.ProductName, + ID: inv.ProductID, + Name: inv.ProductName, + PrinterTypes: []string{}, }, } } diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index 2ed1194..5c7c8c0 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -118,7 +118,7 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse { Status: string(item.Status), CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, - PrinterType: item.PrinterType, + PrinterTypes: item.PrinterTypes, PrintToChecker: item.PrintToChecker, PaidQuantity: item.PaidQuantity, } @@ -195,6 +195,7 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO Status: string(item.Status), CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, + PrinterTypes: item.PrinterTypes, PrintToChecker: item.PrintToChecker, } } diff --git a/internal/transformer/order_transformer_printer_types_test.go b/internal/transformer/order_transformer_printer_types_test.go new file mode 100644 index 0000000..a07b88e --- /dev/null +++ b/internal/transformer/order_transformer_printer_types_test.go @@ -0,0 +1,50 @@ +package transformer + +import ( + "encoding/json" + "testing" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The POS prints items added to an open order from this response, so each one has to +// say where it is printed. +func TestAddToOrderModelToContractCarriesPrinters(t *testing.T) { + result := AddToOrderModelToContract(&models.AddToOrderResponse{ + AddedItems: []models.OrderItemResponse{{PrinterTypes: []string{"kitchen", "bar"}}}, + }) + + require.Len(t, result.AddedItems, 1) + assert.Equal(t, []string{"kitchen", "bar"}, result.AddedItems[0].PrinterTypes) +} + +// Follows an order from the database row to the JSON the POS reads. +func TestOrderJSONCarriesPrinters(t *testing.T) { + paket := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"} + paket.SetPrinterTypes([]string{"kitchen", "bar"}) + order := &entities.Order{ + ID: uuid.New(), + OrderItems: []entities.OrderItem{{ID: uuid.New(), ProductID: paket.ID, Product: paket, Quantity: 1}}, + } + + body, err := json.Marshal(OrderModelToContract(mappers.OrderEntityToResponse(order))) + require.NoError(t, err) + + assert.Contains(t, string(body), `"printer_types":["kitchen","bar"]`) + assert.NotContains(t, string(body), `"printer_type":`, "printer_types replaced it") +} + +func TestOrderModelToContractCarriesPrinters(t *testing.T) { + result := OrderModelToContract(&models.OrderResponse{ + OrderItems: []models.OrderItemResponse{{PrinterTypes: []string{"kitchen", "bar"}}}, + }) + + require.Len(t, result.OrderItems, 1) + assert.Equal(t, []string{"kitchen", "bar"}, result.OrderItems[0].PrinterTypes) +} diff --git a/internal/transformer/product_transformer.go b/internal/transformer/product_transformer.go index 0a628f6..97a4ff4 100644 --- a/internal/transformer/product_transformer.go +++ b/internal/transformer/product_transformer.go @@ -61,7 +61,7 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr Cost: cost, BusinessType: businessType, ImageURL: req.ImageURL, - PrinterType: req.PrinterType, + PrinterTypes: req.PrinterTypes, PrintToChecker: req.PrintToChecker, UnitID: req.UnitID, SellBy: sellBy, @@ -91,7 +91,7 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up Price: req.Price, Cost: req.Cost, ImageURL: req.ImageURL, - PrinterType: req.PrinterType, + PrinterTypes: req.PrinterTypes, PrintToChecker: req.PrintToChecker, UnitID: req.UnitID, SellBy: req.SellBy, @@ -152,7 +152,7 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod Cost: prod.Cost, BusinessType: string(prod.BusinessType), ImageURL: prod.ImageURL, - PrinterType: prod.PrinterType, + PrinterTypes: prod.PrinterTypes, PrintToChecker: prod.PrintToChecker, UnitID: prod.UnitID, SellBy: prod.SellBy, diff --git a/internal/validator/product_validator.go b/internal/validator/product_validator.go index a5943bb..23b5e4c 100644 --- a/internal/validator/product_validator.go +++ b/internal/validator/product_validator.go @@ -59,8 +59,8 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode } - if req.PrinterType != nil && len(*req.PrinterType) > 50 { - return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode + if err, code := validatePrinterTypes(req.PrinterTypes); err != nil { + return err, code } if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil { @@ -70,6 +70,18 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create return nil, "" } +// validatePrinterTypes holds each printer to the 50 characters the single printer_type +// column allowed. +func validatePrinterTypes(printerTypes []string) (error, string) { + for _, printerType := range printerTypes { + if len(strings.TrimSpace(printerType)) > 50 { + return errors.New("each printer_types entry cannot exceed 50 characters"), constants.MalformedFieldErrorCode + } + } + + return nil, "" +} + // validateSellBy checks how a product is sold and that it carries what that choice // needs. A weight-based product without a unit would produce order lines with no unit // to print, so the receipt could show "4,2" with no idea of what. @@ -100,7 +112,7 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update // At least one field should be provided for update if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil && req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil && - req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil && + req.PrinterTypes == nil && req.PrintToChecker == nil && req.UnitID == nil && req.SellBy == nil && req.Metadata == nil && req.IsActive == nil { return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode } @@ -134,8 +146,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode } - if req.PrinterType != nil && len(*req.PrinterType) > 50 { - return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode + if err, code := validatePrinterTypes(req.PrinterTypes); err != nil { + return err, code } // Only the value is checked here. Whether the product ends up with a unit depends on diff --git a/internal/validator/product_validator_printer_types_test.go b/internal/validator/product_validator_printer_types_test.go new file mode 100644 index 0000000..558b2f1 --- /dev/null +++ b/internal/validator/product_validator_printer_types_test.go @@ -0,0 +1,35 @@ +package validator + +import ( + "strings" + "testing" + + "apskel-pos-be/internal/contract" +) + +func TestValidateProductRequestPrinterTypes(t *testing.T) { + v := NewProductValidator() + tooLong := strings.Repeat("a", 51) + + create := baseCreateRequest() + create.PrinterTypes = []string{"kitchen", "bar"} + if err, _ := v.ValidateCreateProductRequest(create); err != nil { + t.Fatalf("kitchen and bar should be accepted, got: %v", err) + } + + create.PrinterTypes = []string{"kitchen", tooLong} + if err, _ := v.ValidateCreateProductRequest(create); err == nil { + t.Fatal("expected an error for a printer longer than 50 characters") + } + + err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{PrinterTypes: []string{tooLong}}) + if err == nil { + t.Fatal("expected an error for a printer longer than 50 characters on update") + } + + // An update that only changes the printers is a real update. + err, _ = v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{PrinterTypes: []string{"kitchen", "bar"}}) + if err != nil { + t.Fatalf("an update of only printer_types should be accepted, got: %v", err) + } +} diff --git a/migrations/000099_add_printer_types_to_products.down.sql b/migrations/000099_add_printer_types_to_products.down.sql new file mode 100644 index 0000000..36d9968 --- /dev/null +++ b/migrations/000099_add_printer_types_to_products.down.sql @@ -0,0 +1,8 @@ +-- Only the first printer fits back into printer_type; the extra stations are lost. +ALTER TABLE products ADD COLUMN printer_type VARCHAR(50) DEFAULT 'kitchen'; + +UPDATE products SET printer_type = COALESCE(printer_types->>0, ''); + +CREATE INDEX idx_products_printer_type ON products(printer_type); + +ALTER TABLE products DROP COLUMN printer_types; diff --git a/migrations/000099_add_printer_types_to_products.up.sql b/migrations/000099_add_printer_types_to_products.up.sql new file mode 100644 index 0000000..8dfc29b --- /dev/null +++ b/migrations/000099_add_printer_types_to_products.up.sql @@ -0,0 +1,12 @@ +-- A product can be prepared at more than one station: a meal-and-drink package goes to +-- the kitchen and to the bar. printer_types replaces the single printer_type. +ALTER TABLE products ADD COLUMN printer_types JSONB NOT NULL DEFAULT '["kitchen"]'; + +UPDATE products +SET printer_types = CASE + WHEN COALESCE(printer_type, '') = '' THEN '[]'::jsonb + ELSE jsonb_build_array(printer_type) +END; + +-- Dropping the column drops idx_products_printer_type with it. +ALTER TABLE products DROP COLUMN printer_type; diff --git a/postman.json b/postman.json index dfc9647..68173bd 100644 --- a/postman.json +++ b/postman.json @@ -500,7 +500,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"name\": \"Cappuccino\",\n \"description\": \"Classic Italian coffee drink\",\n \"category_id\": \"{{category_id}}\",\n \"sku\": \"CAP001\",\n \"barcode\": \"1234567890123\",\n \"price\": 4.50,\n \"cost\": 1.20,\n \"is_active\": true,\n \"has_variants\": false,\n \"image_url\": \"https://example.com/cappuccino.jpg\",\n \"printer_type\": \"kitchen\"\n}" + "raw": "{\n \"name\": \"Cappuccino\",\n \"description\": \"Classic Italian coffee drink\",\n \"category_id\": \"{{category_id}}\",\n \"sku\": \"CAP001\",\n \"barcode\": \"1234567890123\",\n \"price\": 4.50,\n \"cost\": 1.20,\n \"is_active\": true,\n \"has_variants\": false,\n \"image_url\": \"https://example.com/cappuccino.jpg\",\n \"printer_types\": [\"kitchen\"]\n}" }, "url": { "raw": "{{base_url}}/api/v1/products", @@ -563,7 +563,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"name\": \"Premium Cappuccino\",\n \"description\": \"Premium Italian coffee drink with extra foam\",\n \"price\": 5.50,\n \"cost\": 1.50,\n \"is_active\": true,\n \"image_url\": \"https://example.com/premium-cappuccino.jpg\",\n \"printer_type\": \"kitchen\"\n}" + "raw": "{\n \"name\": \"Premium Cappuccino\",\n \"description\": \"Premium Italian coffee drink with extra foam\",\n \"price\": 5.50,\n \"cost\": 1.50,\n \"is_active\": true,\n \"image_url\": \"https://example.com/premium-cappuccino.jpg\",\n \"printer_types\": [\"kitchen\"]\n}" }, "url": { "raw": "{{base_url}}/api/v1/products/{{product_id}}",